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