Skip to main content

everruns_host/
real_disk.rs

1// Real-disk host SessionFileSystem implementation.
2//
3// Rationale: built-in capabilities (`file_system`, `agent_instructions`,
4// `skills`, ...) read and write through `SessionFileSystem`. For non-server
5// embedders like the coding-CLI, the workspace is a real directory on disk,
6// not the in-memory VFS. `RealDiskSessionFileSystemFactory` lets the platform
7// resolve a `RealDiskFileStore` rooted at a workspace path.
8//
9// See `knowledge/runtime-resources/file-store.md` for the contract, path-namespace rules, and the
10// forward-compatibility plan with the mount-overlay resolver (Option B).
11
12use async_trait::async_trait;
13use chrono::{DateTime, TimeZone, Utc};
14use everruns_core::error::{AgentLoopError, Result};
15use everruns_core::session_file::{
16    FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
17    build_grep_search_result,
18};
19use everruns_core::traits::{
20    SessionFileSystem, SessionFileSystemFactory, SessionFileSystemFactoryContext,
21};
22use everruns_core::typed_id::SessionId;
23use everruns_core::{MountFs, WorkspaceRootSet};
24use ignore::WalkBuilder;
25use std::collections::HashSet;
26use std::path::{Component, Path, PathBuf};
27use std::sync::Arc;
28use std::time::SystemTime;
29use tokio::sync::RwLock;
30use uuid::Uuid;
31
32/// A `SessionFileSystem` rooted at a real host directory.
33///
34/// Paths are interpreted per the session filesystem namespace rules (leading `/`,
35/// optional `/workspace` prefix, `..` rejected anywhere). `session_id` is
36/// accepted on every method but ignored — the store is single-workspace per
37/// process. See `knowledge/runtime-resources/file-store.md` for the multi-tenant upgrade path.
38///
39/// `is_readonly` flags from `seed_initial_file` are tracked in an in-memory
40/// set (the disk backend has no place to persist them), so writes and
41/// deletes through this store still honor the trait contract within a
42/// single process. The flag is *not* mapped onto filesystem permissions —
43/// other host processes can still modify the file directly.
44#[derive(Debug, Clone)]
45pub struct RealDiskFileStore {
46    /// Maps the virtual workspace namespace onto this host directory (EVE-660):
47    /// `/workspace` alias and host-absolute aliases, `..` rejection, containment,
48    /// and host-absolute display. The root is shared (Arc) so an embedder's
49    /// worktree switch via `set_host_root` is seen by every clone of the store.
50    paths: HostPathMap,
51    readonly: Arc<RwLock<HashSet<String>>>,
52}
53
54/// Factory for real-disk session files rooted at a fixed host directory.
55#[derive(Debug, Clone)]
56pub struct RealDiskSessionFileSystemFactory {
57    root: PathBuf,
58}
59
60impl RealDiskSessionFileSystemFactory {
61    pub fn new(root: impl Into<PathBuf>) -> Self {
62        Self { root: root.into() }
63    }
64}
65
66#[async_trait]
67impl SessionFileSystemFactory for RealDiskSessionFileSystemFactory {
68    fn name(&self) -> &'static str {
69        "RealDiskSessionFileSystemFactory"
70    }
71
72    async fn create_session_file_system(
73        &self,
74        context: SessionFileSystemFactoryContext,
75    ) -> Result<Arc<dyn SessionFileSystem>> {
76        if let Some(root_set) = context.workspace_roots() {
77            return multi_root_file_system(&root_set);
78        }
79        Ok(Arc::new(RealDiskFileStore::new(self.root.clone())?))
80    }
81}
82
83pub fn multi_root_file_system(root_set: &WorkspaceRootSet) -> Result<Arc<dyn SessionFileSystem>> {
84    let primary = Arc::new(RealDiskFileStore::new(root_set.primary_host_root())?);
85    let mut fs = MountFs::new(primary);
86    for root in &root_set.additional {
87        let store = Arc::new(RealDiskFileStore::new(&root.path)?);
88        fs = fs.with_mount(
89            WorkspaceRootSet::additional_mount_point(&root.name),
90            store,
91            "/",
92        );
93    }
94    Ok(Arc::new(fs))
95}
96
97impl RealDiskFileStore {
98    /// Create a new real-disk store rooted at `root`.
99    ///
100    /// The root is canonicalized once at construction time. Any operation
101    /// whose canonical-form path would escape the root is rejected.
102    pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
103        Ok(Self {
104            paths: HostPathMap::new(root)?,
105            readonly: Arc::new(RwLock::new(HashSet::new())),
106        })
107    }
108
109    async fn is_readonly(&self, canonical_path: &str) -> bool {
110        self.readonly.read().await.contains(canonical_path)
111    }
112
113    async fn mark_readonly(&self, canonical_path: String, readonly: bool) {
114        let mut guard = self.readonly.write().await;
115        if readonly {
116            guard.insert(canonical_path);
117        } else {
118            guard.remove(&canonical_path);
119        }
120    }
121
122    /// The current canonicalized workspace root.
123    pub fn root(&self) -> PathBuf {
124        self.paths.root()
125    }
126
127    /// Repoint the workspace root, e.g. when an embedder switches worktrees.
128    ///
129    /// The root handle is shared, so every clone of this store immediately
130    /// addresses the new root. See EVE-660.
131    pub fn set_host_root(&self, root: impl Into<PathBuf>) -> Result<()> {
132        self.paths.set_root(root)
133    }
134
135    /// Resolve a capability-facing path to an absolute host path.
136    ///
137    /// All parsing (alias stripping, traversal rejection, host-absolute alias
138    /// handling, containment) is delegated to [`WorkspacePaths`]. Symlink
139    /// containment is checked by `reject_symlink_path` at each filesystem access
140    /// so missing write targets can still be created safely.
141    fn resolve(&self, path: &str) -> Result<PathBuf> {
142        let rel = self.paths.parse_input(path)?;
143        self.paths.to_host(&rel)
144    }
145
146    /// Reject symlinks anywhere in the resolved path before performing real
147    /// disk I/O. File operations are LLM-controlled in embedded runtimes, so
148    /// following workspace symlinks would bypass the workspace boundary and
149    /// any lexical write policies layered above this store. Missing components
150    /// are allowed so callers can create new files/directories after all
151    /// existing ancestors have been checked.
152    async fn reject_symlink_path(&self, absolute: &Path) -> Result<()> {
153        // THREAT[TM-FS-016]: recheck every existing component immediately
154        // before I/O. This catches swaps between operations; the local provider
155        // is not an OS sandbox against a same-user process racing the final
156        // path-based syscall.
157        let root = self.root();
158        let relative = absolute.strip_prefix(&root).map_err(|_| {
159            AgentLoopError::tool(format!(
160                "path is outside workspace root: {}",
161                absolute.display()
162            ))
163        })?;
164
165        let mut current = root.clone();
166        for component in relative.components() {
167            match component {
168                Component::Normal(segment) => current.push(segment),
169                _ => {
170                    return Err(AgentLoopError::tool(format!(
171                        "unexpected path component in {}",
172                        absolute.display()
173                    )));
174                }
175            }
176
177            match tokio::fs::symlink_metadata(&current).await {
178                Ok(metadata) if metadata.file_type().is_symlink() => {
179                    return Err(AgentLoopError::tool(format!(
180                        "symlink paths are not allowed in real-disk workspace access: {}",
181                        current.display()
182                    )));
183                }
184                Ok(_) => {}
185                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
186                Err(e) => {
187                    return Err(AgentLoopError::tool(format!(
188                        "lstat failed for {}: {e}",
189                        current.display()
190                    )));
191                }
192            }
193        }
194        Ok(())
195    }
196
197    /// Map an absolute host path under the root back to its canonical
198    /// leading-slash session path (e.g. `/src/lib.rs`).
199    fn relative_capability_path(&self, absolute: &Path) -> Result<String> {
200        Ok(self.paths.relativize(absolute)?.to_session_path())
201    }
202}
203
204#[async_trait]
205impl SessionFileSystem for RealDiskFileStore {
206    /// A real-disk store shows where files actually live: the host-absolute root.
207    /// Filesystem decorators preserve this identity.
208    fn display_root(&self) -> String {
209        self.paths.display_root()
210    }
211
212    fn display_path(&self, path: &str) -> String {
213        // `display_path` receives canonical backend keys, not fresh user input.
214        // Keep a literal top-level `workspace` directory distinct from the
215        // `/workspace` input alias; routing still accepts that alias via
216        // `parse_input` for read/write/list operations.
217        match rel_from_str(path.trim()) {
218            Ok(rel) => self.paths.to_display(&rel),
219            Err(_) => path.to_string(),
220        }
221    }
222
223    fn resolve_path(&self, input: &str) -> String {
224        self.paths
225            .parse_input(input)
226            .map(|path| path.to_session_path())
227            .unwrap_or_else(|_| input.to_string())
228    }
229
230    fn is_mount_resolver(&self) -> bool {
231        false
232    }
233
234    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
235        // Clear any prior readonly mark so seeding always wins over a
236        // previous starter-file declaration with the same path.
237        let absolute = self.resolve(&file.path)?;
238        self.reject_symlink_path(&absolute).await?;
239        let canonical = self.relative_capability_path(&absolute)?;
240        self.mark_readonly(canonical.clone(), false).await;
241
242        self.write_file(session_id, &file.path, &file.content, &file.encoding)
243            .await?;
244        if file.is_readonly {
245            self.mark_readonly(canonical, true).await;
246        }
247        Ok(())
248    }
249
250    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
251        let absolute = self.resolve(path)?;
252        self.reject_symlink_path(&absolute).await?;
253        let metadata = match tokio::fs::metadata(&absolute).await {
254            Ok(m) => m,
255            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
256            Err(e) => {
257                return Err(AgentLoopError::tool(format!(
258                    "stat failed for {}: {e}",
259                    absolute.display()
260                )));
261            }
262        };
263
264        let canonical_path = self.relative_capability_path(&absolute)?;
265        let name = FileInfo::name_from_path(&canonical_path);
266        let id = path_id(&canonical_path);
267
268        let (created_at, updated_at) = file_times(&metadata);
269        let is_readonly = self.is_readonly(&canonical_path).await;
270
271        if metadata.is_dir() {
272            return Ok(Some(SessionFile {
273                id,
274                session_id: session_id.uuid(),
275                path: canonical_path,
276                name,
277                content: None,
278                encoding: "text".to_string(),
279                is_directory: true,
280                is_readonly: false,
281                size_bytes: 0,
282                created_at,
283                updated_at,
284            }));
285        }
286
287        let bytes = tokio::fs::read(&absolute).await.map_err(|e| {
288            AgentLoopError::tool(format!("read failed for {}: {e}", absolute.display()))
289        })?;
290        let size_bytes = saturating_i64(bytes.len() as u64);
291        let (content, encoding) = SessionFile::encode_content(&bytes);
292
293        Ok(Some(SessionFile {
294            id,
295            session_id: session_id.uuid(),
296            path: canonical_path,
297            name,
298            content: Some(content),
299            encoding,
300            is_directory: false,
301            is_readonly,
302            size_bytes,
303            created_at,
304            updated_at,
305        }))
306    }
307
308    async fn write_file(
309        &self,
310        session_id: SessionId,
311        path: &str,
312        content: &str,
313        encoding: &str,
314    ) -> Result<SessionFile> {
315        let absolute = self.resolve(path)?;
316        self.reject_symlink_path(&absolute).await?;
317        let canonical_path = self.relative_capability_path(&absolute)?;
318        if self.is_readonly(&canonical_path).await {
319            return Err(AgentLoopError::tool(format!(
320                "file is read-only: {canonical_path}"
321            )));
322        }
323        if let Some(parent) = absolute.parent() {
324            tokio::fs::create_dir_all(parent).await.map_err(|e| {
325                AgentLoopError::tool(format!("failed to create parent {}: {e}", parent.display()))
326            })?;
327        }
328
329        if let Ok(meta) = tokio::fs::metadata(&absolute).await
330            && meta.is_dir()
331        {
332            return Err(AgentLoopError::tool(format!(
333                "write target is a directory: {}",
334                absolute.display()
335            )));
336        }
337
338        let bytes = SessionFile::decode_content(content, encoding)
339            .map_err(|e| AgentLoopError::tool(format!("base64 decode failed for {path}: {e}")))?;
340        tokio::fs::write(&absolute, &bytes).await.map_err(|e| {
341            AgentLoopError::tool(format!("write failed for {}: {e}", absolute.display()))
342        })?;
343
344        let metadata = tokio::fs::metadata(&absolute).await.map_err(|e| {
345            AgentLoopError::tool(format!(
346                "post-write stat failed for {}: {e}",
347                absolute.display()
348            ))
349        })?;
350        let (created_at, updated_at) = file_times(&metadata);
351        let name = FileInfo::name_from_path(&canonical_path);
352        let id = path_id(&canonical_path);
353
354        Ok(SessionFile {
355            id,
356            session_id: session_id.uuid(),
357            path: canonical_path,
358            name,
359            content: Some(content.to_string()),
360            encoding: encoding.to_string(),
361            is_directory: false,
362            is_readonly: false,
363            size_bytes: saturating_i64(bytes.len() as u64),
364            created_at,
365            updated_at,
366        })
367    }
368
369    async fn delete_file(
370        &self,
371        _session_id: SessionId,
372        path: &str,
373        recursive: bool,
374    ) -> Result<bool> {
375        let absolute = self.resolve(path)?;
376        self.reject_symlink_path(&absolute).await?;
377        if absolute == self.root() {
378            return Err(AgentLoopError::tool(
379                "cannot delete workspace root".to_string(),
380            ));
381        }
382        let canonical_path = self.relative_capability_path(&absolute)?;
383        if self.is_readonly(&canonical_path).await {
384            return Err(AgentLoopError::tool(format!(
385                "file is read-only: {canonical_path}"
386            )));
387        }
388        let metadata = match tokio::fs::metadata(&absolute).await {
389            Ok(m) => m,
390            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
391            Err(e) => {
392                return Err(AgentLoopError::tool(format!(
393                    "stat failed for {}: {e}",
394                    absolute.display()
395                )));
396            }
397        };
398
399        if metadata.is_dir() {
400            if recursive {
401                tokio::fs::remove_dir_all(&absolute).await.map_err(|e| {
402                    AgentLoopError::tool(format!(
403                        "recursive delete failed for {}: {e}",
404                        absolute.display()
405                    ))
406                })?;
407            } else {
408                let mut read_dir = tokio::fs::read_dir(&absolute).await.map_err(|e| {
409                    AgentLoopError::tool(format!("read_dir failed for {}: {e}", absolute.display()))
410                })?;
411                if read_dir
412                    .next_entry()
413                    .await
414                    .map_err(|e| {
415                        AgentLoopError::tool(format!(
416                            "read_dir entry failed for {}: {e}",
417                            absolute.display()
418                        ))
419                    })?
420                    .is_some()
421                {
422                    return Ok(false);
423                }
424                tokio::fs::remove_dir(&absolute).await.map_err(|e| {
425                    AgentLoopError::tool(format!("rmdir failed for {}: {e}", absolute.display()))
426                })?;
427            }
428            return Ok(true);
429        }
430
431        tokio::fs::remove_file(&absolute).await.map_err(|e| {
432            AgentLoopError::tool(format!("delete failed for {}: {e}", absolute.display()))
433        })?;
434        Ok(true)
435    }
436
437    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
438        let absolute = self.resolve(path)?;
439        self.reject_symlink_path(&absolute).await?;
440        let metadata = match tokio::fs::metadata(&absolute).await {
441            Ok(m) => m,
442            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
443            Err(e) => {
444                return Err(AgentLoopError::tool(format!(
445                    "stat failed for {}: {e}",
446                    absolute.display()
447                )));
448            }
449        };
450        if !metadata.is_dir() {
451            return Ok(vec![]);
452        }
453
454        let mut read_dir = tokio::fs::read_dir(&absolute).await.map_err(|e| {
455            AgentLoopError::tool(format!("read_dir failed for {}: {e}", absolute.display()))
456        })?;
457        let mut entries = Vec::new();
458        while let Some(entry) = read_dir.next_entry().await.map_err(|e| {
459            AgentLoopError::tool(format!(
460                "read_dir entry failed for {}: {e}",
461                absolute.display()
462            ))
463        })? {
464            let entry_path = entry.path();
465            let canonical = self.relative_capability_path(&entry_path)?;
466            let entry_meta = match tokio::fs::symlink_metadata(&entry_path).await {
467                Ok(m) if m.file_type().is_symlink() => continue,
468                Ok(m) => m,
469                Err(_) => continue,
470            };
471            let (created_at, updated_at) = file_times(&entry_meta);
472            let is_dir = entry_meta.is_dir();
473            entries.push(FileInfo {
474                id: path_id(&canonical),
475                session_id: session_id.uuid(),
476                name: FileInfo::name_from_path(&canonical),
477                path: canonical,
478                is_directory: is_dir,
479                is_readonly: false,
480                size_bytes: if is_dir {
481                    0
482                } else {
483                    saturating_i64(entry_meta.len())
484                },
485                created_at,
486                updated_at,
487            });
488        }
489        entries.sort_by(|a, b| a.path.cmp(&b.path));
490        Ok(entries)
491    }
492
493    async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
494        let absolute = self.resolve(path)?;
495        self.reject_symlink_path(&absolute).await?;
496        let metadata = match tokio::fs::metadata(&absolute).await {
497            Ok(m) => m,
498            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
499            Err(e) => {
500                return Err(AgentLoopError::tool(format!(
501                    "stat failed for {}: {e}",
502                    absolute.display()
503                )));
504            }
505        };
506        let canonical = self.relative_capability_path(&absolute)?;
507        let name = FileInfo::name_from_path(&canonical);
508        let (created_at, updated_at) = file_times(&metadata);
509        let is_readonly = self.is_readonly(&canonical).await;
510        Ok(Some(FileStat {
511            path: canonical,
512            name,
513            is_directory: metadata.is_dir(),
514            is_readonly,
515            size_bytes: if metadata.is_dir() {
516                0
517            } else {
518                saturating_i64(metadata.len())
519            },
520            created_at,
521            updated_at,
522        }))
523    }
524
525    async fn grep_files(
526        &self,
527        _session_id: SessionId,
528        pattern: &str,
529        path_pattern: Option<&str>,
530    ) -> Result<Vec<GrepMatch>> {
531        let root = self.root();
532        let regex = crate::grep_limits::build_regex(pattern)?;
533        crate::grep_limits::validate_path_pattern(path_pattern)?;
534        let path_pattern = match path_pattern {
535            Some(path) => Some(everruns_core::session_path::GrepPathPattern::new(
536                self.paths.parse_input(path)?.as_relative(),
537            )?),
538            None => None,
539        };
540
541        // `ignore::WalkBuilder` is sync; reading file content per match is
542        // sync too. Push the whole walk onto `spawn_blocking` so we don't
543        // block the executor on large trees.
544        tokio::task::spawn_blocking(move || -> Result<Vec<GrepMatch>> {
545            let mut out = Vec::new();
546            let mut total_scanned = 0;
547            let walker = WalkBuilder::new(&root)
548                .hidden(false)
549                .git_ignore(true)
550                .git_global(false)
551                .git_exclude(true)
552                .build();
553            for entry in walker {
554                let entry = match entry {
555                    Ok(e) => e,
556                    Err(_) => continue,
557                };
558                let path = entry.path();
559                if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
560                    continue;
561                }
562                let relative = match path.strip_prefix(&root) {
563                    Ok(r) => r,
564                    Err(_) => continue,
565                };
566                // Skip non-UTF-8 paths rather than corrupting them with
567                // `to_string_lossy()`: `GrepMatch.path` must round-trip back
568                // through `resolve` for subsequent `read_file` calls.
569                let mut rel_str = String::new();
570                let mut ok = true;
571                let mut first = true;
572                for component in relative.components() {
573                    if let Component::Normal(seg) = component {
574                        if !first {
575                            rel_str.push('/');
576                        }
577                        first = false;
578                        match seg.to_str() {
579                            Some(s) => rel_str.push_str(s),
580                            None => {
581                                ok = false;
582                                break;
583                            }
584                        }
585                    } else {
586                        ok = false;
587                        break;
588                    }
589                }
590                if !ok {
591                    continue;
592                }
593                if let Some(filter) = &path_pattern
594                    && !filter.is_match(&rel_str)
595                {
596                    continue;
597                }
598                let Ok(metadata) = entry.metadata() else {
599                    continue;
600                };
601                let Ok(file_bytes) = usize::try_from(metadata.len()) else {
602                    continue;
603                };
604                if !crate::grep_limits::account_scan(&mut total_scanned, file_bytes)? {
605                    continue;
606                }
607                let bytes = match std::fs::read(path) {
608                    Ok(b) => b,
609                    Err(_) => continue,
610                };
611                if !SessionFile::is_text_content(&bytes) {
612                    continue;
613                }
614                let text = match std::str::from_utf8(&bytes) {
615                    Ok(s) => s,
616                    Err(_) => continue,
617                };
618                let canonical_path = format!("/{rel_str}");
619                for (idx, line) in text.lines().enumerate() {
620                    if regex.is_match(line) {
621                        out.push(GrepMatch {
622                            path: canonical_path.clone(),
623                            line_number: idx + 1,
624                            line: line.to_string(),
625                        });
626                    }
627                }
628            }
629            Ok(out)
630        })
631        .await
632        .map_err(|e| AgentLoopError::tool(format!("grep walk join failed: {e}")))?
633    }
634
635    async fn grep_files_with_options(
636        &self,
637        _session_id: SessionId,
638        pattern: &str,
639        options: &GrepOptions,
640    ) -> Result<GrepSearchResult> {
641        let root = self.root();
642        let regex = crate::grep_limits::build_regex(pattern)?;
643        crate::grep_limits::validate_path_pattern(options.path_pattern.as_deref())?;
644        let path_pattern = match options.path_pattern.as_deref() {
645            Some(path) => Some(everruns_core::session_path::GrepPathPattern::new(
646                self.paths.parse_input(path)?.as_relative(),
647            )?),
648            None => None,
649        };
650        let options = options.clone();
651
652        tokio::task::spawn_blocking(move || -> Result<GrepSearchResult> {
653            let mut text_files = Vec::new();
654            let mut total_scanned = 0;
655            let walker = WalkBuilder::new(&root)
656                .hidden(false)
657                .git_ignore(true)
658                .git_global(false)
659                .git_exclude(true)
660                .build();
661            for entry in walker.filter_map(std::result::Result::ok) {
662                if !entry.file_type().is_some_and(|kind| kind.is_file()) {
663                    continue;
664                }
665                let Ok(relative) = entry.path().strip_prefix(&root) else {
666                    continue;
667                };
668                let Some(rel_str) = relative.to_str() else {
669                    continue;
670                };
671                let rel_str = rel_str.replace(std::path::MAIN_SEPARATOR, "/");
672                if path_pattern
673                    .as_ref()
674                    .is_some_and(|matcher| !matcher.is_match(&rel_str))
675                {
676                    continue;
677                }
678                let Ok(metadata) = entry.metadata() else {
679                    continue;
680                };
681                let Ok(file_bytes) = usize::try_from(metadata.len()) else {
682                    continue;
683                };
684                if !crate::grep_limits::account_scan(&mut total_scanned, file_bytes)? {
685                    continue;
686                }
687                let Ok(bytes) = std::fs::read(entry.path()) else {
688                    continue;
689                };
690                if !SessionFile::is_text_content(&bytes) {
691                    continue;
692                }
693                let Ok(text) = String::from_utf8(bytes) else {
694                    continue;
695                };
696                text_files.push((format!("/{rel_str}"), text));
697            }
698            Ok(build_grep_search_result(text_files, &regex, &options))
699        })
700        .await
701        .map_err(|error| AgentLoopError::tool(format!("grep walk join failed: {error}")))?
702    }
703
704    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
705        let absolute = self.resolve(path)?;
706        self.reject_symlink_path(&absolute).await?;
707        tokio::fs::create_dir_all(&absolute).await.map_err(|e| {
708            AgentLoopError::tool(format!(
709                "create_dir_all failed for {}: {e}",
710                absolute.display()
711            ))
712        })?;
713        let metadata = tokio::fs::metadata(&absolute).await.map_err(|e| {
714            AgentLoopError::tool(format!("stat failed for {}: {e}", absolute.display()))
715        })?;
716        let canonical = self.relative_capability_path(&absolute)?;
717        let (created_at, updated_at) = file_times(&metadata);
718        Ok(FileInfo {
719            id: path_id(&canonical),
720            session_id: session_id.uuid(),
721            name: FileInfo::name_from_path(&canonical),
722            path: canonical,
723            is_directory: true,
724            is_readonly: false,
725            size_bytes: 0,
726            created_at,
727            updated_at,
728        })
729    }
730}
731
732fn path_id(canonical_path: &str) -> Uuid {
733    // Stable, deterministic IDs derived from the canonical path. The disk
734    // backend has no other persistent identifier; consumers that rely on a
735    // SessionFile.id should still see the same UUID on subsequent reads.
736    Uuid::new_v5(&Uuid::NAMESPACE_OID, canonical_path.as_bytes())
737}
738
739fn file_times(metadata: &std::fs::Metadata) -> (DateTime<Utc>, DateTime<Utc>) {
740    let modified = metadata
741        .modified()
742        .ok()
743        .and_then(system_time_to_utc)
744        .unwrap_or_else(Utc::now);
745    let created = metadata
746        .created()
747        .ok()
748        .and_then(system_time_to_utc)
749        .unwrap_or(modified);
750    (created, modified)
751}
752
753fn system_time_to_utc(time: SystemTime) -> Option<DateTime<Utc>> {
754    let duration = time.duration_since(SystemTime::UNIX_EPOCH).ok()?;
755    Utc.timestamp_opt(duration.as_secs() as i64, duration.subsec_nanos())
756        .single()
757}
758
759/// Saturating `u64 -> i64` cast. The `SessionFile` trait fixes size as
760/// `i64`; files larger than 9 EiB are not realistically reachable through
761/// this code path, but the explicit cap makes the wrap intent obvious.
762fn saturating_i64(value: u64) -> i64 {
763    if value > i64::MAX as u64 {
764        i64::MAX
765    } else {
766        value as i64
767    }
768}
769
770// ============================================================================
771// HostPathMap — virtual workspace namespace ⇄ this host directory
772// ============================================================================
773//
774// EVE-660 demoted the old shared `WorkspacePaths` abstraction to what it always
775// was: a detail of the host-backed store. `MountFs` owns the *virtual* namespace
776// (mounts, cwd, `/workspace`); the only thing that genuinely needs a host root
777// is the real-disk backend, so the mapper lives here, private to it. Pure-VFS
778// stores need none of this — they key directly on the session path.
779
780/// A canonical workspace-relative path: forward-slash separated, no leading
781/// slash, no `.`/`..`, no host prefix. The workspace root is the empty path.
782#[derive(Clone, Debug, PartialEq, Eq, Default)]
783struct RelPath(String);
784
785impl RelPath {
786    fn is_root(&self) -> bool {
787        self.0.is_empty()
788    }
789
790    fn as_relative(&self) -> &str {
791        &self.0
792    }
793
794    /// The leading-slash session path the `SessionFileSystem` contract uses.
795    fn to_session_path(&self) -> String {
796        if self.0.is_empty() {
797            "/".to_string()
798        } else {
799            format!("/{}", self.0)
800        }
801    }
802}
803
804/// Maps the virtual workspace namespace onto a host directory. The root is
805/// shared via `Arc<RwLock<_>>` so a worktree switch propagates to every clone.
806#[derive(Debug, Clone)]
807struct HostPathMap {
808    root: Arc<std::sync::RwLock<PathBuf>>,
809}
810
811impl HostPathMap {
812    fn new(root: impl Into<PathBuf>) -> Result<Self> {
813        Ok(Self {
814            root: Arc::new(std::sync::RwLock::new(canonicalize_root(root.into())?)),
815        })
816    }
817
818    fn root(&self) -> PathBuf {
819        self.root.read().expect("host root lock poisoned").clone()
820    }
821
822    fn set_root(&self, root: impl Into<PathBuf>) -> Result<()> {
823        let canonical = canonicalize_root(root.into())?;
824        *self.root.write().expect("host root lock poisoned") = canonical;
825        Ok(())
826    }
827
828    /// Parse any accepted spelling into a canonical [`RelPath`]:
829    ///   * relative `src/foo`, absolute session `/src/foo`
830    ///   * the `/workspace` alias, `/workspace/src/foo`
831    ///   * host-absolute under the root (`<root>/src/foo`) — same canonical path
832    ///
833    /// Rejects `..` traversal anywhere and host-absolute paths outside the root.
834    fn parse_input(&self, input: &str) -> Result<RelPath> {
835        let trimmed = input.trim();
836
837        // Host-absolute paths under the root are aliases for the same canonical
838        // path (e.g. a model echoing the real checkout path).
839        let candidate = Path::new(trimmed);
840        if candidate.is_absolute()
841            && let Ok(relative) = candidate.strip_prefix(self.root())
842        {
843            return rel_from_path(relative);
844        }
845
846        // Otherwise normalize the `/workspace` alias to a session path and split.
847        let session = everruns_core::session_path::to_session_path(trimmed);
848        rel_from_str(&session)
849    }
850
851    /// Canonical path → absolute host path, rejecting any escape from the root.
852    fn to_host(&self, path: &RelPath) -> Result<PathBuf> {
853        let root = self.root();
854        if path.is_root() {
855            return Ok(root);
856        }
857        let candidate = root.join(path.as_relative());
858        if !candidate.starts_with(&root) {
859            return Err(AgentLoopError::tool(format!(
860                "path escapes workspace root: {}",
861                path.as_relative()
862            )));
863        }
864        Ok(candidate)
865    }
866
867    /// Host path under the root → canonical, if contained.
868    fn relativize(&self, host: &Path) -> Result<RelPath> {
869        let relative = host.strip_prefix(self.root()).map_err(|_| {
870            AgentLoopError::tool(format!(
871                "path is outside workspace root: {}",
872                host.display()
873            ))
874        })?;
875        rel_from_path(relative)
876    }
877
878    /// The host-absolute display root.
879    fn display_root(&self) -> String {
880        self.root().display().to_string()
881    }
882
883    /// Canonical path → host-absolute display string.
884    fn to_display(&self, path: &RelPath) -> String {
885        let root = self.root();
886        if path.is_root() {
887            return root.display().to_string();
888        }
889        root.join(path.as_relative()).display().to_string()
890    }
891}
892
893/// Normalize a slash-separated string into a [`RelPath`], rejecting traversal.
894fn rel_from_str(s: &str) -> Result<RelPath> {
895    let mut segments = Vec::new();
896    for part in s.split('/') {
897        match part {
898            "" | "." => {}
899            ".." => {
900                return Err(AgentLoopError::tool(format!(
901                    "path traversal rejected: {s}"
902                )));
903            }
904            segment => segments.push(segment),
905        }
906    }
907    Ok(RelPath(segments.join("/")))
908}
909
910/// Normalize a host-relative `Path` into a [`RelPath`], rejecting traversal and
911/// non-UTF-8 components. `.` segments are skipped so host aliases like
912/// `<root>/./src/lib.rs` resolve cleanly.
913fn rel_from_path(relative: &Path) -> Result<RelPath> {
914    let mut segments = Vec::new();
915    for component in relative.components() {
916        match component {
917            Component::CurDir => {}
918            Component::Normal(seg) => {
919                let segment = seg.to_str().ok_or_else(|| {
920                    AgentLoopError::tool(format!(
921                        "non-UTF-8 path component: {}",
922                        relative.display()
923                    ))
924                })?;
925                segments.push(segment.to_string());
926            }
927            Component::ParentDir => {
928                return Err(AgentLoopError::tool(format!(
929                    "path traversal rejected: {}",
930                    relative.display()
931                )));
932            }
933            Component::RootDir | Component::Prefix(_) => {
934                return Err(AgentLoopError::tool(format!(
935                    "absolute path component rejected: {}",
936                    relative.display()
937                )));
938            }
939        }
940    }
941    Ok(RelPath(segments.join("/")))
942}
943
944fn canonicalize_root(root: PathBuf) -> Result<PathBuf> {
945    if !root.exists() {
946        return Err(AgentLoopError::config(format!(
947            "workspace directory does not exist: {}",
948            root.display()
949        )));
950    }
951    let canonical = std::fs::canonicalize(&root).map_err(|e| {
952        AgentLoopError::config(format!(
953            "failed to canonicalize workspace root {}: {e}",
954            root.display()
955        ))
956    })?;
957    if !canonical.is_dir() {
958        return Err(AgentLoopError::config(format!(
959            "workspace root is not a directory: {}",
960            canonical.display()
961        )));
962    }
963    Ok(canonical)
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use tempfile::TempDir;
970
971    fn make_store() -> (RealDiskFileStore, TempDir) {
972        let dir = TempDir::new().expect("tempdir");
973        let store = RealDiskFileStore::new(dir.path()).expect("store");
974        (store, dir)
975    }
976
977    fn sid() -> SessionId {
978        SessionId::new()
979    }
980
981    #[tokio::test]
982    async fn multi_root_reads_writes_lists_and_greps() {
983        let primary = TempDir::new().unwrap();
984        let backend = TempDir::new().unwrap();
985        let root_set = WorkspaceRootSet::new(
986            primary.path(),
987            [("backend".to_string(), backend.path().to_path_buf())],
988        )
989        .unwrap();
990        let store = multi_root_file_system(&root_set).unwrap();
991        let session = sid();
992
993        let primary_file = store
994            .write_file(session, "/workspace/README.md", "needle primary", "text")
995            .await
996            .unwrap();
997        assert_eq!(primary_file.path, "/README.md");
998        assert_eq!(
999            std::fs::read_to_string(primary.path().join("README.md")).unwrap(),
1000            "needle primary"
1001        );
1002
1003        let backend_file = store
1004            .write_file(
1005                session,
1006                "/workspace/roots/backend/Cargo.toml",
1007                "needle backend",
1008                "text",
1009            )
1010            .await
1011            .unwrap();
1012        assert_eq!(backend_file.path, "/workspace/roots/backend/Cargo.toml");
1013        assert_eq!(
1014            std::fs::read_to_string(backend.path().join("Cargo.toml")).unwrap(),
1015            "needle backend"
1016        );
1017
1018        let listed = store
1019            .list_directory(session, "/workspace/roots/backend")
1020            .await
1021            .unwrap();
1022        assert_eq!(listed.len(), 1);
1023        assert_eq!(listed[0].path, "/workspace/roots/backend/Cargo.toml");
1024
1025        let matches = store.grep_files(session, "needle", None).await.unwrap();
1026        let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
1027        assert_eq!(
1028            paths,
1029            vec![
1030                "/README.md".to_string(),
1031                "/workspace/roots/backend/Cargo.toml".to_string()
1032            ]
1033        );
1034    }
1035
1036    #[tokio::test]
1037    async fn multi_root_escape_attempts_fail() {
1038        let primary = TempDir::new().unwrap();
1039        let backend = TempDir::new().unwrap();
1040        let root_set = WorkspaceRootSet::new(
1041            primary.path(),
1042            [("backend".to_string(), backend.path().to_path_buf())],
1043        )
1044        .unwrap();
1045        let store = multi_root_file_system(&root_set).unwrap();
1046
1047        let err = store
1048            .write_file(
1049                sid(),
1050                "/workspace/roots/backend/../../outside.txt",
1051                "nope",
1052                "text",
1053            )
1054            .await
1055            .unwrap_err();
1056        assert!(err.to_string().contains("path traversal rejected"));
1057    }
1058
1059    #[tokio::test]
1060    async fn multi_root_blocklist_applies_to_every_root() {
1061        let primary = TempDir::new().unwrap();
1062        let backend = TempDir::new().unwrap();
1063        let root_set = WorkspaceRootSet::new(
1064            primary.path(),
1065            [("backend".to_string(), backend.path().to_path_buf())],
1066        )
1067        .unwrap();
1068        let inner = multi_root_file_system(&root_set).unwrap();
1069        let store: Arc<dyn SessionFileSystem> =
1070            Arc::new(crate::WriteBlocklistFileStore::new(inner));
1071
1072        let primary_err = store
1073            .write_file(sid(), "/workspace/target/out.txt", "nope", "text")
1074            .await
1075            .unwrap_err();
1076        assert!(primary_err.to_string().contains("write blocklist rejected"));
1077
1078        let backend_err = store
1079            .write_file(
1080                sid(),
1081                "/workspace/roots/backend/node_modules/pkg.js",
1082                "nope",
1083                "text",
1084            )
1085            .await
1086            .unwrap_err();
1087        assert!(backend_err.to_string().contains("write blocklist rejected"));
1088    }
1089
1090    #[tokio::test]
1091    async fn factory_context_root_set_repoints_only_primary() {
1092        let configured = TempDir::new().unwrap();
1093        let primary = TempDir::new().unwrap();
1094        let backend = TempDir::new().unwrap();
1095        let root_set = WorkspaceRootSet::new(
1096            primary.path(),
1097            [("backend".to_string(), backend.path().to_path_buf())],
1098        )
1099        .unwrap();
1100        let factory = RealDiskSessionFileSystemFactory::new(configured.path());
1101        let store = factory
1102            .create_session_file_system(
1103                SessionFileSystemFactoryContext::new().with_workspace_roots(Arc::new(root_set)),
1104            )
1105            .await
1106            .unwrap();
1107
1108        store
1109            .write_file(sid(), "/workspace/primary.txt", "primary", "text")
1110            .await
1111            .unwrap();
1112        store
1113            .write_file(
1114                sid(),
1115                "/workspace/roots/backend/backend.txt",
1116                "backend",
1117                "text",
1118            )
1119            .await
1120            .unwrap();
1121
1122        assert!(!configured.path().join("primary.txt").exists());
1123        assert_eq!(
1124            std::fs::read_to_string(primary.path().join("primary.txt")).unwrap(),
1125            "primary"
1126        );
1127        assert_eq!(
1128            std::fs::read_to_string(backend.path().join("backend.txt")).unwrap(),
1129            "backend"
1130        );
1131    }
1132
1133    #[tokio::test]
1134    async fn round_trip_text_file() {
1135        let (store, _dir) = make_store();
1136        let session = sid();
1137        let written = store
1138            .write_file(session, "/notes.md", "# hello", "text")
1139            .await
1140            .expect("write");
1141        assert_eq!(written.path, "/notes.md");
1142        assert_eq!(written.encoding, "text");
1143
1144        let read = store
1145            .read_file(session, "/notes.md")
1146            .await
1147            .expect("read")
1148            .expect("present");
1149        assert_eq!(read.content.as_deref(), Some("# hello"));
1150        assert_eq!(read.encoding, "text");
1151        assert_eq!(read.size_bytes, 7);
1152        assert!(!read.is_directory);
1153    }
1154
1155    #[tokio::test]
1156    async fn round_trip_binary_file() {
1157        let (store, _dir) = make_store();
1158        let session = sid();
1159        let bytes = [0x89u8, b'P', b'N', b'G', 0, 1, 2, 3];
1160        let (encoded, encoding) = SessionFile::encode_content(&bytes);
1161        assert_eq!(encoding, "base64");
1162
1163        store
1164            .write_file(session, "/img.bin", &encoded, &encoding)
1165            .await
1166            .expect("write");
1167
1168        let read = store
1169            .read_file(session, "/img.bin")
1170            .await
1171            .expect("read")
1172            .expect("present");
1173        assert_eq!(read.encoding, "base64");
1174        let decoded = SessionFile::decode_content(read.content.as_deref().unwrap(), &read.encoding)
1175            .expect("decode");
1176        assert_eq!(decoded, bytes);
1177    }
1178
1179    #[tokio::test]
1180    async fn workspace_prefix_normalized() {
1181        let (store, _dir) = make_store();
1182        let session = sid();
1183        store
1184            .write_file(session, "/workspace/sub/dir/file.txt", "hi", "text")
1185            .await
1186            .expect("write");
1187
1188        let via_canonical = store
1189            .read_file(session, "/sub/dir/file.txt")
1190            .await
1191            .expect("read")
1192            .expect("present");
1193        let via_workspace = store
1194            .read_file(session, "/workspace/sub/dir/file.txt")
1195            .await
1196            .expect("read")
1197            .expect("present");
1198        assert_eq!(via_canonical.content, via_workspace.content);
1199        assert_eq!(via_canonical.path, "/sub/dir/file.txt");
1200    }
1201
1202    #[tokio::test]
1203    async fn real_disk_display_paths_use_host_root() {
1204        let (store, dir) = make_store();
1205        let root = std::fs::canonicalize(dir.path()).expect("canonical tempdir");
1206
1207        assert_eq!(store.display_root(), root.display().to_string());
1208        assert_eq!(
1209            store.display_path("/sub/dir/file.txt"),
1210            root.join("sub/dir/file.txt").display().to_string()
1211        );
1212    }
1213
1214    #[tokio::test]
1215    async fn host_absolute_paths_under_root_are_workspace_aliases() {
1216        let (store, _dir) = make_store();
1217        let session = sid();
1218        let host_path = store.display_path("/sub/dir/file.txt");
1219
1220        store
1221            .write_file(session, &host_path, "hi", "text")
1222            .await
1223            .expect("write via host path");
1224
1225        let via_workspace = store
1226            .read_file(session, "/workspace/sub/dir/file.txt")
1227            .await
1228            .expect("read")
1229            .expect("present");
1230        assert_eq!(via_workspace.content.as_deref(), Some("hi"));
1231        assert_eq!(via_workspace.path, "/sub/dir/file.txt");
1232    }
1233
1234    #[tokio::test]
1235    async fn host_absolute_aliases_allow_current_dir_segments() {
1236        let (store, _dir) = make_store();
1237        let session = sid();
1238        let host_path = Path::new(&store.display_root())
1239            .join("./sub/dir/file.txt")
1240            .display()
1241            .to_string();
1242
1243        store
1244            .write_file(session, &host_path, "hi", "text")
1245            .await
1246            .expect("write via host path");
1247
1248        let via_workspace = store
1249            .read_file(session, "/workspace/sub/dir/file.txt")
1250            .await
1251            .expect("read")
1252            .expect("present");
1253        assert_eq!(via_workspace.content.as_deref(), Some("hi"));
1254        assert_eq!(via_workspace.path, "/sub/dir/file.txt");
1255    }
1256
1257    #[tokio::test]
1258    async fn grep_path_pattern_accepts_host_absolute_path_alias() {
1259        let (store, _dir) = make_store();
1260        let session = sid();
1261        store
1262            .write_file(session, "/src/lib.rs", "needle", "text")
1263            .await
1264            .expect("write src");
1265        store
1266            .write_file(session, "/docs/readme.md", "needle", "text")
1267            .await
1268            .expect("write docs");
1269        let host_filter = store.display_path("/src");
1270
1271        let matches = store
1272            .grep_files(session, "needle", Some(&host_filter))
1273            .await
1274            .expect("grep");
1275
1276        assert_eq!(matches.len(), 1);
1277        assert_eq!(matches[0].path, "/src/lib.rs");
1278    }
1279
1280    #[tokio::test]
1281    async fn grep_path_pattern_supports_globs() {
1282        let (store, _dir) = make_store();
1283        let session = sid();
1284        for path in [
1285            "/src/lib.rs",
1286            "/src/nested/mod.rs",
1287            "/docs/readme.md",
1288            "/docs/nested/guide.md",
1289            "/notes.txt",
1290            "/nested/notes.txt",
1291        ] {
1292            store
1293                .write_file(session, path, "needle", "text")
1294                .await
1295                .expect("write fixture");
1296        }
1297
1298        let cases = [
1299            ("src/**/*.rs", vec!["/src/lib.rs", "/src/nested/mod.rs"]),
1300            (
1301                "**/*",
1302                vec![
1303                    "/docs/nested/guide.md",
1304                    "/docs/readme.md",
1305                    "/nested/notes.txt",
1306                    "/notes.txt",
1307                    "/src/lib.rs",
1308                    "/src/nested/mod.rs",
1309                ],
1310            ),
1311            ("docs/*", vec!["/docs/readme.md"]),
1312            ("*.txt", vec!["/nested/notes.txt", "/notes.txt"]),
1313            (
1314                "/workspace/src/**/*.rs",
1315                vec!["/src/lib.rs", "/src/nested/mod.rs"],
1316            ),
1317        ];
1318
1319        for (path_pattern, expected) in cases {
1320            let mut paths: Vec<_> = store
1321                .grep_files(session, "needle", Some(path_pattern))
1322                .await
1323                .expect("grep")
1324                .into_iter()
1325                .map(|hit| hit.path)
1326                .collect();
1327            paths.sort();
1328            assert_eq!(paths, expected, "path_pattern={path_pattern}");
1329        }
1330
1331        let host_pattern = Path::new(&store.display_root())
1332            .join("src/**/*.rs")
1333            .display()
1334            .to_string();
1335        let mut paths: Vec<_> = store
1336            .grep_files(session, "needle", Some(&host_pattern))
1337            .await
1338            .expect("host-absolute glob")
1339            .into_iter()
1340            .map(|hit| hit.path)
1341            .collect();
1342        paths.sort();
1343        assert_eq!(paths, vec!["/src/lib.rs", "/src/nested/mod.rs"]);
1344    }
1345
1346    #[tokio::test]
1347    async fn path_traversal_rejected() {
1348        let (store, _dir) = make_store();
1349        let session = sid();
1350        let err = store
1351            .read_file(session, "/../outside.txt")
1352            .await
1353            .expect_err("must reject traversal");
1354        let msg = format!("{err}");
1355        assert!(msg.contains("traversal"), "got: {msg}");
1356
1357        let err = store
1358            .write_file(session, "/foo/../../etc/passwd", "x", "text")
1359            .await
1360            .expect_err("must reject traversal");
1361        let msg = format!("{err}");
1362        assert!(msg.contains("traversal"), "got: {msg}");
1363    }
1364
1365    #[cfg(unix)]
1366    #[tokio::test]
1367    async fn read_file_rejects_symlink_to_outside_workspace() {
1368        let (store, dir) = make_store();
1369        let outside = TempDir::new().expect("outside tempdir");
1370        std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1371        std::fs::create_dir(dir.path().join("docs")).unwrap();
1372        std::os::unix::fs::symlink(outside.path(), dir.path().join("docs/secret")).unwrap();
1373
1374        let err = store
1375            .read_file(sid(), "/docs/secret/secret.txt")
1376            .await
1377            .expect_err("symlink read must be rejected");
1378        let msg = format!("{err}");
1379        assert!(msg.contains("symlink"), "got: {msg}");
1380    }
1381
1382    #[cfg(unix)]
1383    #[tokio::test]
1384    async fn list_directory_rejects_symlink_to_outside_workspace() {
1385        let (store, dir) = make_store();
1386        let outside = TempDir::new().expect("outside tempdir");
1387        std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1388        std::os::unix::fs::symlink(outside.path(), dir.path().join("secret_dir")).unwrap();
1389
1390        let err = store
1391            .list_directory(sid(), "/secret_dir")
1392            .await
1393            .expect_err("symlink list must be rejected");
1394        let msg = format!("{err}");
1395        assert!(msg.contains("symlink"), "got: {msg}");
1396    }
1397
1398    #[cfg(unix)]
1399    #[tokio::test]
1400    async fn write_file_rejects_symlink_parent() {
1401        let (store, dir) = make_store();
1402        let outside = TempDir::new().expect("outside tempdir");
1403        std::os::unix::fs::symlink(outside.path(), dir.path().join("outlink")).unwrap();
1404
1405        let err = store
1406            .write_file(sid(), "/outlink/owned.txt", "owned", "text")
1407            .await
1408            .expect_err("symlink write must be rejected");
1409        let msg = format!("{err}");
1410        assert!(msg.contains("symlink"), "got: {msg}");
1411        assert!(!outside.path().join("owned.txt").exists());
1412    }
1413
1414    #[cfg(unix)]
1415    #[tokio::test]
1416    async fn list_directory_skips_symlink_children() {
1417        let (store, dir) = make_store();
1418        let outside = TempDir::new().expect("outside tempdir");
1419        std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1420        std::os::unix::fs::symlink(
1421            outside.path().join("secret.txt"),
1422            dir.path().join("link.txt"),
1423        )
1424        .unwrap();
1425        store
1426            .write_file(sid(), "/safe.txt", "safe", "text")
1427            .await
1428            .unwrap();
1429
1430        let entries = store.list_directory(sid(), "/").await.unwrap();
1431        let paths: Vec<&str> = entries.iter().map(|entry| entry.path.as_str()).collect();
1432        assert!(paths.contains(&"/safe.txt"));
1433        assert!(!paths.contains(&"/link.txt"));
1434    }
1435
1436    #[tokio::test]
1437    async fn list_directory_returns_children() {
1438        let (store, _dir) = make_store();
1439        let session = sid();
1440        store
1441            .write_file(session, "/a.txt", "1", "text")
1442            .await
1443            .unwrap();
1444        store
1445            .write_file(session, "/sub/b.txt", "2", "text")
1446            .await
1447            .unwrap();
1448        store
1449            .write_file(session, "/sub/c.txt", "3", "text")
1450            .await
1451            .unwrap();
1452
1453        let root = store.list_directory(session, "/").await.unwrap();
1454        let paths: Vec<&str> = root.iter().map(|f| f.path.as_str()).collect();
1455        assert!(paths.contains(&"/a.txt"));
1456        assert!(paths.contains(&"/sub"));
1457
1458        let sub = store.list_directory(session, "/sub").await.unwrap();
1459        let sub_paths: Vec<&str> = sub.iter().map(|f| f.path.as_str()).collect();
1460        assert_eq!(sub_paths, vec!["/sub/b.txt", "/sub/c.txt"]);
1461    }
1462
1463    #[tokio::test]
1464    async fn grep_finds_matches_and_respects_ignore_files() {
1465        let (store, dir) = make_store();
1466        let session = sid();
1467        // The `ignore` crate honors `.ignore` files unconditionally; it
1468        // honors `.gitignore` only inside a real git repo, which we don't
1469        // need for this test. Both files are walked by `WalkBuilder`.
1470        std::fs::write(dir.path().join(".ignore"), "ignored.txt\n").unwrap();
1471        store
1472            .write_file(
1473                session,
1474                "/src.rs",
1475                "fn needle() {}\nfn other() {}\n",
1476                "text",
1477            )
1478            .await
1479            .unwrap();
1480        store
1481            .write_file(session, "/ignored.txt", "needle\n", "text")
1482            .await
1483            .unwrap();
1484
1485        let hits = store.grep_files(session, "needle", None).await.unwrap();
1486        let hit_paths: Vec<&str> = hits.iter().map(|m| m.path.as_str()).collect();
1487        assert!(hit_paths.contains(&"/src.rs"));
1488        assert!(!hit_paths.contains(&"/ignored.txt"));
1489
1490        let filtered = store
1491            .grep_files(session, "needle", Some(".rs"))
1492            .await
1493            .unwrap();
1494        assert!(filtered.iter().all(|m| m.path.ends_with(".rs")));
1495    }
1496
1497    #[tokio::test]
1498    async fn cas_rejects_stale_writes() {
1499        let (store, _dir) = make_store();
1500        let session = sid();
1501        store
1502            .write_file(session, "/foo.txt", "v1", "text")
1503            .await
1504            .unwrap();
1505
1506        // Stale CAS — expects v0 content.
1507        let stale = store
1508            .write_file_if_content_matches(session, "/foo.txt", "v0", "text", "v2", "text")
1509            .await
1510            .unwrap();
1511        assert!(stale.is_none(), "stale CAS should not update");
1512
1513        let read = store.read_file(session, "/foo.txt").await.unwrap().unwrap();
1514        assert_eq!(read.content.as_deref(), Some("v1"));
1515
1516        // Matching CAS — updates.
1517        let updated = store
1518            .write_file_if_content_matches(session, "/foo.txt", "v1", "text", "v2", "text")
1519            .await
1520            .unwrap();
1521        assert!(updated.is_some(), "matching CAS should update");
1522        let read = store.read_file(session, "/foo.txt").await.unwrap().unwrap();
1523        assert_eq!(read.content.as_deref(), Some("v2"));
1524    }
1525
1526    #[tokio::test]
1527    async fn delete_non_recursive_fails_on_nonempty_dir() {
1528        let (store, _dir) = make_store();
1529        let session = sid();
1530        store
1531            .write_file(session, "/d/x.txt", "x", "text")
1532            .await
1533            .unwrap();
1534
1535        let removed = store.delete_file(session, "/d", false).await.unwrap();
1536        assert!(!removed, "non-recursive delete must refuse non-empty dir");
1537
1538        let removed = store.delete_file(session, "/d", true).await.unwrap();
1539        assert!(removed);
1540        let after = store.read_file(session, "/d/x.txt").await.unwrap();
1541        assert!(after.is_none());
1542    }
1543
1544    #[tokio::test]
1545    async fn seed_initial_file_persists() {
1546        let (store, _dir) = make_store();
1547        let session = sid();
1548        store
1549            .seed_initial_file(
1550                session,
1551                &InitialFile {
1552                    path: "/workspace/AGENTS.md".to_string(),
1553                    content: "# Project rules".to_string(),
1554                    encoding: "text".to_string(),
1555                    is_readonly: false,
1556                },
1557            )
1558            .await
1559            .unwrap();
1560
1561        let read = store
1562            .read_file(session, "/AGENTS.md")
1563            .await
1564            .unwrap()
1565            .unwrap();
1566        assert_eq!(read.content.as_deref(), Some("# Project rules"));
1567    }
1568
1569    #[tokio::test]
1570    async fn root_directory_resolves() {
1571        let (store, _dir) = make_store();
1572        let session = sid();
1573        let stat = store.stat_file(session, "/").await.unwrap().unwrap();
1574        assert!(stat.is_directory);
1575        assert_eq!(stat.path, "/");
1576    }
1577
1578    #[tokio::test]
1579    async fn rejects_missing_root() {
1580        let missing = std::env::temp_dir().join("everruns-nonexistent-xyz-12345");
1581        let _ = std::fs::remove_dir_all(&missing);
1582        let err = RealDiskFileStore::new(&missing).expect_err("must reject missing root");
1583        let msg = format!("{err}");
1584        assert!(msg.contains("does not exist"), "got: {msg}");
1585    }
1586
1587    #[tokio::test]
1588    async fn delete_root_returns_explicit_error() {
1589        let (store, _dir) = make_store();
1590        let session = sid();
1591        let err = store
1592            .delete_file(session, "/", true)
1593            .await
1594            .expect_err("root delete must be an explicit error, not Ok(false)");
1595        assert!(format!("{err}").contains("workspace root"));
1596    }
1597
1598    #[tokio::test]
1599    async fn seeded_readonly_file_rejects_writes() {
1600        let (store, _dir) = make_store();
1601        let session = sid();
1602        store
1603            .seed_initial_file(
1604                session,
1605                &InitialFile {
1606                    path: "/locked.txt".to_string(),
1607                    content: "starter".to_string(),
1608                    encoding: "text".to_string(),
1609                    is_readonly: true,
1610                },
1611            )
1612            .await
1613            .unwrap();
1614
1615        let read = store
1616            .read_file(session, "/locked.txt")
1617            .await
1618            .unwrap()
1619            .unwrap();
1620        assert!(read.is_readonly);
1621
1622        let err = store
1623            .write_file(session, "/locked.txt", "changed", "text")
1624            .await
1625            .expect_err("readonly write must fail");
1626        assert!(format!("{err}").contains("read-only"));
1627
1628        let err = store
1629            .delete_file(session, "/locked.txt", false)
1630            .await
1631            .expect_err("readonly delete must fail");
1632        assert!(format!("{err}").contains("read-only"));
1633    }
1634
1635    #[tokio::test]
1636    async fn reseeding_clears_readonly() {
1637        let (store, _dir) = make_store();
1638        let session = sid();
1639        store
1640            .seed_initial_file(
1641                session,
1642                &InitialFile {
1643                    path: "/foo.txt".to_string(),
1644                    content: "v1".to_string(),
1645                    encoding: "text".to_string(),
1646                    is_readonly: true,
1647                },
1648            )
1649            .await
1650            .unwrap();
1651        // Re-seed without readonly: subsequent writes must succeed.
1652        store
1653            .seed_initial_file(
1654                session,
1655                &InitialFile {
1656                    path: "/foo.txt".to_string(),
1657                    content: "v2".to_string(),
1658                    encoding: "text".to_string(),
1659                    is_readonly: false,
1660                },
1661            )
1662            .await
1663            .unwrap();
1664        store
1665            .write_file(session, "/foo.txt", "v3", "text")
1666            .await
1667            .unwrap();
1668    }
1669}