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