Skip to main content

everruns_runtime/
real_disk.rs

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