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