1use 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#[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 #[serde(default)]
41 etag: Option<String>,
42}
43
44impl FsBackend {
45 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 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 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 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 let dir = TempDir::new().unwrap();
470 let fsb = FsBackend::new(dir.path()).await.unwrap();
471 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 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 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 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}