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