Skip to main content

vv_agent/workspace/
local.rs

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}