Skip to main content

hara_native/file/
providers.rs

1//! Trusted remote filesystem provider projections.
2//!
3//! The runtime owns the logical filesystem contract.  Network clients are
4//! deliberately supplied by the host through [`RemoteFilesystemClient`];
5//! credentials, endpoint URLs, authentication, and transport state therefore
6//! never become part of a mounted descriptor.
7
8use 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/// Provider kinds with a Rust semantic projection.
21#[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
42/// Host-owned authenticated transport capability for a remote filesystem.
43///
44/// Implementations must keep credentials and transport details private.  The
45/// methods return only provider-neutral values so callers cannot accidentally
46/// expose access tokens, URLs, or provider-specific response objects.
47pub trait RemoteFilesystemClient {
48    fn authenticated(&self) -> bool;
49
50    /// SFTP clients must override this with their host-key verification result.
51    fn host_key_verified(&self) -> bool {
52        true
53    }
54
55    /// HTTPS/WebDAV clients must override this when certificate or endpoint
56    /// verification is not guaranteed by the host transport.
57    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
603/// A root-scoped, no-follow view over a host-owned SFTP capability.
604///
605/// SFTP paths are remote POSIX paths, so the generic remote projection cannot
606/// be used directly: it would pass logical paths to the transport and would
607/// not inspect ancestors before following them.  This adapter keeps the
608/// configured root private while enforcing the same confinement rules as the
609/// JVM provider.
610struct 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(&current, segment)?;
639            let entry = match self.client.stat(&self.remote_path(&current)?) {
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(&current)?,
651                        MkdirOptions {
652                            parents: false,
653                            exists_ok: false,
654                        },
655                        &FilesystemMutationContext::default(),
656                    )?;
657                    self.client.stat(&self.remote_path(&current)?)?
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
926/// SFTP filesystem projection. Opening requires authentication and verified
927/// host keys in addition to the normal provider capability checks.
928pub 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                // A root validation failure must tear down the authenticated
945                // transport instead of leaving its worker alive in the
946                // background.
947                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
1021/// Google Drive folder projection.  The root is a provider-owned stable ID,
1022/// not a user-visible URL or credential.
1023pub 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
1068/// S3-compatible bucket/prefix projection with virtual-directory semantics
1069/// delegated to the authenticated client.
1070pub 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
1120/// GitHub tree/blob projection.  Commit mode is writable only when the host
1121/// client advertises the required mutation capabilities.
1122pub 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
1190/// WebDAV collection projection over a verified, authenticated client.
1191pub 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/// A deterministic in-memory client useful for host integration tests and
1283/// browser adapters that have not yet bound a real transport.
1284#[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}