Skip to main content

solid_pod_rs/storage/
fs.rs

1//! Filesystem storage backend.
2//!
3//! Persists pod resources under a root directory. Each resource body
4//! is stored as a file. A sidecar file with the `.meta.json`
5//! extension carries the content-type and Link header values.
6
7use std::io::Write;
8use std::path::{Component, Path, PathBuf};
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use bytes::Bytes;
13use cap_std::ambient_authority;
14use cap_std::fs::{Dir, OpenOptions};
15use sha2::{Digest, Sha256};
16use tokio::fs;
17use tokio::sync::{mpsc, RwLock};
18
19use crate::error::PodError;
20use crate::storage::{ResourceMeta, Storage, StorageEvent};
21
22const META_SUFFIX: &str = ".meta.json";
23
24/// Filesystem-rooted `Storage` implementation.
25#[derive(Clone)]
26pub struct FsBackend {
27    root: Arc<PathBuf>,
28    dir: Arc<Dir>,
29    operations: Arc<RwLock<()>>,
30}
31
32#[derive(serde::Serialize, serde::Deserialize)]
33struct MetaSidecar {
34    content_type: String,
35    #[serde(default)]
36    links: Vec<String>,
37    /// Body generation this metadata describes. Legacy sidecars omit this;
38    /// they remain readable, while all new writes use it to reject a
39    /// crash-interrupted body/metadata pair.
40    #[serde(default)]
41    etag: Option<String>,
42}
43
44impl FsBackend {
45    /// Create a new backend rooted at `root`. The directory must
46    /// exist or be creatable; this call ensures it exists.
47    pub async fn new(root: impl Into<PathBuf>) -> Result<Self, PodError> {
48        let root: PathBuf = root.into();
49        fs::create_dir_all(&root).await?;
50        let root = fs::canonicalize(root).await?;
51        let open_root = root.clone();
52        let dir = tokio::task::spawn_blocking(move || {
53            Dir::open_ambient_dir(open_root, ambient_authority())
54        })
55        .await
56        .map_err(|e| PodError::Backend(format!("open filesystem root task failed: {e}")))??;
57        Ok(Self {
58            root: Arc::new(root),
59            dir: Arc::new(dir),
60            operations: Arc::new(RwLock::new(())),
61        })
62    }
63
64    /// Return the root directory.
65    pub fn root(&self) -> &Path {
66        &self.root
67    }
68
69    fn normalize(path: &str) -> Result<String, PodError> {
70        let p = if path.is_empty() {
71            "/".to_string()
72        } else if path.starts_with('/') {
73            path.to_string()
74        } else {
75            format!("/{path}")
76        };
77        if p.contains('\0') {
78            return Err(PodError::InvalidPath(p));
79        }
80        let rel = p.trim_start_matches('/');
81        if Path::new(rel).components().any(|component| {
82            matches!(
83                component,
84                Component::ParentDir | Component::RootDir | Component::Prefix(_)
85            )
86        }) {
87            return Err(PodError::InvalidPath(p));
88        }
89        Ok(p)
90    }
91
92    fn relative(path: &str) -> Result<PathBuf, PodError> {
93        let norm = Self::normalize(path)?;
94        Ok(PathBuf::from(norm.trim_start_matches('/')))
95    }
96
97    fn resolve(&self, path: &str) -> Result<PathBuf, PodError> {
98        Ok(self.root.join(Self::relative(path)?))
99    }
100
101    fn meta_path(data_path: &Path) -> PathBuf {
102        let mut p = data_path.as_os_str().to_owned();
103        p.push(META_SUFFIX);
104        PathBuf::from(p)
105    }
106
107    fn compute_etag(body: &[u8]) -> String {
108        hex::encode(Sha256::digest(body))
109    }
110
111    fn atomic_write(dir: &Dir, path: &Path, contents: &[u8]) -> std::io::Result<()> {
112        if let Some(parent) = path.parent() {
113            if !parent.as_os_str().is_empty() {
114                dir.create_dir_all(parent)?;
115            }
116        }
117        let file_name = path.file_name().ok_or_else(|| {
118            std::io::Error::new(
119                std::io::ErrorKind::InvalidInput,
120                "resource path has no filename",
121            )
122        })?;
123        let tmp_name = format!(
124            ".{}.solid-pod-tmp-{}",
125            file_name.to_string_lossy(),
126            uuid::Uuid::new_v4()
127        );
128        let tmp_path = path
129            .parent()
130            .unwrap_or_else(|| Path::new(""))
131            .join(tmp_name);
132        let mut options = OpenOptions::new();
133        options.write(true).create_new(true);
134        let result = (|| {
135            let mut file = dir.open_with(&tmp_path, &options)?;
136            file.write_all(contents)?;
137            file.sync_all()?;
138            dir.rename(&tmp_path, dir, path)?;
139            Ok(())
140        })();
141        if result.is_err() {
142            let _ = dir.remove_file(&tmp_path);
143        }
144        result
145    }
146
147    fn modified_time(metadata: &cap_std::fs::Metadata) -> chrono::DateTime<chrono::Utc> {
148        metadata
149            .modified()
150            .ok()
151            .map(cap_std::time::SystemTime::into_std)
152            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
153            .map(|d| {
154                chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
155                    .unwrap_or_else(chrono::Utc::now)
156            })
157            .unwrap_or_else(chrono::Utc::now)
158    }
159
160    fn read_meta(
161        dir: &Dir,
162        path: &str,
163        body_len: u64,
164        etag: String,
165        modified: chrono::DateTime<chrono::Utc>,
166    ) -> Result<ResourceMeta, PodError> {
167        let data_path = Self::relative(path)?;
168        let meta_path = Self::meta_path(&data_path);
169        // JSS #294 + #533 parity: sidecar-absent resources resolve their
170        // content-type by extension. `.acl` / `.meta` (and `*.acl` /
171        // `*.meta`) have no Node-style extension and fall back to
172        // `application/ld+json`; everything else (including git-extracted
173        // app files under `/public/apps/`) resolves via Solid overrides →
174        // the mime-types database → `application/octet-stream`, so audio,
175        // video, HTML, CSS, etc. render inline instead of downloading.
176        let fallback_ct: String = crate::ldp::guess_content_type(path);
177        let (content_type, links) = match dir.read(&meta_path) {
178            Ok(bytes) => {
179                let sidecar: MetaSidecar =
180                    serde_json::from_slice(&bytes).unwrap_or_else(|_| MetaSidecar {
181                        content_type: fallback_ct.clone(),
182                        links: Vec::new(),
183                        etag: None,
184                    });
185                if sidecar
186                    .etag
187                    .as_deref()
188                    .is_none_or(|expected| expected == etag)
189                {
190                    (sidecar.content_type, sidecar.links)
191                } else {
192                    (fallback_ct, Vec::new())
193                }
194            }
195            Err(_) => (fallback_ct, Vec::new()),
196        };
197        Ok(ResourceMeta {
198            etag,
199            modified,
200            size: body_len,
201            content_type,
202            links,
203        })
204    }
205}
206
207#[async_trait]
208impl Storage for FsBackend {
209    async fn get(&self, path: &str) -> Result<(Bytes, ResourceMeta), PodError> {
210        let _guard = self.operations.read().await;
211        let rel = Self::relative(path)?;
212        let dir = self.dir.clone();
213        let requested = path.to_string();
214        tokio::task::spawn_blocking(move || {
215            let body = match dir.read(&rel) {
216                Ok(body) => body,
217                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
218                    return Err(PodError::NotFound(requested.clone()));
219                }
220                Err(e) => return Err(e.into()),
221            };
222            let metadata = dir.metadata(&rel)?;
223            let modified = Self::modified_time(&metadata);
224            let etag = Self::compute_etag(&body);
225            let meta = Self::read_meta(&dir, &requested, body.len() as u64, etag, modified)?;
226            Ok((Bytes::from(body), meta))
227        })
228        .await
229        .map_err(|e| PodError::Backend(format!("filesystem read task failed: {e}")))?
230    }
231
232    async fn put(
233        &self,
234        path: &str,
235        body: Bytes,
236        content_type: &str,
237    ) -> Result<ResourceMeta, PodError> {
238        let _guard = self.operations.write().await;
239        let data_path = Self::relative(path)?;
240        if data_path.as_os_str().is_empty() {
241            return Err(PodError::InvalidPath(path.to_string()));
242        }
243        let etag = Self::compute_etag(&body);
244        let sidecar = MetaSidecar {
245            content_type: content_type.to_string(),
246            links: Vec::new(),
247            etag: Some(etag.clone()),
248        };
249        let sidecar_bytes = serde_json::to_vec(&sidecar)?;
250        let meta_path = Self::meta_path(&data_path);
251        let dir = self.dir.clone();
252        let body_to_write = body.clone();
253        tokio::task::spawn_blocking(move || {
254            // Publish metadata first. It is tagged with the future body ETag,
255            // so readers ignore it until the body rename commits the pair.
256            Self::atomic_write(&dir, &meta_path, &sidecar_bytes)?;
257            Self::atomic_write(&dir, &data_path, &body_to_write)?;
258            Ok::<(), PodError>(())
259        })
260        .await
261        .map_err(|e| PodError::Backend(format!("filesystem write task failed: {e}")))??;
262        Ok(ResourceMeta {
263            etag,
264            modified: chrono::Utc::now(),
265            size: body.len() as u64,
266            content_type: content_type.to_string(),
267            links: Vec::new(),
268        })
269    }
270
271    async fn delete(&self, path: &str) -> Result<(), PodError> {
272        let _guard = self.operations.write().await;
273        let data_path = Self::relative(path)?;
274        let meta_path = Self::meta_path(&data_path);
275        let dir = self.dir.clone();
276        let requested = path.to_string();
277        tokio::task::spawn_blocking(move || {
278            match dir.remove_file(&data_path) {
279                Ok(()) => {}
280                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
281                    return Err(PodError::NotFound(requested));
282                }
283                Err(e) => return Err(e.into()),
284            }
285            let _ = dir.remove_file(&meta_path);
286            Ok(())
287        })
288        .await
289        .map_err(|e| PodError::Backend(format!("filesystem delete task failed: {e}")))?
290    }
291
292    async fn list(&self, container: &str) -> Result<Vec<String>, PodError> {
293        let _guard = self.operations.read().await;
294        let container_path = Self::relative(container)?;
295        let dir = self.dir.clone();
296        tokio::task::spawn_blocking(move || {
297            let entries = match dir.read_dir(&container_path) {
298                Ok(entries) => entries,
299                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
300                Err(e) => return Err(PodError::Io(e)),
301            };
302            let mut out = Vec::new();
303            for entry in entries {
304                let entry = entry?;
305                let name = entry.file_name().to_string_lossy().to_string();
306                if name.ends_with(META_SUFFIX) || name.contains(".solid-pod-tmp-") {
307                    continue;
308                }
309                let is_dir = entry.metadata().map(|m| m.is_dir()).unwrap_or(false);
310                out.push(if is_dir { format!("{name}/") } else { name });
311            }
312            out.sort();
313            Ok(out)
314        })
315        .await
316        .map_err(|e| PodError::Backend(format!("filesystem list task failed: {e}")))?
317    }
318
319    async fn head(&self, path: &str) -> Result<ResourceMeta, PodError> {
320        let _guard = self.operations.read().await;
321        let data_path = Self::relative(path)?;
322        let dir = self.dir.clone();
323        let requested = path.to_string();
324        tokio::task::spawn_blocking(move || {
325            let metadata = match dir.metadata(&data_path) {
326                Ok(metadata) => metadata,
327                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
328                    return Err(PodError::NotFound(requested));
329                }
330                Err(e) => return Err(e.into()),
331            };
332            let body = dir.read(&data_path)?;
333            let etag = Self::compute_etag(&body);
334            let modified = Self::modified_time(&metadata);
335            Self::read_meta(&dir, &requested, body.len() as u64, etag, modified)
336        })
337        .await
338        .map_err(|e| PodError::Backend(format!("filesystem head task failed: {e}")))?
339    }
340
341    async fn exists(&self, path: &str) -> Result<bool, PodError> {
342        let _guard = self.operations.read().await;
343        let data_path = Self::relative(path)?;
344        let dir = self.dir.clone();
345        tokio::task::spawn_blocking(move || Ok(dir.exists(data_path)))
346            .await
347            .map_err(|e| PodError::Backend(format!("filesystem exists task failed: {e}")))?
348    }
349
350    async fn create_container(&self, path: &str) -> Result<ResourceMeta, PodError> {
351        let container = if path.ends_with('/') {
352            path.to_string()
353        } else {
354            format!("{path}/")
355        };
356        let _guard = self.operations.write().await;
357        let dir_path = Self::relative(&container)?;
358        let dir = self.dir.clone();
359        tokio::task::spawn_blocking(move || dir.create_dir_all(dir_path))
360            .await
361            .map_err(|e| PodError::Backend(format!("create container task failed: {e}")))??;
362        Ok(ResourceMeta::new("container", 0, "application/ld+json"))
363    }
364
365    async fn watch(&self, path: &str) -> Result<mpsc::Receiver<StorageEvent>, PodError> {
366        use notify::{RecursiveMode, Watcher};
367
368        let data_path = self.resolve(path)?;
369        let filter_root = data_path.clone();
370        let root = self.root.clone();
371        let (tx, rx) = mpsc::channel::<StorageEvent>(64);
372
373        let (raw_tx, raw_rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
374        let mut watcher = notify::recommended_watcher(move |res| {
375            let _ = raw_tx.send(res);
376        })?;
377        let mode = if data_path.is_dir() {
378            RecursiveMode::Recursive
379        } else {
380            RecursiveMode::NonRecursive
381        };
382        let watch_target = if data_path.exists() {
383            data_path.clone()
384        } else {
385            root.to_path_buf()
386        };
387        watcher.watch(&watch_target, mode)?;
388
389        tokio::task::spawn_blocking(move || {
390            let _keep = watcher;
391            while let Ok(Ok(event)) = raw_rx.recv() {
392                for path in &event.paths {
393                    let s = path.to_string_lossy();
394                    if s.ends_with(META_SUFFIX) {
395                        continue;
396                    }
397                    let virt = match path.strip_prefix(root.as_path()) {
398                        Ok(p) => format!("/{}", p.to_string_lossy()),
399                        Err(_) => continue,
400                    };
401                    if !path.starts_with(&filter_root) && path != &filter_root {
402                        continue;
403                    }
404                    use notify::EventKind;
405                    let storage_event = match event.kind {
406                        EventKind::Create(_) => StorageEvent::Created(virt),
407                        EventKind::Modify(_) => StorageEvent::Updated(virt),
408                        EventKind::Remove(_) => StorageEvent::Deleted(virt),
409                        _ => continue,
410                    };
411                    if tx.blocking_send(storage_event).is_err() {
412                        return;
413                    }
414                }
415            }
416        });
417
418        Ok(rx)
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use tempfile::TempDir;
426
427    #[tokio::test]
428    async fn put_get_roundtrip() {
429        let dir = TempDir::new().unwrap();
430        let fsb = FsBackend::new(dir.path()).await.unwrap();
431        fsb.put("/a/b.txt", Bytes::from_static(b"hello"), "text/plain")
432            .await
433            .unwrap();
434        let (body, meta) = fsb.get("/a/b.txt").await.unwrap();
435        assert_eq!(&body[..], b"hello");
436        assert_eq!(meta.content_type, "text/plain");
437        assert_eq!(meta.size, 5);
438    }
439
440    #[tokio::test]
441    async fn list_skips_meta_sidecar() {
442        let dir = TempDir::new().unwrap();
443        let fsb = FsBackend::new(dir.path()).await.unwrap();
444        fsb.put("/c/x.txt", Bytes::from_static(b"x"), "text/plain")
445            .await
446            .unwrap();
447        let items = fsb.list("/c").await.unwrap();
448        assert_eq!(items, vec!["x.txt".to_string()]);
449    }
450
451    #[tokio::test]
452    async fn delete_removes_resource_and_sidecar() {
453        let dir = TempDir::new().unwrap();
454        let fsb = FsBackend::new(dir.path()).await.unwrap();
455        fsb.put("/f.txt", Bytes::from_static(b"y"), "text/plain")
456            .await
457            .unwrap();
458        fsb.delete("/f.txt").await.unwrap();
459        assert!(!fsb.exists("/f.txt").await.unwrap());
460        let sidecar = dir.path().join("f.txt.meta.json");
461        assert!(!sidecar.exists());
462    }
463
464    #[tokio::test]
465    async fn fs_backend_serves_acl_as_jsonld_without_sidecar() {
466        // Row 167 / JSS PR #294: a `.acl` resource written without a
467        // `.meta.json` sidecar must surface as `application/ld+json`,
468        // not `application/octet-stream` (which conneg would reject).
469        let dir = TempDir::new().unwrap();
470        let fsb = FsBackend::new(dir.path()).await.unwrap();
471        // Low-level write: bypass FsBackend::put so no sidecar is
472        // created — simulates a resource provisioned out-of-band or
473        // left behind after a sidecar crash.
474        fs::write(dir.path().join(".acl"), b"{}").await.unwrap();
475        let (_body, meta) = fsb.get("/.acl").await.unwrap();
476        assert_eq!(meta.content_type, "application/ld+json");
477
478        // Also cover `foo.acl` suffix form.
479        fs::write(dir.path().join("foo.acl"), b"{}").await.unwrap();
480        let (_, meta2) = fsb.get("/foo.acl").await.unwrap();
481        assert_eq!(meta2.content_type, "application/ld+json");
482
483        // And `.meta`.
484        fs::write(dir.path().join("bar.meta"), b"{}").await.unwrap();
485        let (_, meta3) = fsb.get("/bar.meta").await.unwrap();
486        assert_eq!(meta3.content_type, "application/ld+json");
487
488        // Non-dotfile still falls back to octet-stream when no sidecar.
489        fs::write(dir.path().join("plain.bin"), b"\x00\x01")
490            .await
491            .unwrap();
492        let (_, meta4) = fsb.get("/plain.bin").await.unwrap();
493        assert_eq!(meta4.content_type, "application/octet-stream");
494    }
495
496    #[tokio::test]
497    async fn rejects_path_traversal() {
498        let dir = TempDir::new().unwrap();
499        let fsb = FsBackend::new(dir.path()).await.unwrap();
500        let err = fsb
501            .put("/../escape.txt", Bytes::from_static(b""), "text/plain")
502            .await
503            .err()
504            .unwrap();
505        assert!(matches!(err, PodError::InvalidPath(_)));
506    }
507
508    #[cfg(unix)]
509    #[tokio::test]
510    async fn symlink_cannot_escape_root_for_read_write_or_delete() {
511        use std::os::unix::fs::symlink;
512
513        let root = TempDir::new().unwrap();
514        let outside = TempDir::new().unwrap();
515        let outside_file = outside.path().join("secret.txt");
516        std::fs::write(&outside_file, b"host-secret").unwrap();
517        symlink(&outside_file, root.path().join("escape.txt")).unwrap();
518
519        let fsb = FsBackend::new(root.path()).await.unwrap();
520        assert!(fsb.get("/escape.txt").await.is_err());
521
522        fsb.put(
523            "/escape.txt",
524            Bytes::from_static(b"pod-content"),
525            "text/plain",
526        )
527        .await
528        .unwrap();
529        assert_eq!(std::fs::read(&outside_file).unwrap(), b"host-secret");
530        assert_eq!(&fsb.get("/escape.txt").await.unwrap().0[..], b"pod-content");
531
532        symlink(outside.path(), root.path().join("escape-dir")).unwrap();
533        assert!(fsb.get("/escape-dir/secret.txt").await.is_err());
534        assert!(fsb
535            .put(
536                "/escape-dir/new.txt",
537                Bytes::from_static(b"nope"),
538                "text/plain",
539            )
540            .await
541            .is_err());
542        assert!(!outside.path().join("new.txt").exists());
543
544        fsb.delete("/escape.txt").await.unwrap();
545        assert_eq!(std::fs::read(&outside_file).unwrap(), b"host-secret");
546    }
547
548    #[tokio::test]
549    async fn concurrent_reads_observe_complete_body_metadata_pairs() {
550        let dir = TempDir::new().unwrap();
551        let fsb = FsBackend::new(dir.path()).await.unwrap();
552        fsb.put("/state", Bytes::from(vec![b'a'; 128 * 1024]), "text/a")
553            .await
554            .unwrap();
555
556        let writer = {
557            let fsb = fsb.clone();
558            tokio::spawn(async move {
559                for i in 0..32 {
560                    let (byte, content_type) = if i % 2 == 0 {
561                        (b'a', "text/a")
562                    } else {
563                        (b'b', "text/b")
564                    };
565                    fsb.put("/state", Bytes::from(vec![byte; 128 * 1024]), content_type)
566                        .await
567                        .unwrap();
568                }
569            })
570        };
571        let reader = {
572            let fsb = fsb.clone();
573            tokio::spawn(async move {
574                for _ in 0..64 {
575                    let (body, meta) = fsb.get("/state").await.unwrap();
576                    assert_eq!(body.len(), 128 * 1024);
577                    let expected = if body[0] == b'a' { "text/a" } else { "text/b" };
578                    assert!(body.iter().all(|byte| *byte == body[0]));
579                    assert_eq!(meta.content_type, expected);
580                    assert_eq!(meta.etag, FsBackend::compute_etag(&body));
581                }
582            })
583        };
584        writer.await.unwrap();
585        reader.await.unwrap();
586    }
587}