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