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