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