Skip to main content

everruns_host/
real_disk.rs

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