1use std::any::Any;
2use std::fs;
3use std::io::{Error, ErrorKind, Write};
4use std::path::{Path, PathBuf};
5
6use sha2::{Digest, Sha256};
7
8use super::{
9 absolutize_path, artifacts::is_reserved_artifact_path, exclusive_workspace_path,
10 expand_home_path, glob_match, normalize_path_lexically, normalize_workspace_path,
11 normalized_glob_pattern, path_to_posix, suffix_with_dot, system_time_to_utc_isoformat,
12 FileInfo, WorkspaceBackend,
13};
14
15const PRIVATE_ARTIFACT_ROOT_ENV: &str = "VV_AGENT_PRIVATE_ARTIFACT_ROOT";
16const PRIVATE_ARTIFACT_ROOT_NAME: &str = "vv-agent-artifacts";
17
18#[derive(Debug, Clone)]
19pub struct LocalWorkspaceBackend {
20 pub root: PathBuf,
21 pub allow_outside_root: bool,
22 artifact_root: PathBuf,
23}
24
25impl LocalWorkspaceBackend {
26 pub fn new(root: impl Into<PathBuf>) -> Self {
27 let root = root.into();
28 let normalized_root = root
29 .canonicalize()
30 .unwrap_or_else(|_| absolutize_path(&root));
31 Self {
32 root,
33 allow_outside_root: false,
34 artifact_root: private_artifact_root(&normalized_root),
35 }
36 }
37
38 fn resolve_path(&self, path: &str) -> std::io::Result<PathBuf> {
39 let root = self.normalized_root();
40 let candidate = expand_home_path(path);
41 let target = if candidate.is_absolute() {
42 candidate
43 } else {
44 root.join(&candidate)
45 };
46 let normalized = resolve_existing_or_parent(&target)?;
47 if !self.allow_outside_root && normalized != root && !normalized.starts_with(&root) {
48 return Err(Error::new(
49 ErrorKind::PermissionDenied,
50 format!("Path escapes workspace: {path}"),
51 ));
52 }
53 Ok(normalized)
54 }
55
56 fn normalized_root(&self) -> PathBuf {
57 self.root
58 .canonicalize()
59 .unwrap_or_else(|_| absolutize_path(&self.root))
60 }
61
62 fn output_path(&self, path: &Path) -> String {
63 let root = self.normalized_root();
64 if let Ok(relative) = path.strip_prefix(&root) {
65 let output = path_to_posix(relative);
66 if output.is_empty() {
67 ".".to_string()
68 } else {
69 output
70 }
71 } else {
72 path.to_string_lossy().to_string()
73 }
74 }
75
76 fn artifact_segments(&self, path: &str) -> std::io::Result<Option<Vec<String>>> {
77 if Path::new(path).is_absolute() || path.starts_with('\\') {
78 return Ok(None);
79 }
80 let normalized = normalize_workspace_path(path);
81 if !is_reserved_artifact_path(&normalized) {
82 return Ok(None);
83 }
84 let canonical = exclusive_workspace_path(path)?;
85 Ok(Some(canonical.split('/').map(str::to_string).collect()))
86 }
87
88 fn resolve_artifact_path(&self, path: &str) -> std::io::Result<Option<(PathBuf, String)>> {
89 let Some(segments) = self.artifact_segments(path)? else {
90 return Ok(None);
91 };
92 ensure_existing_private_directory(&self.artifact_root)?;
93 let mut target = self.artifact_root.clone();
94 for segment in segments {
95 target.push(segment);
96 match fs::symlink_metadata(&target) {
97 Ok(metadata) if metadata.file_type().is_symlink() => {
98 return Err(Error::new(ErrorKind::InvalidInput, "artifact_path_invalid"));
99 }
100 Ok(_) => {}
101 Err(error) if error.kind() == ErrorKind::NotFound => {}
102 Err(error) => return Err(error),
103 }
104 }
105 Ok(Some((target, normalize_workspace_path(path))))
106 }
107
108 fn resolve_read_path(&self, path: &str) -> std::io::Result<(PathBuf, Option<String>)> {
109 if let Some((target, logical_path)) = self.resolve_artifact_path(path)? {
110 return Ok((target, Some(logical_path)));
111 }
112 Ok((self.resolve_path(path)?, None))
113 }
114
115 fn ensure_private_artifact_root(&self) -> std::io::Result<()> {
116 let Some(base) = self.artifact_root.parent() else {
117 return Err(Error::new(ErrorKind::InvalidInput, "artifact_path_invalid"));
118 };
119 ensure_private_directory(base)?;
120 ensure_private_directory(&self.artifact_root)
121 }
122
123 fn is_reserved_target(&self, target: &Path) -> bool {
124 target
125 .strip_prefix(self.normalized_root())
126 .ok()
127 .map(path_to_posix)
128 .is_some_and(|path| is_reserved_artifact_path(&path))
129 }
130}
131
132fn resolve_existing_or_parent(path: &Path) -> std::io::Result<PathBuf> {
133 if path.exists() {
134 return path.canonicalize();
135 }
136 let parent = path.parent().unwrap_or_else(|| Path::new("."));
137 let resolved_parent = if parent.exists() {
138 parent.canonicalize()?
139 } else {
140 normalize_path_lexically(parent.to_path_buf())
141 };
142 Ok(match path.file_name() {
143 Some(file_name) => resolved_parent.join(file_name),
144 None => resolved_parent,
145 })
146}
147
148fn private_artifact_root(workspace_root: &Path) -> PathBuf {
149 let base = std::env::var_os(PRIVATE_ARTIFACT_ROOT_ENV)
150 .filter(|value| !value.is_empty())
151 .map(PathBuf::from)
152 .unwrap_or_else(|| std::env::temp_dir().join(PRIVATE_ARTIFACT_ROOT_NAME));
153 let digest = format!(
154 "{:x}",
155 Sha256::digest(workspace_root.to_string_lossy().as_bytes())
156 );
157 base.join(digest)
158}
159
160fn ensure_existing_private_directory(path: &Path) -> std::io::Result<()> {
161 match fs::symlink_metadata(path) {
162 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
163 Err(Error::new(ErrorKind::InvalidInput, "artifact_path_invalid"))
164 }
165 Ok(_) => Ok(()),
166 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
167 Err(error) => Err(error),
168 }
169}
170
171fn ensure_private_directory(path: &Path) -> std::io::Result<()> {
172 fs::create_dir_all(path)?;
173 let metadata = fs::symlink_metadata(path)?;
174 if metadata.file_type().is_symlink() || !metadata.is_dir() {
175 return Err(Error::new(ErrorKind::InvalidInput, "artifact_path_invalid"));
176 }
177 #[cfg(unix)]
178 {
179 use std::os::unix::fs::PermissionsExt;
180
181 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
182 }
183 Ok(())
184}
185
186impl WorkspaceBackend for LocalWorkspaceBackend {
187 fn as_any(&self) -> &dyn Any {
188 self
189 }
190
191 fn list_files(&self, base: &str, glob: &str) -> std::io::Result<Vec<String>> {
192 if self.artifact_segments(base)?.is_some() {
193 return Ok(Vec::new());
194 }
195 let root = self.resolve_path(base)?;
196 let mut files = Vec::new();
197 if root.exists() && root.is_dir() {
198 let pattern = normalized_glob_pattern(glob);
199 for entry in walk_recursive(&root)? {
200 if entry.is_file() {
201 let Ok(relative_from_base) = entry.strip_prefix(&root) else {
202 continue;
203 };
204 if !glob_match(&path_to_posix(relative_from_base), &pattern) {
205 continue;
206 }
207 let path = self.output_path(&entry);
208 if !is_reserved_artifact_path(&path) {
209 files.push(path);
210 }
211 }
212 }
213 }
214 files.sort();
215 Ok(files)
216 }
217
218 fn read_text(&self, path: &str) -> std::io::Result<String> {
219 let (path, _) = self.resolve_read_path(path)?;
220 let bytes = fs::read(path)?;
221 Ok(String::from_utf8_lossy(&bytes).to_string())
222 }
223
224 fn read_bytes(&self, path: &str) -> std::io::Result<Vec<u8>> {
225 let (path, _) = self.resolve_read_path(path)?;
226 fs::read(path)
227 }
228
229 fn write_text(&self, path: &str, content: &str, append: bool) -> std::io::Result<usize> {
230 if is_reserved_artifact_path(path) {
231 return Err(Error::new(
232 ErrorKind::PermissionDenied,
233 "artifact paths are immutable",
234 ));
235 }
236 let target = self.resolve_path(path)?;
237 if self.is_reserved_target(&target) {
238 return Err(Error::new(
239 ErrorKind::PermissionDenied,
240 "artifact paths are immutable",
241 ));
242 }
243 if let Some(parent) = target.parent() {
244 fs::create_dir_all(parent)?;
245 }
246 if append {
247 use std::io::Write;
248 let mut file = fs::OpenOptions::new()
249 .create(true)
250 .append(true)
251 .open(target)?;
252 file.write_all(content.as_bytes())?;
253 Ok(content.len())
254 } else {
255 fs::write(&target, content)?;
256 Ok(content.len())
257 }
258 }
259
260 fn write_text_exclusive(&self, path: &str, content: &str) -> std::io::Result<usize> {
261 let mut chunks = std::iter::once(Ok(content.to_string()));
262 self.write_text_chunks_exclusive(path, &mut chunks)
263 }
264
265 fn write_text_chunks_exclusive(
266 &self,
267 path: &str,
268 chunks: &mut dyn Iterator<Item = std::io::Result<String>>,
269 ) -> std::io::Result<usize> {
270 let root = if self.artifact_segments(path)?.is_some() {
271 self.ensure_private_artifact_root()?;
272 self.artifact_root.clone()
273 } else {
274 let root = self.normalized_root();
275 fs::create_dir_all(&root)?;
276 root
277 };
278 write_text_chunks_exclusive_below(&root, path, chunks)
279 }
280
281 fn file_info(&self, path: &str) -> std::io::Result<Option<FileInfo>> {
282 let (target, logical_path) = self.resolve_read_path(path)?;
283 if !target.exists() {
284 return Ok(None);
285 }
286 let metadata = fs::metadata(&target)?;
287 let modified_at = metadata
288 .modified()
289 .map(system_time_to_utc_isoformat)
290 .unwrap_or_else(|_| system_time_to_utc_isoformat(std::time::SystemTime::UNIX_EPOCH));
291 Ok(Some(FileInfo {
292 path: logical_path.unwrap_or_else(|| self.output_path(&target)),
293 is_file: metadata.is_file(),
294 is_dir: metadata.is_dir(),
295 size: metadata.len(),
296 modified_at,
297 suffix: suffix_with_dot(&target.to_string_lossy()),
298 }))
299 }
300
301 fn exists(&self, path: &str) -> bool {
302 self.resolve_read_path(path)
303 .map(|(path, _)| path.exists())
304 .unwrap_or(false)
305 }
306
307 fn is_file(&self, path: &str) -> bool {
308 self.resolve_read_path(path)
309 .map(|(path, _)| path.is_file())
310 .unwrap_or(false)
311 }
312
313 fn mkdir(&self, path: &str) -> std::io::Result<()> {
314 if is_reserved_artifact_path(path) {
315 return Err(Error::new(
316 ErrorKind::PermissionDenied,
317 "artifact paths are immutable",
318 ));
319 }
320 let target = self.resolve_path(path)?;
321 if self.is_reserved_target(&target) {
322 return Err(Error::new(
323 ErrorKind::PermissionDenied,
324 "artifact paths are immutable",
325 ));
326 }
327 fs::create_dir_all(target)
328 }
329}
330
331#[cfg(unix)]
332fn write_text_chunks_exclusive_below(
333 root: &Path,
334 path: &str,
335 chunks: &mut dyn Iterator<Item = std::io::Result<String>>,
336) -> std::io::Result<usize> {
337 use std::ffi::CString;
338 use std::os::fd::{AsRawFd, FromRawFd};
339
340 let canonical = exclusive_workspace_path(path)?;
341 let segments = canonical.split('/').collect::<Vec<_>>();
342 let mut directory = fs::File::open(root)?;
343 for segment in &segments[..segments.len() - 1] {
344 let segment = CString::new(*segment)
345 .map_err(|_| Error::new(ErrorKind::InvalidInput, "path contains NUL"))?;
346 let created = unsafe { libc::mkdirat(directory.as_raw_fd(), segment.as_ptr(), 0o700) };
347 if created == -1 {
348 let error = Error::last_os_error();
349 if error.kind() != ErrorKind::AlreadyExists {
350 return Err(error);
351 }
352 }
353 let fd = unsafe {
354 libc::openat(
355 directory.as_raw_fd(),
356 segment.as_ptr(),
357 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
358 )
359 };
360 if fd == -1 {
361 let error = Error::last_os_error();
362 if matches!(error.raw_os_error(), Some(libc::ELOOP | libc::ENOTDIR)) {
363 return Err(Error::new(
364 ErrorKind::InvalidInput,
365 "exclusive path traverses a symlink or non-directory segment",
366 ));
367 }
368 return Err(error);
369 }
370 directory = unsafe { fs::File::from_raw_fd(fd) };
371 }
372
373 let filename = CString::new(*segments.last().expect("non-empty segments"))
374 .map_err(|_| Error::new(ErrorKind::InvalidInput, "path contains NUL"))?;
375 let mut temporary_name = None;
376 let mut temporary_file = None;
377 for _ in 0..32 {
378 let candidate = CString::new(format!(
379 ".vv-agent-write-{}.tmp",
380 uuid::Uuid::new_v4().simple()
381 ))
382 .expect("UUID temporary filename contains no NUL");
383 let fd = unsafe {
384 libc::openat(
385 directory.as_raw_fd(),
386 candidate.as_ptr(),
387 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
388 0o600,
389 )
390 };
391 if fd != -1 {
392 temporary_name = Some(candidate);
393 temporary_file = Some(unsafe { fs::File::from_raw_fd(fd) });
394 break;
395 }
396 let error = Error::last_os_error();
397 if error.kind() != ErrorKind::AlreadyExists {
398 return Err(error);
399 }
400 }
401 let temporary_name = temporary_name.ok_or_else(|| {
402 Error::new(
403 ErrorKind::AlreadyExists,
404 "could not allocate an exclusive temporary artifact path",
405 )
406 })?;
407 let mut file = temporary_file.expect("temporary file accompanies its name");
408 let write_result = write_chunks(&mut file, chunks).and_then(|written| {
409 file.sync_all()?;
410 Ok(written)
411 });
412 drop(file);
413 let written = match write_result {
414 Ok(written) => written,
415 Err(error) => {
416 unsafe {
417 libc::unlinkat(directory.as_raw_fd(), temporary_name.as_ptr(), 0);
418 }
419 return Err(error);
420 }
421 };
422
423 let published = unsafe {
424 libc::linkat(
425 directory.as_raw_fd(),
426 temporary_name.as_ptr(),
427 directory.as_raw_fd(),
428 filename.as_ptr(),
429 0,
430 )
431 };
432 if published == -1 {
433 let error = Error::last_os_error();
434 unsafe {
435 libc::unlinkat(directory.as_raw_fd(), temporary_name.as_ptr(), 0);
436 }
437 return Err(error);
438 }
439 let unlinked = unsafe { libc::unlinkat(directory.as_raw_fd(), temporary_name.as_ptr(), 0) };
440 if unlinked == -1 {
441 let error = Error::last_os_error();
442 unsafe {
443 libc::unlinkat(directory.as_raw_fd(), filename.as_ptr(), 0);
444 libc::unlinkat(directory.as_raw_fd(), temporary_name.as_ptr(), 0);
445 }
446 return Err(error);
447 }
448 if let Err(error) = directory.sync_all() {
449 unsafe {
450 libc::unlinkat(directory.as_raw_fd(), filename.as_ptr(), 0);
451 }
452 return Err(error);
453 }
454 Ok(written)
455}
456
457#[cfg(not(unix))]
458fn write_text_chunks_exclusive_below(
459 root: &Path,
460 path: &str,
461 chunks: &mut dyn Iterator<Item = std::io::Result<String>>,
462) -> std::io::Result<usize> {
463 let canonical = exclusive_workspace_path(path)?;
464 let segments = canonical.split('/').collect::<Vec<_>>();
465 let mut parent = root.to_path_buf();
466 for segment in &segments[..segments.len() - 1] {
467 parent.push(segment);
468 match fs::symlink_metadata(&parent) {
469 Ok(metadata) if metadata.file_type().is_symlink() => {
470 return Err(Error::new(
471 ErrorKind::InvalidInput,
472 "exclusive path contains a symlink",
473 ));
474 }
475 Ok(metadata) if !metadata.is_dir() => {
476 return Err(Error::new(
477 ErrorKind::InvalidInput,
478 "exclusive path parent is not a directory",
479 ));
480 }
481 Ok(_) => {}
482 Err(error) if error.kind() == ErrorKind::NotFound => fs::create_dir(&parent)?,
483 Err(error) => return Err(error),
484 }
485 }
486 let target = parent.join(segments.last().expect("non-empty segments"));
487 let mut temporary_path = None;
488 let mut temporary_file = None;
489 for _ in 0..32 {
490 let candidate = parent.join(format!(
491 ".vv-agent-write-{}.tmp",
492 uuid::Uuid::new_v4().simple()
493 ));
494 match fs::OpenOptions::new()
495 .create_new(true)
496 .write(true)
497 .open(&candidate)
498 {
499 Ok(file) => {
500 temporary_path = Some(candidate);
501 temporary_file = Some(file);
502 break;
503 }
504 Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
505 Err(error) => return Err(error),
506 }
507 }
508 let temporary_path = temporary_path.ok_or_else(|| {
509 Error::new(
510 ErrorKind::AlreadyExists,
511 "could not allocate an exclusive temporary artifact path",
512 )
513 })?;
514 let mut file = temporary_file.expect("temporary file accompanies its path");
515 let write_result = write_chunks(&mut file, chunks).and_then(|written| {
516 file.sync_all()?;
517 Ok(written)
518 });
519 drop(file);
520 let written = match write_result {
521 Ok(written) => written,
522 Err(error) => {
523 let _ = fs::remove_file(&temporary_path);
524 return Err(error);
525 }
526 };
527 if let Err(error) = fs::hard_link(&temporary_path, &target) {
528 let _ = fs::remove_file(&temporary_path);
529 return Err(error);
530 }
531 if let Err(error) = fs::remove_file(&temporary_path) {
532 let _ = fs::remove_file(&target);
533 let _ = fs::remove_file(&temporary_path);
534 return Err(error);
535 }
536 if let Err(error) = fs::File::open(&parent).and_then(|directory| directory.sync_all()) {
537 let _ = fs::remove_file(&target);
538 return Err(error);
539 }
540 Ok(written)
541}
542
543fn write_chunks(
544 file: &mut fs::File,
545 chunks: &mut dyn Iterator<Item = std::io::Result<String>>,
546) -> std::io::Result<usize> {
547 let mut written = 0usize;
548 for chunk in chunks {
549 let chunk = chunk?;
550 written = written
551 .checked_add(chunk.len())
552 .ok_or_else(|| Error::new(ErrorKind::InvalidData, "artifact output is too large"))?;
553 file.write_all(chunk.as_bytes())?;
554 }
555 Ok(written)
556}
557
558fn walk_recursive(root: &Path) -> std::io::Result<Vec<PathBuf>> {
559 let mut stack = vec![root.to_path_buf()];
560 let mut entries = Vec::new();
561 while let Some(path) = stack.pop() {
562 let reader = match fs::read_dir(&path) {
563 Ok(reader) => reader,
564 Err(error) if path != root => {
565 if error.kind() == ErrorKind::PermissionDenied {
566 continue;
567 }
568 return Err(error);
569 }
570 Err(error) => return Err(error),
571 };
572 for entry in reader {
573 let entry = entry?;
574 let entry_path = entry.path();
575 if entry_path.is_dir() {
576 stack.push(entry_path.clone());
577 }
578 entries.push(entry_path);
579 }
580 }
581 Ok(entries)
582}