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