1use crate::{SessionFileSystemFactory, SessionFileSystemFactoryContext};
13use async_trait::async_trait;
14use chrono::{DateTime, TimeZone, Utc};
15use everruns_core::session_file::{
16 FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
17 build_grep_search_result,
18};
19use everruns_core::session_files::SessionFileSystem;
20use everruns_core::{MountFs, WorkspaceRootSet};
21use everruns_provider::error::{AgentLoopError, Result};
22use everruns_provider::typed_id::SessionId;
23use ignore::WalkBuilder;
24use std::collections::{HashMap, HashSet};
25use std::path::{Component, Path, PathBuf};
26use std::sync::{Arc, Mutex, OnceLock, Weak};
27use std::time::SystemTime;
28use tokio::sync::RwLock;
29use uuid::Uuid;
30
31fn shared_write_guard(root: &Path) -> Arc<tokio::sync::Mutex<()>> {
32 static GUARDS: OnceLock<Mutex<HashMap<PathBuf, Weak<tokio::sync::Mutex<()>>>>> =
33 OnceLock::new();
34 let mut guards = GUARDS
35 .get_or_init(|| Mutex::new(HashMap::new()))
36 .lock()
37 .unwrap_or_else(|poisoned| poisoned.into_inner());
38 if let Some(guard) = guards.get(root).and_then(Weak::upgrade) {
39 return guard;
40 }
41 guards.retain(|_, guard| guard.strong_count() > 0);
42 let guard = Arc::new(tokio::sync::Mutex::new(()));
43 guards.insert(root.to_path_buf(), Arc::downgrade(&guard));
44 guard
45}
46
47#[derive(Debug, Clone)]
60pub struct RealDiskFileStore {
61 paths: HostPathMap,
66 readonly: Arc<RwLock<HashSet<String>>>,
67}
68
69#[derive(Debug, Clone)]
71pub struct RealDiskSessionFileSystemFactory {
72 root: PathBuf,
73}
74
75impl RealDiskSessionFileSystemFactory {
76 pub fn new(root: impl Into<PathBuf>) -> Self {
77 Self { root: root.into() }
78 }
79}
80
81#[async_trait]
82impl SessionFileSystemFactory for RealDiskSessionFileSystemFactory {
83 fn name(&self) -> &'static str {
84 "RealDiskSessionFileSystemFactory"
85 }
86
87 async fn create_session_file_system(
88 &self,
89 context: SessionFileSystemFactoryContext,
90 ) -> Result<Arc<dyn SessionFileSystem>> {
91 if let Some(root_set) = context.workspace_roots() {
92 return multi_root_file_system(&root_set);
93 }
94 Ok(Arc::new(RealDiskFileStore::new(self.root.clone())?))
95 }
96}
97
98pub fn multi_root_file_system(root_set: &WorkspaceRootSet) -> Result<Arc<dyn SessionFileSystem>> {
99 let primary = Arc::new(RealDiskFileStore::new(root_set.primary_host_root())?);
100 let mut fs = MountFs::new(primary);
101 for root in &root_set.additional {
102 let store = Arc::new(RealDiskFileStore::new(&root.path)?);
103 fs = fs.with_mount(
104 WorkspaceRootSet::additional_mount_point(&root.name),
105 store,
106 "/",
107 );
108 }
109 Ok(Arc::new(fs))
110}
111
112impl RealDiskFileStore {
113 pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
118 Ok(Self {
119 paths: HostPathMap::new(root)?,
120 readonly: Arc::new(RwLock::new(HashSet::new())),
121 })
122 }
123
124 async fn is_readonly(&self, canonical_path: &str) -> bool {
125 self.readonly.read().await.contains(canonical_path)
126 }
127
128 async fn mark_readonly(&self, canonical_path: String, readonly: bool) {
129 let mut guard = self.readonly.write().await;
130 if readonly {
131 guard.insert(canonical_path);
132 } else {
133 guard.remove(&canonical_path);
134 }
135 }
136
137 pub fn root(&self) -> PathBuf {
139 self.paths.root()
140 }
141
142 pub fn set_host_root(&self, root: impl Into<PathBuf>) -> Result<()> {
147 self.paths.set_root(root)
148 }
149
150 fn resolve(&self, path: &str) -> Result<PathBuf> {
157 let rel = self.paths.parse_input(path)?;
158 self.paths.to_host(&rel)
159 }
160
161 async fn reject_symlink_path(&self, absolute: &Path) -> Result<()> {
168 let root = self.root();
173 let relative = absolute.strip_prefix(&root).map_err(|_| {
174 AgentLoopError::tool(format!(
175 "path is outside workspace root: {}",
176 absolute.display()
177 ))
178 })?;
179
180 let mut current = root.clone();
181 for component in relative.components() {
182 match component {
183 Component::Normal(segment) => current.push(segment),
184 _ => {
185 return Err(AgentLoopError::tool(format!(
186 "unexpected path component in {}",
187 absolute.display()
188 )));
189 }
190 }
191
192 match tokio::fs::symlink_metadata(¤t).await {
193 Ok(metadata) if metadata.file_type().is_symlink() => {
194 return Err(AgentLoopError::tool(format!(
195 "symlink paths are not allowed in real-disk workspace access: {}",
196 current.display()
197 )));
198 }
199 Ok(_) => {}
200 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
201 Err(e) => {
202 return Err(AgentLoopError::tool(format!(
203 "lstat failed for {}: {e}",
204 current.display()
205 )));
206 }
207 }
208 }
209 Ok(())
210 }
211
212 fn relative_capability_path(&self, absolute: &Path) -> Result<String> {
215 Ok(self.paths.relativize(absolute)?.to_session_path())
216 }
217
218 async fn write_file_unlocked(
219 &self,
220 session_id: SessionId,
221 path: &str,
222 content: &str,
223 encoding: &str,
224 ) -> Result<SessionFile> {
225 let absolute = self.resolve(path)?;
226 self.reject_symlink_path(&absolute).await?;
227 let canonical_path = self.relative_capability_path(&absolute)?;
228 if self.is_readonly(&canonical_path).await {
229 return Err(AgentLoopError::tool(format!(
230 "file is read-only: {canonical_path}"
231 )));
232 }
233 if let Some(parent) = absolute.parent() {
234 tokio::fs::create_dir_all(parent).await.map_err(|e| {
235 AgentLoopError::tool(format!("failed to create parent {}: {e}", parent.display()))
236 })?;
237 }
238
239 if let Ok(meta) = tokio::fs::metadata(&absolute).await
240 && meta.is_dir()
241 {
242 return Err(AgentLoopError::tool(format!(
243 "write target is a directory: {}",
244 absolute.display()
245 )));
246 }
247
248 let bytes = SessionFile::decode_content(content, encoding)
249 .map_err(|e| AgentLoopError::tool(format!("base64 decode failed for {path}: {e}")))?;
250 tokio::fs::write(&absolute, &bytes).await.map_err(|e| {
251 AgentLoopError::tool(format!("write failed for {}: {e}", absolute.display()))
252 })?;
253
254 let metadata = tokio::fs::metadata(&absolute).await.map_err(|e| {
255 AgentLoopError::tool(format!(
256 "post-write stat failed for {}: {e}",
257 absolute.display()
258 ))
259 })?;
260 let (created_at, updated_at) = file_times(&metadata);
261 let name = FileInfo::name_from_path(&canonical_path);
262 let id = path_id(&canonical_path);
263
264 Ok(SessionFile {
265 id,
266 session_id: session_id.uuid(),
267 path: canonical_path,
268 name,
269 content: Some(content.to_string()),
270 encoding: encoding.to_string(),
271 is_directory: false,
272 is_readonly: false,
273 size_bytes: saturating_i64(bytes.len() as u64),
274 created_at,
275 updated_at,
276 })
277 }
278}
279
280#[async_trait]
281impl SessionFileSystem for RealDiskFileStore {
282 fn display_root(&self) -> String {
285 self.paths.display_root()
286 }
287
288 fn display_path(&self, path: &str) -> String {
289 match rel_from_str(path.trim()) {
294 Ok(rel) => self.paths.to_display(&rel),
295 Err(_) => path.to_string(),
296 }
297 }
298
299 fn resolve_path(&self, input: &str) -> String {
300 self.paths
301 .parse_input(input)
302 .map(|path| path.to_session_path())
303 .unwrap_or_else(|_| input.to_string())
304 }
305
306 fn is_mount_resolver(&self) -> bool {
307 false
308 }
309
310 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
311 let write_guard = shared_write_guard(&self.paths.root());
312 let _guard = write_guard.lock().await;
313 let absolute = self.resolve(&file.path)?;
316 self.reject_symlink_path(&absolute).await?;
317 let canonical = self.relative_capability_path(&absolute)?;
318 self.mark_readonly(canonical.clone(), false).await;
319
320 self.write_file_unlocked(session_id, &file.path, &file.content, &file.encoding)
321 .await?;
322 if file.is_readonly {
323 self.mark_readonly(canonical, true).await;
324 }
325 Ok(())
326 }
327
328 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
329 let absolute = self.resolve(path)?;
330 self.reject_symlink_path(&absolute).await?;
331 let metadata = match tokio::fs::metadata(&absolute).await {
332 Ok(m) => m,
333 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
334 Err(e) => {
335 return Err(AgentLoopError::tool(format!(
336 "stat failed for {}: {e}",
337 absolute.display()
338 )));
339 }
340 };
341
342 let canonical_path = self.relative_capability_path(&absolute)?;
343 let name = FileInfo::name_from_path(&canonical_path);
344 let id = path_id(&canonical_path);
345
346 let (created_at, updated_at) = file_times(&metadata);
347 let is_readonly = self.is_readonly(&canonical_path).await;
348
349 if metadata.is_dir() {
350 return Ok(Some(SessionFile {
351 id,
352 session_id: session_id.uuid(),
353 path: canonical_path,
354 name,
355 content: None,
356 encoding: "text".to_string(),
357 is_directory: true,
358 is_readonly: false,
359 size_bytes: 0,
360 created_at,
361 updated_at,
362 }));
363 }
364
365 let bytes = tokio::fs::read(&absolute).await.map_err(|e| {
366 AgentLoopError::tool(format!("read failed for {}: {e}", absolute.display()))
367 })?;
368 let size_bytes = saturating_i64(bytes.len() as u64);
369 let (content, encoding) = SessionFile::encode_content(&bytes);
370
371 Ok(Some(SessionFile {
372 id,
373 session_id: session_id.uuid(),
374 path: canonical_path,
375 name,
376 content: Some(content),
377 encoding,
378 is_directory: false,
379 is_readonly,
380 size_bytes,
381 created_at,
382 updated_at,
383 }))
384 }
385
386 async fn write_file(
387 &self,
388 session_id: SessionId,
389 path: &str,
390 content: &str,
391 encoding: &str,
392 ) -> Result<SessionFile> {
393 let write_guard = shared_write_guard(&self.paths.root());
394 let _guard = write_guard.lock().await;
395 self.write_file_unlocked(session_id, path, content, encoding)
396 .await
397 }
398
399 async fn write_file_if_content_matches(
400 &self,
401 session_id: SessionId,
402 path: &str,
403 expected_content: &str,
404 expected_encoding: &str,
405 content: &str,
406 encoding: &str,
407 ) -> Result<Option<SessionFile>> {
408 let write_guard = shared_write_guard(&self.paths.root());
412 let _guard = write_guard.lock().await;
413 let Some(existing) = self.read_file(session_id, path).await? else {
414 return Ok(None);
415 };
416 if existing.is_directory
417 || existing.content.as_deref().unwrap_or_default() != expected_content
418 || existing.encoding != expected_encoding
419 {
420 return Ok(None);
421 }
422 self.write_file_unlocked(session_id, path, content, encoding)
423 .await
424 .map(Some)
425 }
426
427 async fn delete_file(
428 &self,
429 _session_id: SessionId,
430 path: &str,
431 recursive: bool,
432 ) -> Result<bool> {
433 let write_guard = shared_write_guard(&self.paths.root());
434 let _guard = write_guard.lock().await;
435 let absolute = self.resolve(path)?;
436 self.reject_symlink_path(&absolute).await?;
437 if absolute == self.root() {
438 return Err(AgentLoopError::tool(
439 "cannot delete workspace root".to_string(),
440 ));
441 }
442 let canonical_path = self.relative_capability_path(&absolute)?;
443 if self.is_readonly(&canonical_path).await {
444 return Err(AgentLoopError::tool(format!(
445 "file is read-only: {canonical_path}"
446 )));
447 }
448 let metadata = match tokio::fs::metadata(&absolute).await {
449 Ok(m) => m,
450 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
451 Err(e) => {
452 return Err(AgentLoopError::tool(format!(
453 "stat failed for {}: {e}",
454 absolute.display()
455 )));
456 }
457 };
458
459 if metadata.is_dir() {
460 if recursive {
461 tokio::fs::remove_dir_all(&absolute).await.map_err(|e| {
462 AgentLoopError::tool(format!(
463 "recursive delete failed for {}: {e}",
464 absolute.display()
465 ))
466 })?;
467 } else {
468 let mut read_dir = tokio::fs::read_dir(&absolute).await.map_err(|e| {
469 AgentLoopError::tool(format!("read_dir failed for {}: {e}", absolute.display()))
470 })?;
471 if read_dir
472 .next_entry()
473 .await
474 .map_err(|e| {
475 AgentLoopError::tool(format!(
476 "read_dir entry failed for {}: {e}",
477 absolute.display()
478 ))
479 })?
480 .is_some()
481 {
482 return Ok(false);
483 }
484 tokio::fs::remove_dir(&absolute).await.map_err(|e| {
485 AgentLoopError::tool(format!("rmdir failed for {}: {e}", absolute.display()))
486 })?;
487 }
488 return Ok(true);
489 }
490
491 tokio::fs::remove_file(&absolute).await.map_err(|e| {
492 AgentLoopError::tool(format!("delete failed for {}: {e}", absolute.display()))
493 })?;
494 Ok(true)
495 }
496
497 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
498 let absolute = self.resolve(path)?;
499 self.reject_symlink_path(&absolute).await?;
500 let metadata = match tokio::fs::metadata(&absolute).await {
501 Ok(m) => m,
502 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
503 Err(e) => {
504 return Err(AgentLoopError::tool(format!(
505 "stat failed for {}: {e}",
506 absolute.display()
507 )));
508 }
509 };
510 if !metadata.is_dir() {
511 return Ok(vec![]);
512 }
513
514 let mut read_dir = tokio::fs::read_dir(&absolute).await.map_err(|e| {
515 AgentLoopError::tool(format!("read_dir failed for {}: {e}", absolute.display()))
516 })?;
517 let mut entries = Vec::new();
518 while let Some(entry) = read_dir.next_entry().await.map_err(|e| {
519 AgentLoopError::tool(format!(
520 "read_dir entry failed for {}: {e}",
521 absolute.display()
522 ))
523 })? {
524 let entry_path = entry.path();
525 let canonical = self.relative_capability_path(&entry_path)?;
526 let entry_meta = match tokio::fs::symlink_metadata(&entry_path).await {
527 Ok(m) if m.file_type().is_symlink() => continue,
528 Ok(m) => m,
529 Err(_) => continue,
530 };
531 let (created_at, updated_at) = file_times(&entry_meta);
532 let is_dir = entry_meta.is_dir();
533 entries.push(FileInfo {
534 id: path_id(&canonical),
535 session_id: session_id.uuid(),
536 name: FileInfo::name_from_path(&canonical),
537 path: canonical,
538 is_directory: is_dir,
539 is_readonly: false,
540 size_bytes: if is_dir {
541 0
542 } else {
543 saturating_i64(entry_meta.len())
544 },
545 created_at,
546 updated_at,
547 });
548 }
549 entries.sort_by(|a, b| a.path.cmp(&b.path));
550 Ok(entries)
551 }
552
553 async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
554 let absolute = self.resolve(path)?;
555 self.reject_symlink_path(&absolute).await?;
556 let metadata = match tokio::fs::metadata(&absolute).await {
557 Ok(m) => m,
558 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
559 Err(e) => {
560 return Err(AgentLoopError::tool(format!(
561 "stat failed for {}: {e}",
562 absolute.display()
563 )));
564 }
565 };
566 let canonical = self.relative_capability_path(&absolute)?;
567 let name = FileInfo::name_from_path(&canonical);
568 let (created_at, updated_at) = file_times(&metadata);
569 let is_readonly = self.is_readonly(&canonical).await;
570 Ok(Some(FileStat {
571 path: canonical,
572 name,
573 is_directory: metadata.is_dir(),
574 is_readonly,
575 size_bytes: if metadata.is_dir() {
576 0
577 } else {
578 saturating_i64(metadata.len())
579 },
580 created_at,
581 updated_at,
582 }))
583 }
584
585 async fn grep_files(
586 &self,
587 _session_id: SessionId,
588 pattern: &str,
589 path_pattern: Option<&str>,
590 ) -> Result<Vec<GrepMatch>> {
591 let root = self.root();
592 let regex = crate::grep_limits::build_regex(pattern)?;
593 crate::grep_limits::validate_path_pattern(path_pattern)?;
594 let path_pattern = match path_pattern {
595 Some(path) => Some(everruns_core::session_path::GrepPathPattern::new(
596 self.paths.parse_input(path)?.as_relative(),
597 )?),
598 None => None,
599 };
600
601 tokio::task::spawn_blocking(move || -> Result<Vec<GrepMatch>> {
605 let mut out = Vec::new();
606 let mut total_scanned = 0;
607 let walker = WalkBuilder::new(&root)
608 .hidden(false)
609 .git_ignore(true)
610 .git_global(false)
611 .git_exclude(true)
612 .build();
613 for entry in walker {
614 let entry = match entry {
615 Ok(e) => e,
616 Err(_) => continue,
617 };
618 let path = entry.path();
619 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
620 continue;
621 }
622 let relative = match path.strip_prefix(&root) {
623 Ok(r) => r,
624 Err(_) => continue,
625 };
626 let mut rel_str = String::new();
630 let mut ok = true;
631 let mut first = true;
632 for component in relative.components() {
633 if let Component::Normal(seg) = component {
634 if !first {
635 rel_str.push('/');
636 }
637 first = false;
638 match seg.to_str() {
639 Some(s) => rel_str.push_str(s),
640 None => {
641 ok = false;
642 break;
643 }
644 }
645 } else {
646 ok = false;
647 break;
648 }
649 }
650 if !ok {
651 continue;
652 }
653 if let Some(filter) = &path_pattern
654 && !filter.is_match(&rel_str)
655 {
656 continue;
657 }
658 let Ok(metadata) = entry.metadata() else {
659 continue;
660 };
661 let Ok(file_bytes) = usize::try_from(metadata.len()) else {
662 continue;
663 };
664 if !crate::grep_limits::account_scan(&mut total_scanned, file_bytes)? {
665 continue;
666 }
667 let bytes = match std::fs::read(path) {
668 Ok(b) => b,
669 Err(_) => continue,
670 };
671 if !SessionFile::is_text_content(&bytes) {
672 continue;
673 }
674 let text = match std::str::from_utf8(&bytes) {
675 Ok(s) => s,
676 Err(_) => continue,
677 };
678 let canonical_path = format!("/{rel_str}");
679 for (idx, line) in text.lines().enumerate() {
680 if regex.is_match(line) {
681 out.push(GrepMatch {
682 path: canonical_path.clone(),
683 line_number: idx + 1,
684 line: line.to_string(),
685 });
686 }
687 }
688 }
689 Ok(out)
690 })
691 .await
692 .map_err(|e| AgentLoopError::tool(format!("grep walk join failed: {e}")))?
693 }
694
695 async fn grep_files_with_options(
696 &self,
697 _session_id: SessionId,
698 pattern: &str,
699 options: &GrepOptions,
700 ) -> Result<GrepSearchResult> {
701 let root = self.root();
702 let regex = crate::grep_limits::build_regex(pattern)?;
703 crate::grep_limits::validate_path_pattern(options.path_pattern.as_deref())?;
704 let path_pattern = match options.path_pattern.as_deref() {
705 Some(path) => Some(everruns_core::session_path::GrepPathPattern::new(
706 self.paths.parse_input(path)?.as_relative(),
707 )?),
708 None => None,
709 };
710 let options = options.clone();
711
712 tokio::task::spawn_blocking(move || -> Result<GrepSearchResult> {
713 let mut text_files = Vec::new();
714 let mut total_scanned = 0;
715 let walker = WalkBuilder::new(&root)
716 .hidden(false)
717 .git_ignore(true)
718 .git_global(false)
719 .git_exclude(true)
720 .build();
721 for entry in walker.filter_map(std::result::Result::ok) {
722 if !entry.file_type().is_some_and(|kind| kind.is_file()) {
723 continue;
724 }
725 let Ok(relative) = entry.path().strip_prefix(&root) else {
726 continue;
727 };
728 let Some(rel_str) = relative.to_str() else {
729 continue;
730 };
731 let rel_str = rel_str.replace(std::path::MAIN_SEPARATOR, "/");
732 if path_pattern
733 .as_ref()
734 .is_some_and(|matcher| !matcher.is_match(&rel_str))
735 {
736 continue;
737 }
738 let Ok(metadata) = entry.metadata() else {
739 continue;
740 };
741 let Ok(file_bytes) = usize::try_from(metadata.len()) else {
742 continue;
743 };
744 if !crate::grep_limits::account_scan(&mut total_scanned, file_bytes)? {
745 continue;
746 }
747 let Ok(bytes) = std::fs::read(entry.path()) else {
748 continue;
749 };
750 if !SessionFile::is_text_content(&bytes) {
751 continue;
752 }
753 let Ok(text) = String::from_utf8(bytes) else {
754 continue;
755 };
756 text_files.push((format!("/{rel_str}"), text));
757 }
758 Ok(build_grep_search_result(text_files, ®ex, &options))
759 })
760 .await
761 .map_err(|error| AgentLoopError::tool(format!("grep walk join failed: {error}")))?
762 }
763
764 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
765 let absolute = self.resolve(path)?;
766 self.reject_symlink_path(&absolute).await?;
767 tokio::fs::create_dir_all(&absolute).await.map_err(|e| {
768 AgentLoopError::tool(format!(
769 "create_dir_all failed for {}: {e}",
770 absolute.display()
771 ))
772 })?;
773 let metadata = tokio::fs::metadata(&absolute).await.map_err(|e| {
774 AgentLoopError::tool(format!("stat failed for {}: {e}", absolute.display()))
775 })?;
776 let canonical = self.relative_capability_path(&absolute)?;
777 let (created_at, updated_at) = file_times(&metadata);
778 Ok(FileInfo {
779 id: path_id(&canonical),
780 session_id: session_id.uuid(),
781 name: FileInfo::name_from_path(&canonical),
782 path: canonical,
783 is_directory: true,
784 is_readonly: false,
785 size_bytes: 0,
786 created_at,
787 updated_at,
788 })
789 }
790}
791
792fn path_id(canonical_path: &str) -> Uuid {
793 Uuid::new_v5(&Uuid::NAMESPACE_OID, canonical_path.as_bytes())
797}
798
799fn file_times(metadata: &std::fs::Metadata) -> (DateTime<Utc>, DateTime<Utc>) {
800 let modified = metadata
801 .modified()
802 .ok()
803 .and_then(system_time_to_utc)
804 .unwrap_or_else(Utc::now);
805 let created = metadata
806 .created()
807 .ok()
808 .and_then(system_time_to_utc)
809 .unwrap_or(modified);
810 (created, modified)
811}
812
813fn system_time_to_utc(time: SystemTime) -> Option<DateTime<Utc>> {
814 let duration = time.duration_since(SystemTime::UNIX_EPOCH).ok()?;
815 Utc.timestamp_opt(duration.as_secs() as i64, duration.subsec_nanos())
816 .single()
817}
818
819fn saturating_i64(value: u64) -> i64 {
823 if value > i64::MAX as u64 {
824 i64::MAX
825 } else {
826 value as i64
827 }
828}
829
830#[derive(Clone, Debug, PartialEq, Eq, Default)]
843struct RelPath(String);
844
845impl RelPath {
846 fn is_root(&self) -> bool {
847 self.0.is_empty()
848 }
849
850 fn as_relative(&self) -> &str {
851 &self.0
852 }
853
854 fn to_session_path(&self) -> String {
856 if self.0.is_empty() {
857 "/".to_string()
858 } else {
859 format!("/{}", self.0)
860 }
861 }
862}
863
864#[derive(Debug, Clone)]
867struct HostPathMap {
868 root: Arc<std::sync::RwLock<PathBuf>>,
869}
870
871impl HostPathMap {
872 fn new(root: impl Into<PathBuf>) -> Result<Self> {
873 Ok(Self {
874 root: Arc::new(std::sync::RwLock::new(canonicalize_root(root.into())?)),
875 })
876 }
877
878 fn root(&self) -> PathBuf {
879 self.root.read().expect("host root lock poisoned").clone()
880 }
881
882 fn set_root(&self, root: impl Into<PathBuf>) -> Result<()> {
883 let canonical = canonicalize_root(root.into())?;
884 *self.root.write().expect("host root lock poisoned") = canonical;
885 Ok(())
886 }
887
888 fn parse_input(&self, input: &str) -> Result<RelPath> {
895 let trimmed = input.trim();
896
897 let candidate = Path::new(trimmed);
900 if candidate.is_absolute()
901 && let Ok(relative) = candidate.strip_prefix(self.root())
902 {
903 return rel_from_path(relative);
904 }
905
906 let session = everruns_core::session_path::to_session_path(trimmed);
908 rel_from_str(&session)
909 }
910
911 fn to_host(&self, path: &RelPath) -> Result<PathBuf> {
913 let root = self.root();
914 if path.is_root() {
915 return Ok(root);
916 }
917 let candidate = root.join(path.as_relative());
918 if !candidate.starts_with(&root) {
919 return Err(AgentLoopError::tool(format!(
920 "path escapes workspace root: {}",
921 path.as_relative()
922 )));
923 }
924 Ok(candidate)
925 }
926
927 fn relativize(&self, host: &Path) -> Result<RelPath> {
929 let relative = host.strip_prefix(self.root()).map_err(|_| {
930 AgentLoopError::tool(format!(
931 "path is outside workspace root: {}",
932 host.display()
933 ))
934 })?;
935 rel_from_path(relative)
936 }
937
938 fn display_root(&self) -> String {
940 self.root().display().to_string()
941 }
942
943 fn to_display(&self, path: &RelPath) -> String {
945 let root = self.root();
946 if path.is_root() {
947 return root.display().to_string();
948 }
949 root.join(path.as_relative()).display().to_string()
950 }
951}
952
953fn rel_from_str(s: &str) -> Result<RelPath> {
955 let mut segments = Vec::new();
956 for part in s.split('/') {
957 match part {
958 "" | "." => {}
959 ".." => {
960 return Err(AgentLoopError::tool(format!(
961 "path traversal rejected: {s}"
962 )));
963 }
964 segment => segments.push(segment),
965 }
966 }
967 Ok(RelPath(segments.join("/")))
968}
969
970fn rel_from_path(relative: &Path) -> Result<RelPath> {
974 let mut segments = Vec::new();
975 for component in relative.components() {
976 match component {
977 Component::CurDir => {}
978 Component::Normal(seg) => {
979 let segment = seg.to_str().ok_or_else(|| {
980 AgentLoopError::tool(format!(
981 "non-UTF-8 path component: {}",
982 relative.display()
983 ))
984 })?;
985 segments.push(segment.to_string());
986 }
987 Component::ParentDir => {
988 return Err(AgentLoopError::tool(format!(
989 "path traversal rejected: {}",
990 relative.display()
991 )));
992 }
993 Component::RootDir | Component::Prefix(_) => {
994 return Err(AgentLoopError::tool(format!(
995 "absolute path component rejected: {}",
996 relative.display()
997 )));
998 }
999 }
1000 }
1001 Ok(RelPath(segments.join("/")))
1002}
1003
1004fn canonicalize_root(root: PathBuf) -> Result<PathBuf> {
1005 if !root.exists() {
1006 return Err(AgentLoopError::config(format!(
1007 "workspace directory does not exist: {}",
1008 root.display()
1009 )));
1010 }
1011 let canonical = std::fs::canonicalize(&root).map_err(|e| {
1012 AgentLoopError::config(format!(
1013 "failed to canonicalize workspace root {}: {e}",
1014 root.display()
1015 ))
1016 })?;
1017 if !canonical.is_dir() {
1018 return Err(AgentLoopError::config(format!(
1019 "workspace root is not a directory: {}",
1020 canonical.display()
1021 )));
1022 }
1023 Ok(canonical)
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029 use tempfile::TempDir;
1030
1031 fn make_store() -> (RealDiskFileStore, TempDir) {
1032 let dir = TempDir::new().expect("tempdir");
1033 let store = RealDiskFileStore::new(dir.path()).expect("store");
1034 (store, dir)
1035 }
1036
1037 fn sid() -> SessionId {
1038 SessionId::new()
1039 }
1040
1041 #[tokio::test]
1042 async fn multi_root_reads_writes_lists_and_greps() {
1043 let primary = TempDir::new().unwrap();
1044 let backend = TempDir::new().unwrap();
1045 let root_set = WorkspaceRootSet::new(
1046 primary.path(),
1047 [("backend".to_string(), backend.path().to_path_buf())],
1048 )
1049 .unwrap();
1050 let store = multi_root_file_system(&root_set).unwrap();
1051 let session = sid();
1052
1053 let primary_file = store
1054 .write_file(session, "/workspace/README.md", "needle primary", "text")
1055 .await
1056 .unwrap();
1057 assert_eq!(primary_file.path, "/README.md");
1058 assert_eq!(
1059 std::fs::read_to_string(primary.path().join("README.md")).unwrap(),
1060 "needle primary"
1061 );
1062
1063 let backend_file = store
1064 .write_file(
1065 session,
1066 "/workspace/roots/backend/Cargo.toml",
1067 "needle backend",
1068 "text",
1069 )
1070 .await
1071 .unwrap();
1072 assert_eq!(backend_file.path, "/workspace/roots/backend/Cargo.toml");
1073 assert_eq!(
1074 std::fs::read_to_string(backend.path().join("Cargo.toml")).unwrap(),
1075 "needle backend"
1076 );
1077
1078 let listed = store
1079 .list_directory(session, "/workspace/roots/backend")
1080 .await
1081 .unwrap();
1082 assert_eq!(listed.len(), 1);
1083 assert_eq!(listed[0].path, "/workspace/roots/backend/Cargo.toml");
1084
1085 let matches = store.grep_files(session, "needle", None).await.unwrap();
1086 let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
1087 assert_eq!(
1088 paths,
1089 vec![
1090 "/README.md".to_string(),
1091 "/workspace/roots/backend/Cargo.toml".to_string()
1092 ]
1093 );
1094 }
1095
1096 #[tokio::test]
1097 async fn multi_root_escape_attempts_fail() {
1098 let primary = TempDir::new().unwrap();
1099 let backend = TempDir::new().unwrap();
1100 let root_set = WorkspaceRootSet::new(
1101 primary.path(),
1102 [("backend".to_string(), backend.path().to_path_buf())],
1103 )
1104 .unwrap();
1105 let store = multi_root_file_system(&root_set).unwrap();
1106
1107 let err = store
1108 .write_file(
1109 sid(),
1110 "/workspace/roots/backend/../../outside.txt",
1111 "nope",
1112 "text",
1113 )
1114 .await
1115 .unwrap_err();
1116 assert!(err.to_string().contains("path traversal rejected"));
1117 }
1118
1119 #[tokio::test]
1120 async fn multi_root_blocklist_applies_to_every_root() {
1121 let primary = TempDir::new().unwrap();
1122 let backend = TempDir::new().unwrap();
1123 let root_set = WorkspaceRootSet::new(
1124 primary.path(),
1125 [("backend".to_string(), backend.path().to_path_buf())],
1126 )
1127 .unwrap();
1128 let inner = multi_root_file_system(&root_set).unwrap();
1129 let store: Arc<dyn SessionFileSystem> =
1130 Arc::new(crate::WriteBlocklistFileStore::new(inner));
1131
1132 let primary_err = store
1133 .write_file(sid(), "/workspace/target/out.txt", "nope", "text")
1134 .await
1135 .unwrap_err();
1136 assert!(primary_err.to_string().contains("write blocklist rejected"));
1137
1138 let backend_err = store
1139 .write_file(
1140 sid(),
1141 "/workspace/roots/backend/node_modules/pkg.js",
1142 "nope",
1143 "text",
1144 )
1145 .await
1146 .unwrap_err();
1147 assert!(backend_err.to_string().contains("write blocklist rejected"));
1148 }
1149
1150 #[tokio::test]
1151 async fn factory_context_root_set_repoints_only_primary() {
1152 let configured = TempDir::new().unwrap();
1153 let primary = TempDir::new().unwrap();
1154 let backend = TempDir::new().unwrap();
1155 let root_set = WorkspaceRootSet::new(
1156 primary.path(),
1157 [("backend".to_string(), backend.path().to_path_buf())],
1158 )
1159 .unwrap();
1160 let factory = RealDiskSessionFileSystemFactory::new(configured.path());
1161 let store = factory
1162 .create_session_file_system(
1163 SessionFileSystemFactoryContext::new().with_workspace_roots(Arc::new(root_set)),
1164 )
1165 .await
1166 .unwrap();
1167
1168 store
1169 .write_file(sid(), "/workspace/primary.txt", "primary", "text")
1170 .await
1171 .unwrap();
1172 store
1173 .write_file(
1174 sid(),
1175 "/workspace/roots/backend/backend.txt",
1176 "backend",
1177 "text",
1178 )
1179 .await
1180 .unwrap();
1181
1182 assert!(!configured.path().join("primary.txt").exists());
1183 assert_eq!(
1184 std::fs::read_to_string(primary.path().join("primary.txt")).unwrap(),
1185 "primary"
1186 );
1187 assert_eq!(
1188 std::fs::read_to_string(backend.path().join("backend.txt")).unwrap(),
1189 "backend"
1190 );
1191 }
1192
1193 #[tokio::test]
1194 async fn round_trip_text_file() {
1195 let (store, _dir) = make_store();
1196 let session = sid();
1197 let written = store
1198 .write_file(session, "/notes.md", "# hello", "text")
1199 .await
1200 .expect("write");
1201 assert_eq!(written.path, "/notes.md");
1202 assert_eq!(written.encoding, "text");
1203
1204 let read = store
1205 .read_file(session, "/notes.md")
1206 .await
1207 .expect("read")
1208 .expect("present");
1209 assert_eq!(read.content.as_deref(), Some("# hello"));
1210 assert_eq!(read.encoding, "text");
1211 assert_eq!(read.size_bytes, 7);
1212 assert!(!read.is_directory);
1213 }
1214
1215 #[tokio::test]
1216 async fn round_trip_binary_file() {
1217 let (store, _dir) = make_store();
1218 let session = sid();
1219 let bytes = [0x89u8, b'P', b'N', b'G', 0, 1, 2, 3];
1220 let (encoded, encoding) = SessionFile::encode_content(&bytes);
1221 assert_eq!(encoding, "base64");
1222
1223 store
1224 .write_file(session, "/img.bin", &encoded, &encoding)
1225 .await
1226 .expect("write");
1227
1228 let read = store
1229 .read_file(session, "/img.bin")
1230 .await
1231 .expect("read")
1232 .expect("present");
1233 assert_eq!(read.encoding, "base64");
1234 let decoded = SessionFile::decode_content(read.content.as_deref().unwrap(), &read.encoding)
1235 .expect("decode");
1236 assert_eq!(decoded, bytes);
1237 }
1238
1239 #[tokio::test]
1240 async fn workspace_prefix_normalized() {
1241 let (store, _dir) = make_store();
1242 let session = sid();
1243 store
1244 .write_file(session, "/workspace/sub/dir/file.txt", "hi", "text")
1245 .await
1246 .expect("write");
1247
1248 let via_canonical = store
1249 .read_file(session, "/sub/dir/file.txt")
1250 .await
1251 .expect("read")
1252 .expect("present");
1253 let via_workspace = store
1254 .read_file(session, "/workspace/sub/dir/file.txt")
1255 .await
1256 .expect("read")
1257 .expect("present");
1258 assert_eq!(via_canonical.content, via_workspace.content);
1259 assert_eq!(via_canonical.path, "/sub/dir/file.txt");
1260 }
1261
1262 #[tokio::test]
1263 async fn real_disk_display_paths_use_host_root() {
1264 let (store, dir) = make_store();
1265 let root = std::fs::canonicalize(dir.path()).expect("canonical tempdir");
1266
1267 assert_eq!(store.display_root(), root.display().to_string());
1268 assert_eq!(
1269 store.display_path("/sub/dir/file.txt"),
1270 root.join("sub/dir/file.txt").display().to_string()
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn host_absolute_paths_under_root_are_workspace_aliases() {
1276 let (store, _dir) = make_store();
1277 let session = sid();
1278 let host_path = store.display_path("/sub/dir/file.txt");
1279
1280 store
1281 .write_file(session, &host_path, "hi", "text")
1282 .await
1283 .expect("write via host path");
1284
1285 let via_workspace = store
1286 .read_file(session, "/workspace/sub/dir/file.txt")
1287 .await
1288 .expect("read")
1289 .expect("present");
1290 assert_eq!(via_workspace.content.as_deref(), Some("hi"));
1291 assert_eq!(via_workspace.path, "/sub/dir/file.txt");
1292 }
1293
1294 #[tokio::test]
1295 async fn host_absolute_aliases_allow_current_dir_segments() {
1296 let (store, _dir) = make_store();
1297 let session = sid();
1298 let host_path = Path::new(&store.display_root())
1299 .join("./sub/dir/file.txt")
1300 .display()
1301 .to_string();
1302
1303 store
1304 .write_file(session, &host_path, "hi", "text")
1305 .await
1306 .expect("write via host path");
1307
1308 let via_workspace = store
1309 .read_file(session, "/workspace/sub/dir/file.txt")
1310 .await
1311 .expect("read")
1312 .expect("present");
1313 assert_eq!(via_workspace.content.as_deref(), Some("hi"));
1314 assert_eq!(via_workspace.path, "/sub/dir/file.txt");
1315 }
1316
1317 #[tokio::test]
1318 async fn grep_path_pattern_accepts_host_absolute_path_alias() {
1319 let (store, _dir) = make_store();
1320 let session = sid();
1321 store
1322 .write_file(session, "/src/lib.rs", "needle", "text")
1323 .await
1324 .expect("write src");
1325 store
1326 .write_file(session, "/docs/readme.md", "needle", "text")
1327 .await
1328 .expect("write docs");
1329 let host_filter = store.display_path("/src");
1330
1331 let matches = store
1332 .grep_files(session, "needle", Some(&host_filter))
1333 .await
1334 .expect("grep");
1335
1336 assert_eq!(matches.len(), 1);
1337 assert_eq!(matches[0].path, "/src/lib.rs");
1338 }
1339
1340 #[tokio::test]
1341 async fn grep_path_pattern_supports_globs() {
1342 let (store, _dir) = make_store();
1343 let session = sid();
1344 for path in [
1345 "/src/lib.rs",
1346 "/src/nested/mod.rs",
1347 "/docs/readme.md",
1348 "/docs/nested/guide.md",
1349 "/notes.txt",
1350 "/nested/notes.txt",
1351 ] {
1352 store
1353 .write_file(session, path, "needle", "text")
1354 .await
1355 .expect("write fixture");
1356 }
1357
1358 let cases = [
1359 ("src/**/*.rs", vec!["/src/lib.rs", "/src/nested/mod.rs"]),
1360 (
1361 "**/*",
1362 vec![
1363 "/docs/nested/guide.md",
1364 "/docs/readme.md",
1365 "/nested/notes.txt",
1366 "/notes.txt",
1367 "/src/lib.rs",
1368 "/src/nested/mod.rs",
1369 ],
1370 ),
1371 ("docs/*", vec!["/docs/readme.md"]),
1372 ("*.txt", vec!["/nested/notes.txt", "/notes.txt"]),
1373 (
1374 "/workspace/src/**/*.rs",
1375 vec!["/src/lib.rs", "/src/nested/mod.rs"],
1376 ),
1377 ];
1378
1379 for (path_pattern, expected) in cases {
1380 let mut paths: Vec<_> = store
1381 .grep_files(session, "needle", Some(path_pattern))
1382 .await
1383 .expect("grep")
1384 .into_iter()
1385 .map(|hit| hit.path)
1386 .collect();
1387 paths.sort();
1388 assert_eq!(paths, expected, "path_pattern={path_pattern}");
1389 }
1390
1391 let host_pattern = Path::new(&store.display_root())
1392 .join("src/**/*.rs")
1393 .display()
1394 .to_string();
1395 let mut paths: Vec<_> = store
1396 .grep_files(session, "needle", Some(&host_pattern))
1397 .await
1398 .expect("host-absolute glob")
1399 .into_iter()
1400 .map(|hit| hit.path)
1401 .collect();
1402 paths.sort();
1403 assert_eq!(paths, vec!["/src/lib.rs", "/src/nested/mod.rs"]);
1404 }
1405
1406 #[tokio::test]
1407 async fn path_traversal_rejected() {
1408 let (store, _dir) = make_store();
1409 let session = sid();
1410 let err = store
1411 .read_file(session, "/../outside.txt")
1412 .await
1413 .expect_err("must reject traversal");
1414 let msg = format!("{err}");
1415 assert!(msg.contains("traversal"), "got: {msg}");
1416
1417 let err = store
1418 .write_file(session, "/foo/../../etc/passwd", "x", "text")
1419 .await
1420 .expect_err("must reject traversal");
1421 let msg = format!("{err}");
1422 assert!(msg.contains("traversal"), "got: {msg}");
1423 }
1424
1425 #[cfg(unix)]
1426 #[tokio::test]
1427 async fn read_file_rejects_symlink_to_outside_workspace() {
1428 let (store, dir) = make_store();
1429 let outside = TempDir::new().expect("outside tempdir");
1430 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1431 std::fs::create_dir(dir.path().join("docs")).unwrap();
1432 std::os::unix::fs::symlink(outside.path(), dir.path().join("docs/secret")).unwrap();
1433
1434 let err = store
1435 .read_file(sid(), "/docs/secret/secret.txt")
1436 .await
1437 .expect_err("symlink read must be rejected");
1438 let msg = format!("{err}");
1439 assert!(msg.contains("symlink"), "got: {msg}");
1440 }
1441
1442 #[cfg(unix)]
1443 #[tokio::test]
1444 async fn list_directory_rejects_symlink_to_outside_workspace() {
1445 let (store, dir) = make_store();
1446 let outside = TempDir::new().expect("outside tempdir");
1447 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1448 std::os::unix::fs::symlink(outside.path(), dir.path().join("secret_dir")).unwrap();
1449
1450 let err = store
1451 .list_directory(sid(), "/secret_dir")
1452 .await
1453 .expect_err("symlink list must be rejected");
1454 let msg = format!("{err}");
1455 assert!(msg.contains("symlink"), "got: {msg}");
1456 }
1457
1458 #[cfg(unix)]
1459 #[tokio::test]
1460 async fn write_file_rejects_symlink_parent() {
1461 let (store, dir) = make_store();
1462 let outside = TempDir::new().expect("outside tempdir");
1463 std::os::unix::fs::symlink(outside.path(), dir.path().join("outlink")).unwrap();
1464
1465 let err = store
1466 .write_file(sid(), "/outlink/owned.txt", "owned", "text")
1467 .await
1468 .expect_err("symlink write must be rejected");
1469 let msg = format!("{err}");
1470 assert!(msg.contains("symlink"), "got: {msg}");
1471 assert!(!outside.path().join("owned.txt").exists());
1472 }
1473
1474 #[cfg(unix)]
1475 #[tokio::test]
1476 async fn list_directory_skips_symlink_children() {
1477 let (store, dir) = make_store();
1478 let outside = TempDir::new().expect("outside tempdir");
1479 std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1480 std::os::unix::fs::symlink(
1481 outside.path().join("secret.txt"),
1482 dir.path().join("link.txt"),
1483 )
1484 .unwrap();
1485 store
1486 .write_file(sid(), "/safe.txt", "safe", "text")
1487 .await
1488 .unwrap();
1489
1490 let entries = store.list_directory(sid(), "/").await.unwrap();
1491 let paths: Vec<&str> = entries.iter().map(|entry| entry.path.as_str()).collect();
1492 assert!(paths.contains(&"/safe.txt"));
1493 assert!(!paths.contains(&"/link.txt"));
1494 }
1495
1496 #[tokio::test]
1497 async fn list_directory_returns_children() {
1498 let (store, _dir) = make_store();
1499 let session = sid();
1500 store
1501 .write_file(session, "/a.txt", "1", "text")
1502 .await
1503 .unwrap();
1504 store
1505 .write_file(session, "/sub/b.txt", "2", "text")
1506 .await
1507 .unwrap();
1508 store
1509 .write_file(session, "/sub/c.txt", "3", "text")
1510 .await
1511 .unwrap();
1512
1513 let root = store.list_directory(session, "/").await.unwrap();
1514 let paths: Vec<&str> = root.iter().map(|f| f.path.as_str()).collect();
1515 assert!(paths.contains(&"/a.txt"));
1516 assert!(paths.contains(&"/sub"));
1517
1518 let sub = store.list_directory(session, "/sub").await.unwrap();
1519 let sub_paths: Vec<&str> = sub.iter().map(|f| f.path.as_str()).collect();
1520 assert_eq!(sub_paths, vec!["/sub/b.txt", "/sub/c.txt"]);
1521 }
1522
1523 #[tokio::test]
1524 async fn grep_finds_matches_and_respects_ignore_files() {
1525 let (store, dir) = make_store();
1526 let session = sid();
1527 std::fs::write(dir.path().join(".ignore"), "ignored.txt\n").unwrap();
1531 store
1532 .write_file(
1533 session,
1534 "/src.rs",
1535 "fn needle() {}\nfn other() {}\n",
1536 "text",
1537 )
1538 .await
1539 .unwrap();
1540 store
1541 .write_file(session, "/ignored.txt", "needle\n", "text")
1542 .await
1543 .unwrap();
1544
1545 let hits = store.grep_files(session, "needle", None).await.unwrap();
1546 let hit_paths: Vec<&str> = hits.iter().map(|m| m.path.as_str()).collect();
1547 assert!(hit_paths.contains(&"/src.rs"));
1548 assert!(!hit_paths.contains(&"/ignored.txt"));
1549
1550 let filtered = store
1551 .grep_files(session, "needle", Some(".rs"))
1552 .await
1553 .unwrap();
1554 assert!(filtered.iter().all(|m| m.path.ends_with(".rs")));
1555 }
1556
1557 #[tokio::test]
1558 async fn cas_rejects_stale_writes() {
1559 let (store, _dir) = make_store();
1560 let session = sid();
1561 store
1562 .write_file(session, "/foo.txt", "v1", "text")
1563 .await
1564 .unwrap();
1565
1566 let stale = store
1568 .write_file_if_content_matches(session, "/foo.txt", "v0", "text", "v2", "text")
1569 .await
1570 .unwrap();
1571 assert!(stale.is_none(), "stale CAS should not update");
1572
1573 let read = store.read_file(session, "/foo.txt").await.unwrap().unwrap();
1574 assert_eq!(read.content.as_deref(), Some("v1"));
1575
1576 let updated = store
1578 .write_file_if_content_matches(session, "/foo.txt", "v1", "text", "v2", "text")
1579 .await
1580 .unwrap();
1581 assert!(updated.is_some(), "matching CAS should update");
1582 let read = store.read_file(session, "/foo.txt").await.unwrap().unwrap();
1583 assert_eq!(read.content.as_deref(), Some("v2"));
1584 }
1585
1586 #[tokio::test]
1587 async fn cas_is_serialized_across_stores_for_the_same_shared_root() {
1588 let directory = TempDir::new().unwrap();
1589 let first = RealDiskFileStore::new(directory.path()).unwrap();
1590 let second = RealDiskFileStore::new(directory.path()).unwrap();
1591 let session = sid();
1592 first
1593 .write_file(session, "/shared.txt", "v1", "text")
1594 .await
1595 .unwrap();
1596 let barrier = Arc::new(tokio::sync::Barrier::new(3));
1597 let write = |store: RealDiskFileStore, content: &'static str| {
1598 let barrier = barrier.clone();
1599 async move {
1600 barrier.wait().await;
1601 store
1602 .write_file_if_content_matches(
1603 session,
1604 "/shared.txt",
1605 "v1",
1606 "text",
1607 content,
1608 "text",
1609 )
1610 .await
1611 .unwrap()
1612 .is_some()
1613 }
1614 };
1615 let first_write = tokio::spawn(write(first, "first"));
1616 let second_write = tokio::spawn(write(second, "second"));
1617 barrier.wait().await;
1618 let (first_won, second_won) = tokio::join!(first_write, second_write);
1619 assert_ne!(first_won.unwrap(), second_won.unwrap());
1620 }
1621
1622 #[tokio::test]
1623 async fn delete_non_recursive_fails_on_nonempty_dir() {
1624 let (store, _dir) = make_store();
1625 let session = sid();
1626 store
1627 .write_file(session, "/d/x.txt", "x", "text")
1628 .await
1629 .unwrap();
1630
1631 let removed = store.delete_file(session, "/d", false).await.unwrap();
1632 assert!(!removed, "non-recursive delete must refuse non-empty dir");
1633
1634 let removed = store.delete_file(session, "/d", true).await.unwrap();
1635 assert!(removed);
1636 let after = store.read_file(session, "/d/x.txt").await.unwrap();
1637 assert!(after.is_none());
1638 }
1639
1640 #[tokio::test]
1641 async fn seed_initial_file_persists() {
1642 let (store, _dir) = make_store();
1643 let session = sid();
1644 store
1645 .seed_initial_file(
1646 session,
1647 &InitialFile {
1648 path: "/workspace/AGENTS.md".to_string(),
1649 content: "# Project rules".to_string(),
1650 encoding: "text".to_string(),
1651 is_readonly: false,
1652 },
1653 )
1654 .await
1655 .unwrap();
1656
1657 let read = store
1658 .read_file(session, "/AGENTS.md")
1659 .await
1660 .unwrap()
1661 .unwrap();
1662 assert_eq!(read.content.as_deref(), Some("# Project rules"));
1663 }
1664
1665 #[tokio::test]
1666 async fn root_directory_resolves() {
1667 let (store, _dir) = make_store();
1668 let session = sid();
1669 let stat = store.stat_file(session, "/").await.unwrap().unwrap();
1670 assert!(stat.is_directory);
1671 assert_eq!(stat.path, "/");
1672 }
1673
1674 #[tokio::test]
1675 async fn rejects_missing_root() {
1676 let missing = std::env::temp_dir().join("everruns-nonexistent-xyz-12345");
1677 let _ = std::fs::remove_dir_all(&missing);
1678 let err = RealDiskFileStore::new(&missing).expect_err("must reject missing root");
1679 let msg = format!("{err}");
1680 assert!(msg.contains("does not exist"), "got: {msg}");
1681 }
1682
1683 #[tokio::test]
1684 async fn delete_root_returns_explicit_error() {
1685 let (store, _dir) = make_store();
1686 let session = sid();
1687 let err = store
1688 .delete_file(session, "/", true)
1689 .await
1690 .expect_err("root delete must be an explicit error, not Ok(false)");
1691 assert!(format!("{err}").contains("workspace root"));
1692 }
1693
1694 #[tokio::test]
1695 async fn seeded_readonly_file_rejects_writes() {
1696 let (store, _dir) = make_store();
1697 let session = sid();
1698 store
1699 .seed_initial_file(
1700 session,
1701 &InitialFile {
1702 path: "/locked.txt".to_string(),
1703 content: "starter".to_string(),
1704 encoding: "text".to_string(),
1705 is_readonly: true,
1706 },
1707 )
1708 .await
1709 .unwrap();
1710
1711 let read = store
1712 .read_file(session, "/locked.txt")
1713 .await
1714 .unwrap()
1715 .unwrap();
1716 assert!(read.is_readonly);
1717
1718 let err = store
1719 .write_file(session, "/locked.txt", "changed", "text")
1720 .await
1721 .expect_err("readonly write must fail");
1722 assert!(format!("{err}").contains("read-only"));
1723
1724 let err = store
1725 .delete_file(session, "/locked.txt", false)
1726 .await
1727 .expect_err("readonly delete must fail");
1728 assert!(format!("{err}").contains("read-only"));
1729 }
1730
1731 #[tokio::test]
1732 async fn reseeding_clears_readonly() {
1733 let (store, _dir) = make_store();
1734 let session = sid();
1735 store
1736 .seed_initial_file(
1737 session,
1738 &InitialFile {
1739 path: "/foo.txt".to_string(),
1740 content: "v1".to_string(),
1741 encoding: "text".to_string(),
1742 is_readonly: true,
1743 },
1744 )
1745 .await
1746 .unwrap();
1747 store
1749 .seed_initial_file(
1750 session,
1751 &InitialFile {
1752 path: "/foo.txt".to_string(),
1753 content: "v2".to_string(),
1754 encoding: "text".to_string(),
1755 is_readonly: false,
1756 },
1757 )
1758 .await
1759 .unwrap();
1760 store
1761 .write_file(session, "/foo.txt", "v3", "text")
1762 .await
1763 .unwrap();
1764 }
1765}