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