Skip to main content

synwire_agent/vfs/
composite.rs

1//! Composite VFS provider routing by path prefix.
2
3use synwire_core::BoxFuture;
4use synwire_core::vfs::error::VfsError;
5use synwire_core::vfs::grep_options::GrepOptions;
6use synwire_core::vfs::protocol::Vfs;
7use synwire_core::vfs::types::{
8    CpOptions, DirEntry, EditResult, FileContent, GlobEntry, GrepMatch, LsOptions, MountInfo,
9    RmOptions, TransferResult, VfsCapabilities, WriteResult,
10};
11
12/// A single mount point mapping a path prefix to a backend.
13pub struct Mount {
14    /// Prefix (e.g. `/store` or `/git`).  Must start with `/`.
15    pub prefix: String,
16    /// Provider serving this mount point.
17    pub backend: Box<dyn Vfs>,
18}
19
20impl std::fmt::Debug for Mount {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.debug_struct("Mount")
23            .field("prefix", &self.prefix)
24            .finish_non_exhaustive()
25    }
26}
27
28/// Routes operations to the provider whose prefix is the longest match.
29///
30/// Mounts are sorted by descending prefix length so the most specific
31/// mount wins.  Segment-boundary matching is enforced: `/store` matches
32/// `/store/foo` but not `/storefront`.
33pub struct CompositeProvider {
34    mounts: Vec<Mount>,
35}
36
37impl std::fmt::Debug for CompositeProvider {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("CompositeProvider")
40            .field(
41                "mounts",
42                &self.mounts.iter().map(|m| &m.prefix).collect::<Vec<_>>(),
43            )
44            .finish()
45    }
46}
47
48impl CompositeProvider {
49    /// Create a new composite backend from a list of mounts.
50    ///
51    /// Mounts are sorted by descending prefix length automatically.
52    #[must_use]
53    pub fn new(mut mounts: Vec<Mount>) -> Self {
54        mounts.sort_by(|a, b| b.prefix.len().cmp(&a.prefix.len()));
55        Self { mounts }
56    }
57
58    fn find_mount(&self, path: &str) -> Option<(&dyn Vfs, String)> {
59        for mount in &self.mounts {
60            let prefix = &mount.prefix;
61            // Segment-boundary check: path must start with prefix followed by `/` or be equal.
62            if path == prefix || path.starts_with(&format!("{}/", prefix.trim_end_matches('/'))) {
63                // Strip the prefix to get the relative path for the backend.
64                let stripped = path
65                    .strip_prefix(prefix.trim_end_matches('/'))
66                    .unwrap_or(path);
67                let relative = if stripped.is_empty() { "/" } else { stripped };
68                return Some((mount.backend.as_ref(), relative.to_string()));
69            }
70        }
71        None
72    }
73}
74
75macro_rules! delegate {
76    ($self:expr, $path:expr, $method:ident $(, $arg:expr)*) => {{
77        let path = $path.to_string();
78        Box::pin(async move {
79            match $self.find_mount(&path) {
80                Some((backend, relative)) => backend.$method(&relative, $($arg,)*).await,
81                None => Err(VfsError::NotFound(path)),
82            }
83        })
84    }};
85}
86
87impl Vfs for CompositeProvider {
88    fn ls(&self, path: &str, opts: LsOptions) -> BoxFuture<'_, Result<Vec<DirEntry>, VfsError>> {
89        let path_str = path.to_string();
90        Box::pin(async move {
91            if let Some((backend, relative)) = self.find_mount(&path_str) {
92                return backend.ls(&relative, opts).await;
93            }
94            // Root or unmatched path: show mount points as directories
95            if path_str == "/" || path_str.is_empty() || path_str == "." {
96                let entries = self
97                    .mounts
98                    .iter()
99                    .map(|m| {
100                        let name = m
101                            .prefix
102                            .trim_start_matches('/')
103                            .split('/')
104                            .next()
105                            .unwrap_or(&m.prefix);
106                        DirEntry {
107                            name: name.to_string(),
108                            path: m.prefix.clone(),
109                            is_dir: true,
110                            size: None,
111                            modified: None,
112                            permissions: None,
113                            is_symlink: false,
114                        }
115                    })
116                    .collect();
117                return Ok(entries);
118            }
119            Err(VfsError::NotFound(path_str))
120        })
121    }
122
123    fn read(&self, path: &str) -> BoxFuture<'_, Result<FileContent, VfsError>> {
124        delegate!(self, path, read)
125    }
126
127    fn write(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>> {
128        let path = path.to_string();
129        let content = content.to_vec();
130        Box::pin(async move {
131            match self.find_mount(&path) {
132                Some((backend, relative)) => backend.write(&relative, &content).await,
133                None => Err(VfsError::NotFound(path)),
134            }
135        })
136    }
137
138    fn edit(
139        &self,
140        path: &str,
141        old: &str,
142        new: &str,
143    ) -> BoxFuture<'_, Result<EditResult, VfsError>> {
144        let path = path.to_string();
145        let old = old.to_string();
146        let new = new.to_string();
147        Box::pin(async move {
148            match self.find_mount(&path) {
149                Some((backend, relative)) => backend.edit(&relative, &old, &new).await,
150                None => Err(VfsError::NotFound(path)),
151            }
152        })
153    }
154
155    fn grep(
156        &self,
157        pattern: &str,
158        opts: GrepOptions,
159    ) -> BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>> {
160        let pattern = pattern.to_string();
161        Box::pin(async move {
162            let mut all = Vec::new();
163            for mount in &self.mounts {
164                if mount.backend.capabilities().contains(VfsCapabilities::GREP)
165                    && let Ok(mut matches) = mount.backend.grep(&pattern, opts.clone()).await
166                {
167                    // Prefix match file paths with mount prefix
168                    for m in &mut matches {
169                        if !m.file.starts_with(&mount.prefix) {
170                            let suffix = if m.file.starts_with('/') {
171                                m.file.clone()
172                            } else {
173                                format!("/{}", m.file)
174                            };
175                            m.file = format!("{}{}", mount.prefix.trim_end_matches('/'), suffix,);
176                        }
177                    }
178                    all.append(&mut matches);
179                }
180            }
181            if all.is_empty()
182                && !self
183                    .mounts
184                    .iter()
185                    .any(|m| m.backend.capabilities().contains(VfsCapabilities::GREP))
186            {
187                return Err(VfsError::Unsupported(
188                    "no mounted provider supports grep".into(),
189                ));
190            }
191            Ok(all)
192        })
193    }
194
195    fn glob(&self, pattern: &str) -> BoxFuture<'_, Result<Vec<GlobEntry>, VfsError>> {
196        // Aggregate from all mounts that support GLOB.
197        let pattern = pattern.to_string();
198        Box::pin(async move {
199            let mut all = Vec::new();
200            for mount in &self.mounts {
201                if mount.backend.capabilities().contains(VfsCapabilities::GLOB) {
202                    let mut entries = mount.backend.glob(&pattern).await?;
203                    all.append(&mut entries);
204                }
205            }
206            Ok(all)
207        })
208    }
209
210    fn upload(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
211        let to = to.to_string();
212        let from = from.to_string();
213        Box::pin(async move {
214            match self.find_mount(&to) {
215                Some((backend, relative)) => backend.upload(&from, &relative).await,
216                None => Err(VfsError::NotFound(to)),
217            }
218        })
219    }
220
221    fn download(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
222        let from = from.to_string();
223        let to = to.to_string();
224        Box::pin(async move {
225            match self.find_mount(&from) {
226                Some((backend, relative)) => backend.download(&relative, &to).await,
227                None => Err(VfsError::NotFound(from)),
228            }
229        })
230    }
231
232    fn pwd(&self) -> BoxFuture<'_, Result<String, VfsError>> {
233        Box::pin(async { Ok("/".to_string()) })
234    }
235
236    fn cd(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
237        delegate!(self, path, cd)
238    }
239
240    fn rm(&self, path: &str, opts: RmOptions) -> BoxFuture<'_, Result<(), VfsError>> {
241        delegate!(self, path, rm, opts)
242    }
243
244    fn cp(
245        &self,
246        from: &str,
247        to: &str,
248        opts: CpOptions,
249    ) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
250        let from = from.to_string();
251        let to = to.to_string();
252        Box::pin(async move {
253            let src_mount = self.find_mount(&from);
254            let dst_mount = self.find_mount(&to);
255            match (src_mount, dst_mount) {
256                (Some((src_backend, src_rel)), Some((dst_backend, dst_rel))) => {
257                    if std::ptr::eq(src_backend, dst_backend) {
258                        return src_backend.cp(&src_rel, &dst_rel, opts).await;
259                    }
260                    // Cross-boundary: read from source, write to destination
261                    if opts.no_overwrite && dst_backend.stat(&dst_rel).await.is_ok() {
262                        return Ok(TransferResult {
263                            path: to,
264                            bytes_transferred: 0,
265                        });
266                    }
267                    let content = src_backend.read(&src_rel).await?;
268                    let result = dst_backend.write(&dst_rel, &content.content).await?;
269                    Ok(TransferResult {
270                        path: to,
271                        bytes_transferred: result.bytes_written,
272                    })
273                }
274                (None, _) => Err(VfsError::NotFound(from)),
275                (_, None) => Err(VfsError::NotFound(to)),
276            }
277        })
278    }
279
280    fn mv_file(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
281        let from = from.to_string();
282        let to = to.to_string();
283        Box::pin(async move {
284            let src_mount = self.find_mount(&from);
285            let dst_mount = self.find_mount(&to);
286            match (src_mount, dst_mount) {
287                (Some((src_backend, src_rel)), Some((dst_backend, dst_rel))) => {
288                    if std::ptr::eq(src_backend, dst_backend) {
289                        return src_backend.mv_file(&src_rel, &dst_rel).await;
290                    }
291                    // Cross-boundary: read, write, delete
292                    let content = src_backend.read(&src_rel).await?;
293                    let bytes = content.content.len() as u64;
294                    let _ = dst_backend.write(&dst_rel, &content.content).await?;
295                    src_backend.rm(&src_rel, RmOptions::default()).await?;
296                    Ok(TransferResult {
297                        path: to,
298                        bytes_transferred: bytes,
299                    })
300                }
301                (None, _) => Err(VfsError::NotFound(from)),
302                (_, None) => Err(VfsError::NotFound(to)),
303            }
304        })
305    }
306
307    fn capabilities(&self) -> VfsCapabilities {
308        self.mounts.iter().fold(VfsCapabilities::empty(), |acc, m| {
309            acc | m.backend.capabilities()
310        })
311    }
312
313    fn provider_name(&self) -> &'static str {
314        "CompositeProvider"
315    }
316
317    fn mount_info(&self) -> Vec<MountInfo> {
318        self.mounts
319            .iter()
320            .map(|m| {
321                let caps = m.backend.capabilities();
322                MountInfo {
323                    prefix: m.prefix.clone(),
324                    provider: m.backend.provider_name().to_string(),
325                    capabilities: synwire_core::vfs::protocol::capability_names(caps),
326                }
327            })
328            .collect()
329    }
330}
331
332#[cfg(test)]
333#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
334mod tests {
335    use super::*;
336    use synwire_core::vfs::MemoryProvider;
337
338    fn make_composite() -> CompositeProvider {
339        let fs1 = Box::new(MemoryProvider::new());
340        let fs2 = Box::new(MemoryProvider::new());
341        CompositeProvider::new(vec![
342            Mount {
343                prefix: "/store".to_string(),
344                backend: fs1,
345            },
346            Mount {
347                prefix: "/git".to_string(),
348                backend: fs2,
349            },
350        ])
351    }
352
353    #[tokio::test]
354    async fn test_composite_routing() {
355        let composite = make_composite();
356        let _ = composite
357            .write("/store/key1", b"data")
358            .await
359            .expect("write to /store");
360
361        let content = composite.read("/store/key1").await.expect("read /store");
362        assert_eq!(content.content, b"data");
363    }
364
365    #[tokio::test]
366    async fn test_path_traversal_rejection() {
367        let composite = make_composite();
368        // /storefront must NOT match /store.
369        let err = composite.write("/storefront/f", b"x").await;
370        assert!(err.is_err());
371    }
372
373    #[tokio::test]
374    async fn test_longer_prefix_wins() {
375        let deep = Box::new(MemoryProvider::new());
376        let shallow = Box::new(MemoryProvider::new());
377        let composite = CompositeProvider::new(vec![
378            Mount {
379                prefix: "/a/b".to_string(),
380                backend: deep,
381            },
382            Mount {
383                prefix: "/a".to_string(),
384                backend: shallow,
385            },
386        ]);
387        // /a/b/file should go to the /a/b mount.
388        let _ = composite.write("/a/b/file", b"deep").await.expect("write");
389        // /a/other should go to the /a mount.
390        let _ = composite
391            .write("/a/other", b"shallow")
392            .await
393            .expect("write shallow");
394    }
395}