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