Skip to main content

everruns_runtime/
real_disk.rs

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