1use crate::file::{
9 CopyOptions, DeleteOptions, FileError, FileProvider, FileType, MkdirOptions, MoveOptions,
10 WriteMode, WriteOptions,
11};
12use crate::filesystem::{
13 FilesystemCallContext, FilesystemCapabilities, FilesystemCapability, FilesystemDescriptor,
14 FilesystemEntry, FilesystemEntryPage, FilesystemFuture, FilesystemMutation,
15 FilesystemMutationContext, FilesystemPageRequest, IFilesystem,
16};
17use std::cell::Cell;
18use std::rc::Rc;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum FilesystemProviderKind {
23 Sftp,
24 GoogleDrive,
25 S3,
26 GitHub,
27 WebDav,
28}
29
30impl FilesystemProviderKind {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Sftp => "sftp",
34 Self::GoogleDrive => "google-drive",
35 Self::S3 => "s3",
36 Self::GitHub => "github",
37 Self::WebDav => "webdav",
38 }
39 }
40}
41
42pub trait RemoteFilesystemClient {
48 fn authenticated(&self) -> bool;
49
50 fn host_key_verified(&self) -> bool {
52 true
53 }
54
55 fn transport_verified(&self) -> bool {
58 true
59 }
60
61 fn capabilities(&self) -> FilesystemCapabilities;
62
63 fn stat(&self, path: &str) -> Result<FilesystemEntry, FileError>;
64 fn read(&self, path: &str) -> Result<Vec<u8>, FileError>;
65 fn write(
66 &self,
67 path: &str,
68 bytes: Vec<u8>,
69 options: WriteOptions,
70 mutation: &FilesystemMutationContext,
71 ) -> Result<FilesystemMutation, FileError>;
72 fn entries_page(
73 &self,
74 path: &str,
75 request: &FilesystemPageRequest,
76 ) -> Result<FilesystemEntryPage, FileError>;
77 fn mkdir(
78 &self,
79 path: &str,
80 options: MkdirOptions,
81 mutation: &FilesystemMutationContext,
82 ) -> Result<FilesystemMutation, FileError>;
83 fn delete(
84 &self,
85 path: &str,
86 options: DeleteOptions,
87 mutation: &FilesystemMutationContext,
88 ) -> Result<FilesystemMutation, FileError>;
89 fn copy(
90 &self,
91 source: &str,
92 target: &str,
93 options: CopyOptions,
94 mutation: &FilesystemMutationContext,
95 ) -> Result<FilesystemMutation, FileError>;
96 fn move_entry(
97 &self,
98 source: &str,
99 target: &str,
100 options: MoveOptions,
101 mutation: &FilesystemMutationContext,
102 ) -> Result<FilesystemMutation, FileError>;
103 fn close(&self) -> Result<(), FileError>;
104}
105
106struct RemoteFilesystem {
107 client: Rc<dyn RemoteFilesystemClient>,
108 descriptor: FilesystemDescriptor,
109 capabilities: FilesystemCapabilities,
110 read_only: bool,
111 closed: Rc<Cell<bool>>,
112}
113
114impl RemoteFilesystem {
115 fn new(
116 kind: FilesystemProviderKind,
117 client: Rc<dyn RemoteFilesystemClient>,
118 display: String,
119 root: String,
120 read_only: bool,
121 blocked_capabilities: impl IntoIterator<Item = FilesystemCapability>,
122 extra_extensions: impl IntoIterator<Item = (&'static str, String)>,
123 ) -> Result<Self, FileError> {
124 if !client.authenticated() {
125 return Err(FileError::PermissionDenied);
126 }
127 let _root = crate::file::logical_normalise(&root)?;
128 let capabilities =
129 effective_capabilities(client.capabilities(), read_only, blocked_capabilities);
130 let mut descriptor = FilesystemDescriptor::new(
131 kind.as_str(),
132 validate_display(display, kind.as_str())?,
133 read_only,
134 capabilities.clone(),
135 )
136 .with_extension("provider/root-scoped?", "true");
137 for (key, value) in extra_extensions {
138 descriptor = descriptor.with_extension(key, value);
139 }
140 Ok(Self {
141 client,
142 descriptor,
143 capabilities,
144 read_only,
145 closed: Rc::new(Cell::new(false)),
146 })
147 }
148
149 fn descriptor(&self) -> FilesystemDescriptor {
150 self.descriptor.clone()
151 }
152
153 fn stat<'a>(
154 &'a self,
155 context: FilesystemCallContext,
156 path: String,
157 ) -> FilesystemFuture<'a, FilesystemEntry> {
158 let path = match crate::file::logical_normalise(&path) {
159 Ok(path) => path,
160 Err(error) => return failed(error),
161 };
162 let client = self.client.clone();
163 let closed = self.closed.clone();
164 let capabilities = self.capabilities.clone();
165 Box::pin(async move {
166 context.check()?;
167 ensure_open(&closed)?;
168 ensure_capability(&capabilities, FilesystemCapability::Read)?;
169 let entry = client.stat(&path)?;
170 validate_entry(&entry, Some(&path))?;
171 Ok(entry)
172 })
173 }
174
175 fn read<'a>(
176 &'a self,
177 context: FilesystemCallContext,
178 path: String,
179 ) -> FilesystemFuture<'a, Vec<u8>> {
180 let path = match crate::file::logical_normalise(&path) {
181 Ok(path) => path,
182 Err(error) => return failed(error),
183 };
184 let client = self.client.clone();
185 let closed = self.closed.clone();
186 let capabilities = self.capabilities.clone();
187 Box::pin(async move {
188 context.check()?;
189 ensure_open(&closed)?;
190 ensure_capability(&capabilities, FilesystemCapability::Read)?;
191 client.read(&path)
192 })
193 }
194
195 fn write<'a>(
196 &'a self,
197 context: FilesystemCallContext,
198 path: String,
199 bytes: Vec<u8>,
200 options: WriteOptions,
201 mutation: FilesystemMutationContext,
202 ) -> FilesystemFuture<'a, FilesystemMutation> {
203 let path = match crate::file::logical_normalise(&path) {
204 Ok(path) => path,
205 Err(error) => return failed(error),
206 };
207 let client = self.client.clone();
208 let closed = self.closed.clone();
209 let capabilities = self.capabilities.clone();
210 let read_only = self.read_only;
211 Box::pin(async move {
212 context.check()?;
213 ensure_open(&closed)?;
214 ensure_mutation(
215 &capabilities,
216 read_only,
217 FilesystemCapability::Write,
218 &mutation,
219 )?;
220 if options.mode == WriteMode::Append
221 && !capabilities.contains(FilesystemCapability::Append)
222 {
223 return Err(FileError::Unsupported);
224 }
225 client.write(&path, bytes, options, &mutation)
226 })
227 }
228
229 fn entries_page<'a>(
230 &'a self,
231 context: FilesystemCallContext,
232 path: String,
233 request: FilesystemPageRequest,
234 ) -> FilesystemFuture<'a, FilesystemEntryPage> {
235 let path = match crate::file::logical_normalise(&path) {
236 Ok(path) => path,
237 Err(error) => return failed(error),
238 };
239 let client = self.client.clone();
240 let closed = self.closed.clone();
241 let capabilities = self.capabilities.clone();
242 Box::pin(async move {
243 context.check()?;
244 ensure_open(&closed)?;
245 ensure_capability(&capabilities, FilesystemCapability::Entries)?;
246 let page = client.entries_page(&path, &request)?;
247 for entry in &page.entries {
248 validate_entry(entry, None)?;
249 if crate::file::logical_parent(&entry.path)?.as_deref() != Some(path.as_str()) {
250 return Err(FileError::InvalidPath(
251 "provider returned an entry outside its requested directory".into(),
252 ));
253 }
254 }
255 if matches!(
256 (&page.next_token, &request.token),
257 (Some(next), Some(current)) if next == current
258 ) {
259 return Err(FileError::Io(
260 "provider returned a repeated page token".into(),
261 ));
262 }
263 Ok(page)
264 })
265 }
266
267 fn mkdir<'a>(
268 &'a self,
269 context: FilesystemCallContext,
270 path: String,
271 options: MkdirOptions,
272 mutation: FilesystemMutationContext,
273 ) -> FilesystemFuture<'a, FilesystemMutation> {
274 self.mutation(
275 context,
276 path,
277 mutation,
278 FilesystemCapability::Mkdir,
279 move |client, path, mutation| client.mkdir(&path, options, &mutation),
280 )
281 }
282
283 fn delete<'a>(
284 &'a self,
285 context: FilesystemCallContext,
286 path: String,
287 options: DeleteOptions,
288 mutation: FilesystemMutationContext,
289 ) -> FilesystemFuture<'a, FilesystemMutation> {
290 self.mutation(
291 context,
292 path,
293 mutation,
294 FilesystemCapability::Delete,
295 move |client, path, mutation| client.delete(&path, options, &mutation),
296 )
297 }
298
299 fn copy<'a>(
300 &'a self,
301 context: FilesystemCallContext,
302 source: String,
303 target: String,
304 options: CopyOptions,
305 mutation: FilesystemMutationContext,
306 ) -> FilesystemFuture<'a, FilesystemMutation> {
307 let source = match crate::file::logical_normalise(&source) {
308 Ok(path) => path,
309 Err(error) => return failed(error),
310 };
311 let target = match crate::file::logical_normalise(&target) {
312 Ok(path) => path,
313 Err(error) => return failed(error),
314 };
315 let client = self.client.clone();
316 let closed = self.closed.clone();
317 let capabilities = self.capabilities.clone();
318 let read_only = self.read_only;
319 Box::pin(async move {
320 context.check()?;
321 ensure_open(&closed)?;
322 ensure_mutation(
323 &capabilities,
324 read_only,
325 FilesystemCapability::Copy,
326 &mutation,
327 )?;
328 if options.preserve_modified
329 && !capabilities.contains(FilesystemCapability::PreserveModified)
330 {
331 return Err(FileError::Unsupported);
332 }
333 client.copy(&source, &target, options, &mutation)
334 })
335 }
336
337 fn move_entry<'a>(
338 &'a self,
339 context: FilesystemCallContext,
340 source: String,
341 target: String,
342 options: MoveOptions,
343 mutation: FilesystemMutationContext,
344 ) -> FilesystemFuture<'a, FilesystemMutation> {
345 let source = match crate::file::logical_normalise(&source) {
346 Ok(path) => path,
347 Err(error) => return failed(error),
348 };
349 let target = match crate::file::logical_normalise(&target) {
350 Ok(path) => path,
351 Err(error) => return failed(error),
352 };
353 let client = self.client.clone();
354 let closed = self.closed.clone();
355 let capabilities = self.capabilities.clone();
356 let read_only = self.read_only;
357 Box::pin(async move {
358 context.check()?;
359 ensure_open(&closed)?;
360 ensure_mutation(
361 &capabilities,
362 read_only,
363 FilesystemCapability::Move,
364 &mutation,
365 )?;
366 if options.atomic && !capabilities.contains(FilesystemCapability::AtomicMove) {
367 return Err(FileError::Unsupported);
368 }
369 client.move_entry(&source, &target, options, &mutation)
370 })
371 }
372
373 fn mutation<'a, F>(
374 &'a self,
375 context: FilesystemCallContext,
376 path: String,
377 mutation: FilesystemMutationContext,
378 capability: FilesystemCapability,
379 operation: F,
380 ) -> FilesystemFuture<'a, FilesystemMutation>
381 where
382 F: FnOnce(
383 Rc<dyn RemoteFilesystemClient>,
384 String,
385 FilesystemMutationContext,
386 ) -> Result<FilesystemMutation, FileError>
387 + 'a,
388 {
389 let path = match crate::file::logical_normalise(&path) {
390 Ok(path) => path,
391 Err(error) => return failed(error),
392 };
393 let client = self.client.clone();
394 let closed = self.closed.clone();
395 let capabilities = self.capabilities.clone();
396 let read_only = self.read_only;
397 Box::pin(async move {
398 context.check()?;
399 ensure_open(&closed)?;
400 ensure_mutation(&capabilities, read_only, capability, &mutation)?;
401 operation(client, path, mutation)
402 })
403 }
404
405 fn close<'a>(&'a self, _context: FilesystemCallContext) -> FilesystemFuture<'a, ()> {
406 let client = self.client.clone();
407 let closed = self.closed.clone();
408 Box::pin(async move {
409 if closed.replace(true) {
410 return Ok(());
411 }
412 client.close()
413 })
414 }
415}
416
417fn effective_capabilities(
418 capabilities: FilesystemCapabilities,
419 read_only: bool,
420 blocked_capabilities: impl IntoIterator<Item = FilesystemCapability>,
421) -> FilesystemCapabilities {
422 let blocked_capabilities = blocked_capabilities
423 .into_iter()
424 .collect::<std::collections::BTreeSet<_>>();
425 if read_only {
426 FilesystemCapabilities::new(capabilities.iter().filter(|capability| {
427 matches!(
428 capability,
429 FilesystemCapability::Read | FilesystemCapability::Entries
430 ) && !blocked_capabilities.contains(capability)
431 }))
432 } else {
433 FilesystemCapabilities::new(
434 capabilities
435 .iter()
436 .filter(|capability| !blocked_capabilities.contains(capability)),
437 )
438 }
439}
440
441fn validate_display(display: String, kind: &str) -> Result<String, FileError> {
442 if display.trim().is_empty() {
443 return Err(FileError::InvalidPath(format!(
444 "{kind} filesystem display must not be empty"
445 )));
446 }
447 Ok(display)
448}
449
450fn ensure_open(closed: &Cell<bool>) -> Result<(), FileError> {
451 if closed.get() {
452 Err(FileError::Io("filesystem is closed".into()))
453 } else {
454 Ok(())
455 }
456}
457
458fn ensure_capability(
459 capabilities: &FilesystemCapabilities,
460 capability: FilesystemCapability,
461) -> Result<(), FileError> {
462 capabilities
463 .contains(capability)
464 .then_some(())
465 .ok_or(FileError::Unsupported)
466}
467
468fn ensure_mutation(
469 capabilities: &FilesystemCapabilities,
470 read_only: bool,
471 capability: FilesystemCapability,
472 mutation: &FilesystemMutationContext,
473) -> Result<(), FileError> {
474 if read_only {
475 return Err(FileError::PermissionDenied);
476 }
477 ensure_capability(capabilities, capability)?;
478 if mutation.required() && !capabilities.contains(FilesystemCapability::RevisionCheck) {
479 return Err(FileError::Unsupported);
480 }
481 Ok(())
482}
483
484fn validate_entry(entry: &FilesystemEntry, expected_path: Option<&str>) -> Result<(), FileError> {
485 let path = crate::file::logical_normalise(&entry.path)?;
486 if path != entry.path {
487 return Err(FileError::InvalidPath(
488 "provider returned a non-canonical logical path".into(),
489 ));
490 }
491 if let Some(expected_path) = expected_path {
492 if path != expected_path {
493 return Err(FileError::InvalidPath(
494 "provider returned a path different from the requested entry".into(),
495 ));
496 }
497 }
498 if crate::file::logical_name(&path)? != entry.name {
499 return Err(FileError::InvalidPath(
500 "provider returned an entry with an invalid name".into(),
501 ));
502 }
503 Ok(())
504}
505
506fn failed<'a, T>(error: FileError) -> FilesystemFuture<'a, T> {
507 Box::pin(async move { Err(error) })
508}
509
510macro_rules! delegate_filesystem {
511 ($provider:ty) => {
512 impl IFilesystem for $provider {
513 fn descriptor(&self) -> FilesystemDescriptor {
514 self.core.descriptor()
515 }
516
517 fn stat<'a>(
518 &'a self,
519 context: FilesystemCallContext,
520 path: String,
521 ) -> FilesystemFuture<'a, FilesystemEntry> {
522 self.core.stat(context, path)
523 }
524
525 fn read<'a>(
526 &'a self,
527 context: FilesystemCallContext,
528 path: String,
529 ) -> FilesystemFuture<'a, Vec<u8>> {
530 self.core.read(context, path)
531 }
532
533 fn write<'a>(
534 &'a self,
535 context: FilesystemCallContext,
536 path: String,
537 bytes: Vec<u8>,
538 options: WriteOptions,
539 mutation: FilesystemMutationContext,
540 ) -> FilesystemFuture<'a, FilesystemMutation> {
541 self.core.write(context, path, bytes, options, mutation)
542 }
543
544 fn entries_page<'a>(
545 &'a self,
546 context: FilesystemCallContext,
547 path: String,
548 request: FilesystemPageRequest,
549 ) -> FilesystemFuture<'a, FilesystemEntryPage> {
550 self.core.entries_page(context, path, request)
551 }
552
553 fn mkdir<'a>(
554 &'a self,
555 context: FilesystemCallContext,
556 path: String,
557 options: MkdirOptions,
558 mutation: FilesystemMutationContext,
559 ) -> FilesystemFuture<'a, FilesystemMutation> {
560 self.core.mkdir(context, path, options, mutation)
561 }
562
563 fn delete<'a>(
564 &'a self,
565 context: FilesystemCallContext,
566 path: String,
567 options: DeleteOptions,
568 mutation: FilesystemMutationContext,
569 ) -> FilesystemFuture<'a, FilesystemMutation> {
570 self.core.delete(context, path, options, mutation)
571 }
572
573 fn copy<'a>(
574 &'a self,
575 context: FilesystemCallContext,
576 source: String,
577 target: String,
578 options: CopyOptions,
579 mutation: FilesystemMutationContext,
580 ) -> FilesystemFuture<'a, FilesystemMutation> {
581 self.core.copy(context, source, target, options, mutation)
582 }
583
584 fn move_entry<'a>(
585 &'a self,
586 context: FilesystemCallContext,
587 source: String,
588 target: String,
589 options: MoveOptions,
590 mutation: FilesystemMutationContext,
591 ) -> FilesystemFuture<'a, FilesystemMutation> {
592 self.core
593 .move_entry(context, source, target, options, mutation)
594 }
595
596 fn close<'a>(&'a self, context: FilesystemCallContext) -> FilesystemFuture<'a, ()> {
597 self.core.close(context)
598 }
599 }
600 };
601}
602
603struct ScopedSftpClient {
611 client: Rc<dyn RemoteFilesystemClient>,
612 root: String,
613}
614
615impl ScopedSftpClient {
616 fn new(client: Rc<dyn RemoteFilesystemClient>, root: String) -> Self {
617 Self { client, root }
618 }
619
620 fn remote_path(&self, path: &str) -> Result<String, FileError> {
621 crate::file::logical_join(&self.root, path)
622 }
623
624 fn guard_ancestors(&self, path: &str) -> Result<(), FileError> {
625 self.ensure_ancestors(path, false)
626 }
627
628 fn ensure_ancestors(&self, path: &str, parents: bool) -> Result<(), FileError> {
629 let path = crate::file::logical_normalise(path)?;
630 let segments = path
631 .strip_prefix('/')
632 .unwrap_or_default()
633 .split('/')
634 .filter(|segment| !segment.is_empty())
635 .collect::<Vec<_>>();
636 let mut current = "/".to_owned();
637 for segment in segments.iter().take(segments.len().saturating_sub(1)) {
638 current = crate::file::logical_join(¤t, segment)?;
639 let entry = match self.client.stat(&self.remote_path(¤t)?) {
640 Ok(entry) => entry,
641 Err(FileError::NotFound) if parents => {
642 if !self
643 .client
644 .capabilities()
645 .contains(FilesystemCapability::Mkdir)
646 {
647 return Err(FileError::Unsupported);
648 }
649 self.client.mkdir(
650 &self.remote_path(¤t)?,
651 MkdirOptions {
652 parents: false,
653 exists_ok: false,
654 },
655 &FilesystemMutationContext::default(),
656 )?;
657 self.client.stat(&self.remote_path(¤t)?)?
658 }
659 Err(FileError::NotFound) => return Err(FileError::NotFound),
660 Err(error) => return Err(error),
661 };
662 if entry.kind == FileType::Symlink {
663 return Err(FileError::OutsideRoot);
664 }
665 if entry.kind != FileType::Directory {
666 return Err(FileError::NotDirectory);
667 }
668 }
669 Ok(())
670 }
671
672 fn rewrite_entry(
673 &self,
674 logical: &str,
675 mut entry: FilesystemEntry,
676 ) -> Result<FilesystemEntry, FileError> {
677 let logical = crate::file::logical_normalise(logical)?;
678 entry.path = logical.clone();
679 entry.name = crate::file::logical_name(&logical)?;
680 Ok(entry)
681 }
682
683 fn reject_symlink(entry: &FilesystemEntry) -> Result<(), FileError> {
684 if entry.kind == FileType::Symlink {
685 Err(FileError::Unsupported)
686 } else {
687 Ok(())
688 }
689 }
690
691 fn stat_remote(&self, logical: &str) -> Result<FilesystemEntry, FileError> {
692 self.guard_ancestors(logical)?;
693 let remote = self.remote_path(logical)?;
694 let entry = self.client.stat(&remote)?;
695 self.rewrite_entry(logical, entry)
696 }
697
698 fn optional_stat(&self, logical: &str) -> Result<Option<FilesystemEntry>, FileError> {
699 match self.client.stat(&self.remote_path(logical)?) {
700 Ok(entry) => self.rewrite_entry(logical, entry).map(Some),
701 Err(FileError::NotFound) => Ok(None),
702 Err(error) => Err(error),
703 }
704 }
705}
706
707impl RemoteFilesystemClient for ScopedSftpClient {
708 fn authenticated(&self) -> bool {
709 self.client.authenticated()
710 }
711
712 fn host_key_verified(&self) -> bool {
713 self.client.host_key_verified()
714 }
715
716 fn capabilities(&self) -> FilesystemCapabilities {
717 self.client.capabilities()
718 }
719
720 fn stat(&self, path: &str) -> Result<FilesystemEntry, FileError> {
721 self.stat_remote(path)
722 }
723
724 fn read(&self, path: &str) -> Result<Vec<u8>, FileError> {
725 let entry = self.stat_remote(path)?;
726 Self::reject_symlink(&entry)?;
727 if entry.kind == FileType::Directory {
728 return Err(FileError::IsDirectory);
729 }
730 if entry.kind != FileType::File {
731 return Err(FileError::Unsupported);
732 }
733 self.client.read(&self.remote_path(path)?)
734 }
735
736 fn write(
737 &self,
738 path: &str,
739 bytes: Vec<u8>,
740 options: WriteOptions,
741 mutation: &FilesystemMutationContext,
742 ) -> Result<FilesystemMutation, FileError> {
743 self.ensure_ancestors(path, options.parents)?;
744 if let Some(entry) = self.optional_stat(path)? {
745 Self::reject_symlink(&entry)?;
746 if entry.kind == FileType::Directory {
747 return Err(FileError::IsDirectory);
748 }
749 }
750 let mutation = self
751 .client
752 .write(&self.remote_path(path)?, bytes, options, mutation)?;
753 Ok(FilesystemMutation {
754 path: crate::file::logical_normalise(path)?,
755 ..mutation
756 })
757 }
758
759 fn entries_page(
760 &self,
761 path: &str,
762 request: &FilesystemPageRequest,
763 ) -> Result<FilesystemEntryPage, FileError> {
764 let directory = self.stat_remote(path)?;
765 if directory.kind == FileType::Symlink {
766 return Err(FileError::Unsupported);
767 }
768 if directory.kind != FileType::Directory {
769 return Err(FileError::NotDirectory);
770 }
771 let logical = crate::file::logical_normalise(path)?;
772 let page = self
773 .client
774 .entries_page(&self.remote_path(&logical)?, request)?;
775 let entries = page
776 .entries
777 .into_iter()
778 .map(|entry| {
779 let child = crate::file::logical_join(&logical, &entry.name)?;
780 self.rewrite_entry(&child, entry)
781 })
782 .collect::<Result<Vec<_>, _>>()?;
783 Ok(FilesystemEntryPage {
784 entries,
785 next_token: page.next_token,
786 })
787 }
788
789 fn mkdir(
790 &self,
791 path: &str,
792 options: MkdirOptions,
793 mutation: &FilesystemMutationContext,
794 ) -> Result<FilesystemMutation, FileError> {
795 let logical = crate::file::logical_normalise(path)?;
796 self.ensure_ancestors(&logical, options.parents)?;
797 if let Some(entry) = self.optional_stat(&logical)? {
798 if entry.kind == FileType::Directory && options.exists_ok {
799 return Ok(FilesystemMutation::path(logical));
800 }
801 return Err(if entry.kind == FileType::Symlink {
802 FileError::Unsupported
803 } else {
804 FileError::AlreadyExists
805 });
806 }
807 let mutation = self
808 .client
809 .mkdir(&self.remote_path(&logical)?, options, mutation)?;
810 Ok(FilesystemMutation {
811 path: logical,
812 ..mutation
813 })
814 }
815
816 fn delete(
817 &self,
818 path: &str,
819 options: DeleteOptions,
820 mutation: &FilesystemMutationContext,
821 ) -> Result<FilesystemMutation, FileError> {
822 let logical = crate::file::logical_normalise(path)?;
823 if logical == "/" {
824 return Err(FileError::Denied);
825 }
826 self.guard_ancestors(&logical)?;
827 let entry = match self.client.stat(&self.remote_path(&logical)?) {
828 Ok(entry) => entry,
829 Err(FileError::NotFound) if options.missing_ok => {
830 return Ok(FilesystemMutation::path(logical));
831 }
832 Err(error) => return Err(error),
833 };
834 Self::reject_symlink(&entry)?;
835 let mutation = self
836 .client
837 .delete(&self.remote_path(&logical)?, options, mutation)?;
838 Ok(FilesystemMutation {
839 path: logical,
840 ..mutation
841 })
842 }
843
844 fn copy(
845 &self,
846 source: &str,
847 target: &str,
848 options: CopyOptions,
849 mutation: &FilesystemMutationContext,
850 ) -> Result<FilesystemMutation, FileError> {
851 let source = crate::file::logical_normalise(source)?;
852 let target = crate::file::logical_normalise(target)?;
853 if source == target {
854 return Err(FileError::AlreadyExists);
855 }
856 self.guard_ancestors(&source)?;
857 self.ensure_ancestors(&target, options.parents)?;
858 let source_entry = self.stat_remote(&source)?;
859 Self::reject_symlink(&source_entry)?;
860 if source_entry.kind != FileType::File {
861 return Err(FileError::Unsupported);
862 }
863 if let Some(target_entry) = self.optional_stat(&target)? {
864 Self::reject_symlink(&target_entry)?;
865 if target_entry.kind == FileType::Directory {
866 return Err(FileError::IsDirectory);
867 }
868 }
869 let mutation = self.client.copy(
870 &self.remote_path(&source)?,
871 &self.remote_path(&target)?,
872 options,
873 mutation,
874 )?;
875 Ok(FilesystemMutation {
876 path: target,
877 ..mutation
878 })
879 }
880
881 fn move_entry(
882 &self,
883 source: &str,
884 target: &str,
885 options: MoveOptions,
886 mutation: &FilesystemMutationContext,
887 ) -> Result<FilesystemMutation, FileError> {
888 let source = crate::file::logical_normalise(source)?;
889 let target = crate::file::logical_normalise(target)?;
890 if source == "/" || target == "/" {
891 return Err(FileError::Denied);
892 }
893 if source == target {
894 self.stat_remote(&source)?;
895 return Ok(FilesystemMutation::path(target));
896 }
897 if target.starts_with(&(source.clone() + "/")) {
898 return Err(FileError::InvalidPath(
899 "cannot move an entry beneath itself".into(),
900 ));
901 }
902 self.guard_ancestors(&source)?;
903 self.ensure_ancestors(&target, options.parents)?;
904 let source_entry = self.stat_remote(&source)?;
905 Self::reject_symlink(&source_entry)?;
906 if let Some(target_entry) = self.optional_stat(&target)? {
907 Self::reject_symlink(&target_entry)?;
908 }
909 let mutation = self.client.move_entry(
910 &self.remote_path(&source)?,
911 &self.remote_path(&target)?,
912 options,
913 mutation,
914 )?;
915 Ok(FilesystemMutation {
916 path: target,
917 ..mutation
918 })
919 }
920
921 fn close(&self) -> Result<(), FileError> {
922 self.client.close()
923 }
924}
925
926pub struct SftpFilesystem {
929 core: RemoteFilesystem,
930}
931
932impl SftpFilesystem {
933 #[cfg(not(target_arch = "wasm32"))]
934 pub fn connect(
935 options: crate::filesystem::sftp::SftpConnectOptions,
936 root: impl Into<String>,
937 display: impl Into<String>,
938 read_only: bool,
939 ) -> Result<Self, FileError> {
940 let client = Rc::new(crate::filesystem::sftp::NativeSftpClient::connect(options)?);
941 match Self::new(client.clone(), root, display, read_only) {
942 Ok(filesystem) => Ok(filesystem),
943 Err(error) => {
944 let _ = client.close();
948 Err(error)
949 }
950 }
951 }
952
953 pub fn new(
954 client: Rc<dyn RemoteFilesystemClient>,
955 root: impl Into<String>,
956 display: impl Into<String>,
957 read_only: bool,
958 ) -> Result<Self, FileError> {
959 let root = sftp_root(root.into())?;
960 if !client.host_key_verified() {
961 return Err(FileError::PermissionDenied);
962 }
963 if !client.authenticated() {
964 return Err(FileError::PermissionDenied);
965 }
966 let root_entry = client.stat(&root)?;
967 if root_entry.kind == FileType::Symlink {
968 return Err(FileError::OutsideRoot);
969 }
970 if root_entry.kind != FileType::Directory {
971 return Err(FileError::NotDirectory);
972 }
973 let client = Rc::new(ScopedSftpClient::new(client, root));
974 Ok(Self {
975 core: RemoteFilesystem::new(
976 FilesystemProviderKind::Sftp,
977 client,
978 display.into(),
979 "/".into(),
980 read_only,
981 [],
982 [("provider/host-key-verified?", "true".into())],
983 )?,
984 })
985 }
986
987 pub fn from_client<C: RemoteFilesystemClient + 'static>(
988 client: C,
989 root: impl Into<String>,
990 display: impl Into<String>,
991 read_only: bool,
992 ) -> Result<Self, FileError> {
993 Self::new(Rc::new(client), root, display, read_only)
994 }
995}
996
997delegate_filesystem!(SftpFilesystem);
998
999fn sftp_root(value: String) -> Result<String, FileError> {
1000 if value.trim().is_empty() || !value.starts_with('/') {
1001 return Err(FileError::InvalidPath(
1002 "SFTP root must be an absolute POSIX path".into(),
1003 ));
1004 }
1005 if value.contains('\0') || value.contains('\\') {
1006 return Err(FileError::InvalidPath(
1007 "SFTP root contains an invalid character".into(),
1008 ));
1009 }
1010 if value
1011 .split('/')
1012 .any(|segment| segment == "." || segment == "..")
1013 {
1014 return Err(FileError::InvalidPath(
1015 "SFTP root cannot contain dot segments".into(),
1016 ));
1017 }
1018 crate::file::logical_normalise(&value)
1019}
1020
1021pub struct GoogleDriveFilesystem {
1024 core: RemoteFilesystem,
1025}
1026
1027impl GoogleDriveFilesystem {
1028 pub fn new(
1029 client: Rc<dyn RemoteFilesystemClient>,
1030 root_id: impl Into<String>,
1031 display: impl Into<String>,
1032 read_only: bool,
1033 ) -> Result<Self, FileError> {
1034 let root_id = require_text(root_id.into(), "Google Drive root id")?;
1035 Ok(Self {
1036 core: RemoteFilesystem::new(
1037 FilesystemProviderKind::GoogleDrive,
1038 client,
1039 display.into(),
1040 "/".into(),
1041 read_only,
1042 [
1043 FilesystemCapability::Append,
1044 FilesystemCapability::AtomicMove,
1045 FilesystemCapability::PreserveModified,
1046 ],
1047 [
1048 ("provider/workspace-documents", "unsupported".into()),
1049 ("provider/shared-drive?", "false".into()),
1050 ("provider/root-id", root_id),
1051 ],
1052 )?,
1053 })
1054 }
1055
1056 pub fn from_client<C: RemoteFilesystemClient + 'static>(
1057 client: C,
1058 root_id: impl Into<String>,
1059 display: impl Into<String>,
1060 read_only: bool,
1061 ) -> Result<Self, FileError> {
1062 Self::new(Rc::new(client), root_id, display, read_only)
1063 }
1064}
1065
1066delegate_filesystem!(GoogleDriveFilesystem);
1067
1068pub struct S3Filesystem {
1071 core: RemoteFilesystem,
1072}
1073
1074impl S3Filesystem {
1075 pub fn new(
1076 client: Rc<dyn RemoteFilesystemClient>,
1077 bucket: impl Into<String>,
1078 prefix: impl Into<String>,
1079 display: impl Into<String>,
1080 read_only: bool,
1081 ) -> Result<Self, FileError> {
1082 let bucket = require_bucket(bucket.into())?;
1083 let prefix = validate_prefix(prefix.into())?;
1084 Ok(Self {
1085 core: RemoteFilesystem::new(
1086 FilesystemProviderKind::S3,
1087 client,
1088 display.into(),
1089 "/".into(),
1090 read_only,
1091 [
1092 FilesystemCapability::Mkdir,
1093 FilesystemCapability::Append,
1094 FilesystemCapability::AtomicMove,
1095 FilesystemCapability::PreserveModified,
1096 ],
1097 [
1098 ("provider/virtual-directories?", "true".into()),
1099 ("provider/atomic-move?", "false".into()),
1100 ("provider/bucket", bucket),
1101 ("provider/prefix", prefix),
1102 ],
1103 )?,
1104 })
1105 }
1106
1107 pub fn from_client<C: RemoteFilesystemClient + 'static>(
1108 client: C,
1109 bucket: impl Into<String>,
1110 prefix: impl Into<String>,
1111 display: impl Into<String>,
1112 read_only: bool,
1113 ) -> Result<Self, FileError> {
1114 Self::new(Rc::new(client), bucket, prefix, display, read_only)
1115 }
1116}
1117
1118delegate_filesystem!(S3Filesystem);
1119
1120pub struct GitHubFilesystem {
1123 core: RemoteFilesystem,
1124}
1125
1126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1127pub enum GitHubMountMode {
1128 ReadOnly,
1129 Commit,
1130}
1131
1132impl GitHubFilesystem {
1133 pub fn new(
1134 client: Rc<dyn RemoteFilesystemClient>,
1135 repository: impl Into<String>,
1136 reference: impl Into<String>,
1137 root: impl Into<String>,
1138 mode: GitHubMountMode,
1139 display: impl Into<String>,
1140 ) -> Result<Self, FileError> {
1141 let repository = require_repository(repository.into())?;
1142 let reference = require_text(reference.into(), "GitHub ref")?;
1143 if matches!(mode, GitHubMountMode::Commit) && !reference.starts_with("heads/") {
1144 return Err(FileError::InvalidPath(
1145 "writable GitHub mounts require a heads/* ref".into(),
1146 ));
1147 }
1148 let read_only = matches!(mode, GitHubMountMode::ReadOnly);
1149 let root = crate::file::logical_normalise(&root.into())?;
1150 Ok(Self {
1151 core: RemoteFilesystem::new(
1152 FilesystemProviderKind::GitHub,
1153 client,
1154 display.into(),
1155 root.clone(),
1156 read_only,
1157 [
1158 FilesystemCapability::Mkdir,
1159 FilesystemCapability::Append,
1160 FilesystemCapability::AtomicMove,
1161 FilesystemCapability::PreserveModified,
1162 ],
1163 [
1164 ("provider/repository", repository),
1165 ("provider/ref", reference),
1166 ("provider/root", root),
1167 (
1168 "provider/mode",
1169 if read_only { "read-only" } else { "commit" }.into(),
1170 ),
1171 ],
1172 )?,
1173 })
1174 }
1175
1176 pub fn from_client<C: RemoteFilesystemClient + 'static>(
1177 client: C,
1178 repository: impl Into<String>,
1179 reference: impl Into<String>,
1180 root: impl Into<String>,
1181 mode: GitHubMountMode,
1182 display: impl Into<String>,
1183 ) -> Result<Self, FileError> {
1184 Self::new(Rc::new(client), repository, reference, root, mode, display)
1185 }
1186}
1187
1188delegate_filesystem!(GitHubFilesystem);
1189
1190pub struct WebdavFilesystem {
1192 core: RemoteFilesystem,
1193}
1194
1195impl WebdavFilesystem {
1196 pub fn new(
1197 client: Rc<dyn RemoteFilesystemClient>,
1198 root: impl Into<String>,
1199 display: impl Into<String>,
1200 read_only: bool,
1201 ) -> Result<Self, FileError> {
1202 if !client.transport_verified() {
1203 return Err(FileError::PermissionDenied);
1204 }
1205 Ok(Self {
1206 core: RemoteFilesystem::new(
1207 FilesystemProviderKind::WebDav,
1208 client,
1209 display.into(),
1210 root.into(),
1211 read_only,
1212 [
1213 FilesystemCapability::Append,
1214 FilesystemCapability::AtomicMove,
1215 FilesystemCapability::PreserveModified,
1216 ],
1217 [("provider/transport-verified?", "true".into())],
1218 )?,
1219 })
1220 }
1221
1222 pub fn from_client<C: RemoteFilesystemClient + 'static>(
1223 client: C,
1224 root: impl Into<String>,
1225 display: impl Into<String>,
1226 read_only: bool,
1227 ) -> Result<Self, FileError> {
1228 Self::new(Rc::new(client), root, display, read_only)
1229 }
1230}
1231
1232delegate_filesystem!(WebdavFilesystem);
1233
1234fn require_text(value: String, label: &str) -> Result<String, FileError> {
1235 if value.trim().is_empty() || value.contains('\0') {
1236 return Err(FileError::InvalidPath(format!("{label} must not be empty")));
1237 }
1238 Ok(value)
1239}
1240
1241fn require_bucket(value: String) -> Result<String, FileError> {
1242 let value = require_text(value, "S3 bucket")?;
1243 if value.contains('/') || value.contains('\\') {
1244 return Err(FileError::InvalidPath(
1245 "S3 bucket must not contain path separators".into(),
1246 ));
1247 }
1248 Ok(value)
1249}
1250
1251fn validate_prefix(value: String) -> Result<String, FileError> {
1252 if value.contains('\0') || value.contains('\\') {
1253 return Err(FileError::InvalidPath(
1254 "S3 prefix contains an invalid character".into(),
1255 ));
1256 }
1257 Ok(value.trim_matches('/').to_owned())
1258}
1259
1260fn require_repository(value: String) -> Result<String, FileError> {
1261 let value = require_text(value, "GitHub repository")?;
1262 let mut parts = value.split('/');
1263 match (parts.next(), parts.next(), parts.next()) {
1264 (Some(owner), Some(name), None)
1265 if !owner.is_empty()
1266 && !name.is_empty()
1267 && owner.chars().all(|character| {
1268 character.is_ascii_alphanumeric() || "_.-".contains(character)
1269 })
1270 && name.chars().all(|character| {
1271 character.is_ascii_alphanumeric() || "_.-".contains(character)
1272 }) =>
1273 {
1274 Ok(value)
1275 }
1276 _ => Err(FileError::InvalidPath(
1277 "GitHub repository must be owner/name".into(),
1278 )),
1279 }
1280}
1281
1282#[derive(Clone)]
1285pub struct MemoryRemoteClient {
1286 provider: crate::file::MemoryFileProvider,
1287 capabilities: FilesystemCapabilities,
1288 authenticated: bool,
1289 host_key_verified: bool,
1290 closed: Rc<Cell<bool>>,
1291}
1292
1293impl MemoryRemoteClient {
1294 pub fn new() -> Self {
1295 Self::with_capabilities(FilesystemCapabilities::legacy_read_write())
1296 }
1297
1298 pub fn with_capabilities(capabilities: FilesystemCapabilities) -> Self {
1299 Self {
1300 provider: crate::file::MemoryFileProvider::new("/"),
1301 capabilities,
1302 authenticated: true,
1303 host_key_verified: true,
1304 closed: Rc::new(Cell::new(false)),
1305 }
1306 }
1307
1308 pub fn unauthenticated(mut self) -> Self {
1309 self.authenticated = false;
1310 self
1311 }
1312
1313 pub fn with_unverified_host_key(mut self) -> Self {
1314 self.host_key_verified = false;
1315 self
1316 }
1317
1318 pub fn insert(&self, path: &str, bytes: Vec<u8>) -> Result<(), FileError> {
1319 self.provider.insert(path, bytes)
1320 }
1321
1322 pub fn provider(&self) -> &crate::file::MemoryFileProvider {
1323 &self.provider
1324 }
1325
1326 fn check_open(&self) -> Result<(), FileError> {
1327 if self.closed.get() {
1328 Err(FileError::Io("provider client is closed".into()))
1329 } else {
1330 Ok(())
1331 }
1332 }
1333
1334 fn mutation(path: String) -> FilesystemMutation {
1335 FilesystemMutation::path(path)
1336 }
1337}
1338
1339impl Default for MemoryRemoteClient {
1340 fn default() -> Self {
1341 Self::new()
1342 }
1343}
1344
1345impl RemoteFilesystemClient for MemoryRemoteClient {
1346 fn authenticated(&self) -> bool {
1347 self.authenticated
1348 }
1349
1350 fn host_key_verified(&self) -> bool {
1351 self.host_key_verified
1352 }
1353
1354 fn capabilities(&self) -> FilesystemCapabilities {
1355 self.capabilities.clone()
1356 }
1357
1358 fn stat(&self, path: &str) -> Result<FilesystemEntry, FileError> {
1359 self.check_open()?;
1360 Ok(self.provider.stat_entry(path)?.into())
1361 }
1362
1363 fn read(&self, path: &str) -> Result<Vec<u8>, FileError> {
1364 self.check_open()?;
1365 self.provider.read_bytes(path)
1366 }
1367
1368 fn write(
1369 &self,
1370 path: &str,
1371 bytes: Vec<u8>,
1372 options: WriteOptions,
1373 _mutation: &FilesystemMutationContext,
1374 ) -> Result<FilesystemMutation, FileError> {
1375 self.check_open()?;
1376 Ok(Self::mutation(
1377 self.provider.write_bytes(path, bytes, options)?,
1378 ))
1379 }
1380
1381 fn entries_page(
1382 &self,
1383 path: &str,
1384 request: &FilesystemPageRequest,
1385 ) -> Result<FilesystemEntryPage, FileError> {
1386 self.check_open()?;
1387 let entries = self
1388 .provider
1389 .entries_values(path)?
1390 .into_iter()
1391 .map(FilesystemEntry::from)
1392 .collect::<Vec<_>>();
1393 let offset = request
1394 .token
1395 .as_deref()
1396 .unwrap_or("0")
1397 .parse::<usize>()
1398 .map_err(|_| FileError::InvalidPath("invalid filesystem page token".into()))?;
1399 if offset > entries.len() {
1400 return Err(FileError::InvalidPath(
1401 "filesystem page token is out of range".into(),
1402 ));
1403 }
1404 let limit = request.limit.max(1);
1405 let end = offset.saturating_add(limit).min(entries.len());
1406 let next_token = (end < entries.len()).then(|| end.to_string());
1407 Ok(FilesystemEntryPage {
1408 entries: entries[offset..end].to_vec(),
1409 next_token,
1410 })
1411 }
1412
1413 fn mkdir(
1414 &self,
1415 path: &str,
1416 options: MkdirOptions,
1417 _mutation: &FilesystemMutationContext,
1418 ) -> Result<FilesystemMutation, FileError> {
1419 self.check_open()?;
1420 Ok(Self::mutation(self.provider.mkdir_path(path, options)?))
1421 }
1422
1423 fn delete(
1424 &self,
1425 path: &str,
1426 options: DeleteOptions,
1427 _mutation: &FilesystemMutationContext,
1428 ) -> Result<FilesystemMutation, FileError> {
1429 self.check_open()?;
1430 Ok(Self::mutation(self.provider.delete_path(path, options)?))
1431 }
1432
1433 fn copy(
1434 &self,
1435 source: &str,
1436 target: &str,
1437 options: CopyOptions,
1438 _mutation: &FilesystemMutationContext,
1439 ) -> Result<FilesystemMutation, FileError> {
1440 self.check_open()?;
1441 Ok(Self::mutation(
1442 self.provider.copy_path(source, target, options)?,
1443 ))
1444 }
1445
1446 fn move_entry(
1447 &self,
1448 source: &str,
1449 target: &str,
1450 options: MoveOptions,
1451 _mutation: &FilesystemMutationContext,
1452 ) -> Result<FilesystemMutation, FileError> {
1453 self.check_open()?;
1454 Ok(Self::mutation(
1455 self.provider.move_path(source, target, options)?,
1456 ))
1457 }
1458
1459 fn close(&self) -> Result<(), FileError> {
1460 self.closed.set(true);
1461 Ok(())
1462 }
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467 use super::*;
1468 use crate::filesystem::{FilesystemCapability, FilesystemHandle};
1469 use crate::filesystem_bridge::block_on_local;
1470
1471 #[test]
1472 fn all_remote_providers_publish_redacted_provider_descriptors() {
1473 let client = MemoryRemoteClient::new();
1474 client.insert("/hello.txt", b"hello".to_vec()).unwrap();
1475
1476 let sftp = SftpFilesystem::from_client(
1477 client.clone().with_unverified_host_key(),
1478 "/srv",
1479 "trusted SFTP",
1480 false,
1481 );
1482 assert_eq!(sftp.err().unwrap().code(), "permission-denied");
1483
1484 let drive = GoogleDriveFilesystem::from_client(client.clone(), "drive-root", "Drive", true)
1485 .unwrap();
1486 assert_eq!(drive.core.descriptor.kind(), "google-drive");
1487 assert_eq!(
1488 drive
1489 .core
1490 .descriptor
1491 .extensions()
1492 .get("provider/root-scoped?"),
1493 Some(&"true".to_string())
1494 );
1495 assert_eq!(
1496 drive.core.descriptor.extensions().get("provider/root-id"),
1497 Some(&"drive-root".to_string())
1498 );
1499
1500 let s3 =
1501 S3Filesystem::from_client(client.clone(), "bucket", "prefix/", "S3", false).unwrap();
1502 assert_eq!(s3.core.descriptor.kind(), "s3");
1503 assert_eq!(
1504 s3.core.descriptor.extensions().get("provider/atomic-move?"),
1505 Some(&"false".to_string())
1506 );
1507 assert_eq!(
1508 s3.core.descriptor.extensions().get("provider/bucket"),
1509 Some(&"bucket".to_string())
1510 );
1511 assert_eq!(
1512 s3.core.descriptor.extensions().get("provider/prefix"),
1513 Some(&"prefix".to_string())
1514 );
1515
1516 let github = GitHubFilesystem::from_client(
1517 client.clone(),
1518 "hara-lang/hara",
1519 "heads/main",
1520 "/",
1521 GitHubMountMode::Commit,
1522 "hara",
1523 )
1524 .unwrap();
1525 assert_eq!(
1526 github.core.descriptor.extensions().get("provider/mode"),
1527 Some(&"commit".to_string())
1528 );
1529
1530 let webdav = WebdavFilesystem::from_client(client, "/remote", "WebDAV", true).unwrap();
1531 assert_eq!(webdav.core.descriptor.kind(), "webdav");
1532 assert_eq!(
1533 webdav
1534 .core
1535 .descriptor
1536 .extensions()
1537 .get("provider/transport-verified?"),
1538 Some(&"true".to_string())
1539 );
1540 }
1541
1542 #[test]
1543 fn provider_operations_are_async_and_confined_to_canonical_paths() {
1544 let client = MemoryRemoteClient::new();
1545 client.insert("/hello.txt", b"hello".to_vec()).unwrap();
1546 let filesystem = SftpFilesystem::from_client(client, "/", "SFTP", false).unwrap();
1547 let entry = block_on_local(
1548 filesystem.stat(FilesystemCallContext::default(), "/./hello.txt".into()),
1549 )
1550 .unwrap();
1551 assert_eq!(entry.path, "/hello.txt");
1552 let bytes =
1553 block_on_local(filesystem.read(FilesystemCallContext::default(), "/hello.txt".into()))
1554 .unwrap();
1555 assert_eq!(bytes, b"hello");
1556 let page = block_on_local(filesystem.entries_page(
1557 FilesystemCallContext::default(),
1558 "/".into(),
1559 FilesystemPageRequest::default(),
1560 ))
1561 .unwrap();
1562 assert_eq!(page.entries.len(), 1);
1563 assert!(page.next_token.is_none());
1564 let error = block_on_local(
1565 filesystem.read(FilesystemCallContext::default(), "/../../secret".into()),
1566 )
1567 .unwrap_err();
1568 assert_eq!(error.code(), "outside-root");
1569 }
1570
1571 #[test]
1572 fn sftp_mount_maps_logical_paths_to_the_private_remote_root() {
1573 let client = MemoryRemoteClient::new();
1574 client
1575 .insert("/srv/application/hello.txt", b"hello".to_vec())
1576 .unwrap();
1577 let filesystem =
1578 SftpFilesystem::from_client(client.clone(), "/srv/application", "SFTP", false).unwrap();
1579
1580 let entry =
1581 block_on_local(filesystem.stat(FilesystemCallContext::default(), "/hello.txt".into()))
1582 .unwrap();
1583 assert_eq!(entry.path, "/hello.txt");
1584 assert_eq!(entry.name, "hello.txt");
1585 let bytes =
1586 block_on_local(filesystem.read(FilesystemCallContext::default(), "/hello.txt".into()))
1587 .unwrap();
1588 assert_eq!(bytes, b"hello");
1589
1590 block_on_local(filesystem.write(
1591 FilesystemCallContext::default(),
1592 "/new.txt".into(),
1593 b"new".to_vec(),
1594 WriteOptions::default(),
1595 FilesystemMutationContext::default(),
1596 ))
1597 .unwrap();
1598 assert_eq!(
1599 client
1600 .provider()
1601 .read_bytes("/srv/application/new.txt")
1602 .unwrap(),
1603 b"new"
1604 );
1605
1606 let invalid_root = SftpFilesystem::from_client(
1607 MemoryRemoteClient::new(),
1608 "/srv/../application",
1609 "SFTP",
1610 false,
1611 )
1612 .err()
1613 .unwrap();
1614 assert_eq!(invalid_root.code(), "invalid-path");
1615 }
1616
1617 #[test]
1618 fn sftp_mutations_enforce_parent_and_mount_boundaries() {
1619 let client = MemoryRemoteClient::new();
1620 client.insert("/source.txt", b"source".to_vec()).unwrap();
1621 let filesystem = SftpFilesystem::from_client(client.clone(), "/", "SFTP", false).unwrap();
1622
1623 let missing_parent = block_on_local(filesystem.write(
1624 FilesystemCallContext::default(),
1625 "/missing/file.txt".into(),
1626 b"data".to_vec(),
1627 WriteOptions::default(),
1628 FilesystemMutationContext::default(),
1629 ))
1630 .unwrap_err();
1631 assert_eq!(missing_parent.code(), "not-found");
1632
1633 block_on_local(filesystem.write(
1634 FilesystemCallContext::default(),
1635 "/missing/file.txt".into(),
1636 b"data".to_vec(),
1637 WriteOptions {
1638 parents: true,
1639 ..WriteOptions::default()
1640 },
1641 FilesystemMutationContext::default(),
1642 ))
1643 .unwrap();
1644 assert_eq!(
1645 client.provider().read_bytes("/missing/file.txt").unwrap(),
1646 b"data"
1647 );
1648
1649 let target_directory = block_on_local(filesystem.copy(
1650 FilesystemCallContext::default(),
1651 "/source.txt".into(),
1652 "/missing".into(),
1653 CopyOptions {
1654 replace: true,
1655 ..CopyOptions::default()
1656 },
1657 FilesystemMutationContext::default(),
1658 ))
1659 .unwrap_err();
1660 assert_eq!(target_directory.code(), "is-directory");
1661
1662 let same_move = block_on_local(filesystem.move_entry(
1663 FilesystemCallContext::default(),
1664 "/source.txt".into(),
1665 "/./source.txt".into(),
1666 MoveOptions::default(),
1667 FilesystemMutationContext::default(),
1668 ))
1669 .unwrap();
1670 assert_eq!(same_move.path, "/source.txt");
1671
1672 let root_delete = block_on_local(filesystem.delete(
1673 FilesystemCallContext::default(),
1674 "/".into(),
1675 DeleteOptions::default(),
1676 FilesystemMutationContext::default(),
1677 ))
1678 .unwrap_err();
1679 assert_eq!(root_delete.code(), "denied");
1680 }
1681
1682 #[test]
1683 fn read_only_and_close_boundaries_are_deterministic() {
1684 let client = MemoryRemoteClient::new();
1685 let filesystem = WebdavFilesystem::from_client(client, "/", "WebDAV", true).unwrap();
1686 let write = block_on_local(filesystem.write(
1687 FilesystemCallContext::default(),
1688 "/new".into(),
1689 b"data".to_vec(),
1690 WriteOptions::default(),
1691 FilesystemMutationContext::default(),
1692 ))
1693 .unwrap_err();
1694 assert_eq!(write.code(), "permission-denied");
1695 block_on_local(filesystem.close(FilesystemCallContext::default())).unwrap();
1696 block_on_local(filesystem.close(FilesystemCallContext::default())).unwrap();
1697 let closed = block_on_local(filesystem.read(FilesystemCallContext::default(), "/".into()))
1698 .unwrap_err();
1699 assert_eq!(closed.code(), "io");
1700 }
1701
1702 #[test]
1703 fn provider_handles_mount_through_the_runtime_adapter() {
1704 let client = MemoryRemoteClient::new();
1705 client.insert("/hello", b"world".to_vec()).unwrap();
1706 let handle = FilesystemHandle::new(
1707 WebdavFilesystem::from_client(client, "/", "WebDAV", true).unwrap(),
1708 );
1709 assert_eq!(handle.descriptor().kind(), "webdav");
1710 assert!(handle.capabilities().contains(FilesystemCapability::Read));
1711 }
1712}