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