1use 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
12pub struct Mount {
14 pub prefix: String,
16 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
28pub 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 #[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 if path == prefix || path.starts_with(&format!("{}/", prefix.trim_end_matches('/'))) {
63 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 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 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 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 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 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 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 let _ = composite.write("/a/b/file", b"deep").await.expect("write");
389 let _ = composite
391 .write("/a/other", b"shallow")
392 .await
393 .expect("write shallow");
394 }
395}