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