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