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