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