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