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