1use std::collections::HashMap;
8use std::io::{Read, SeekFrom};
9use std::path::{Component, Path, PathBuf};
10use std::sync::Arc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use tokio::sync::OwnedMutexGuard;
14
15use async_trait::async_trait;
16use libfw_core::metadata::{etag_from_size_mtime, ChunkRange, FileMeta};
17use libfw_core::range::RangeSpec;
18use libfw_core::storage::{DirEntry, StorageBackend, UploadSink, WriteMode};
19use libfw_core::StorageError;
20
21#[derive(Debug, Clone)]
26pub struct FsStorage {
27 root: PathBuf,
28 locks: Arc<std::sync::Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>,
35}
36
37impl FsStorage {
38 pub fn new(root: impl Into<PathBuf>) -> Self {
40 FsStorage {
41 root: root.into(),
42 locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
43 }
44 }
45
46 fn lock_for(&self, path: &Path) -> Arc<tokio::sync::Mutex<()>> {
48 let mut map = self.locks.lock().expect("lock registry poisoned");
49 if map.len() > 1024 {
52 map.retain(|_, v| Arc::strong_count(v) > 1);
53 }
54 map.entry(path.to_path_buf())
55 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
56 .clone()
57 }
58
59 fn resolve(&self, path: &str) -> Result<PathBuf, StorageError> {
67 let rel = Path::new(path);
68 if rel.is_absolute() {
69 return Err(StorageError::Unsupported("absolute paths are not allowed"));
70 }
71 let mut joined = self.root.clone();
72 for component in rel.components() {
73 match component {
74 Component::Normal(seg) => {
75 joined.push(seg);
76 }
77 Component::CurDir => {}
78 _ => {
79 return Err(StorageError::Unsupported(
80 "path must not contain '..' or special components",
81 ))
82 }
83 }
84 }
85 Ok(joined)
86 }
87
88 async fn check_symlinks(&self, full: &Path) -> Result<(), StorageError> {
95 let rel = match full.strip_prefix(&self.root) {
97 Ok(r) => r,
98 Err(_) => return Ok(()), };
100 let mut probe = self.root.clone();
101 for component in rel.components() {
102 probe.push(component);
103 match tokio::fs::symlink_metadata(&probe).await {
104 Ok(m) if m.file_type().is_symlink() => {
105 return Err(StorageError::Unsupported(
106 "path must not traverse a symlink",
107 ))
108 }
109 Ok(_) => {}
110 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
112 Err(e) => return Err(StorageError::Other(e)),
113 }
114 }
115 Ok(())
116 }
117}
118
119fn file_meta_at(rel: &str, meta: &std::fs::Metadata) -> FileMeta {
120 let mtime = meta
121 .modified()
122 .ok()
123 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
124 .map(|d| d.as_secs())
125 .unwrap_or(0);
126 FileMeta {
127 path: rel.to_string(),
128 size: meta.len(),
129 mtime,
130 etag: etag_from_size_mtime(meta.len(), mtime),
131 }
132}
133
134#[async_trait]
135impl StorageBackend for FsStorage {
136 async fn file_meta(&self, path: &str) -> Result<Option<FileMeta>, StorageError> {
137 let full = self.resolve(path)?;
138 self.check_symlinks(&full).await?;
139 let meta = match tokio::fs::metadata(&full).await {
140 Ok(m) => m,
141 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
142 Err(e) => return Err(StorageError::Other(e)),
143 };
144 if meta.is_dir() {
145 return Err(StorageError::Unsupported("path is a directory"));
146 }
147 Ok(Some(file_meta_at(path, &meta)))
148 }
149
150 async fn read_stream(
151 &self,
152 path: &str,
153 range: RangeSpec,
154 ) -> Result<Box<dyn Read + Send>, StorageError> {
155 let full = self.resolve(path)?;
156 self.check_symlinks(&full).await?;
157 let mut file = tokio::fs::File::open(&full)
158 .await
159 .map_err(|e| StorageError::Other(e))?;
160 if range.start > 0 {
161 tokio::io::AsyncSeekExt::seek(&mut file, SeekFrom::Start(range.start))
162 .await
163 .map_err(|e| StorageError::Other(e))?;
164 }
165 let std_file = file
166 .try_into_std()
167 .map_err(|e| StorageError::Other(std::io::Error::other(format!("{e:?}"))))?;
168 let limited = std_file.take(range.len());
170 Ok(Box::new(limited))
171 }
172
173 async fn write_stream(
174 &self,
175 path: &str,
176 mode: WriteMode,
177 ) -> Result<Box<dyn UploadSink>, StorageError> {
178 let full = self.resolve(path)?;
179 self.check_symlinks(&full).await?;
180 if let Some(parent) = full.parent() {
181 tokio::fs::create_dir_all(parent)
182 .await
183 .map_err(|e| StorageError::Other(e))?;
184 }
185
186 match mode {
187 WriteMode::Create | WriteMode::Overwrite => {
188 if mode == WriteMode::Create
189 && tokio::fs::try_exists(&full)
190 .await
191 .map_err(|e| StorageError::Other(e))?
192 {
193 return Err(StorageError::AlreadyExists(path.to_string()));
194 }
195 let tmp = temp_path_for(&full);
197 let file = tokio::fs::File::create(&tmp)
198 .await
199 .map_err(|e| StorageError::Other(e))?;
200 Ok(Box::new(FsSink {
201 file,
202 tmp: Some(tmp),
203 target: full,
204 rel: path.to_string(),
205 mode,
206 written: 0,
207 blocks_path: None,
208 ranges: Vec::new(),
209 target_guard: None,
210 }))
211 }
212 WriteMode::Resume { offset } => {
213 let target_guard = Some(self.lock_for(&full).lock_owned().await);
219 let file = tokio::fs::OpenOptions::new()
220 .write(true)
221 .append(true)
222 .open(&full)
223 .await
224 .map_err(|e| StorageError::Other(e))?;
225 let current = file
226 .metadata()
227 .await
228 .map_err(|e| StorageError::Other(e))?
229 .len();
230 if current != offset {
231 return Err(StorageError::write_failed(
232 offset,
233 std::io::Error::other(format!(
234 "existing file is {current} bytes, expected {offset}"
235 )),
236 ));
237 }
238 Ok(Box::new(FsSink {
239 file,
240 tmp: None,
241 target: full,
242 rel: path.to_string(),
243 mode,
244 written: offset,
245 blocks_path: None,
246 ranges: Vec::new(),
247 target_guard,
248 }))
249 }
250 }
251 }
252
253 async fn write_stream_session(
254 &self,
255 path: &str,
256 session: &str,
257 owner: &str,
258 mode: WriteMode,
259 ) -> Result<Box<dyn UploadSink>, StorageError> {
260 let full = self.resolve(path)?;
261 self.check_symlinks(&full).await?;
262 if let Some(parent) = full.parent() {
263 tokio::fs::create_dir_all(parent)
264 .await
265 .map_err(|e| StorageError::Other(e))?;
266 }
267 let sanitize = |s: &str| -> String {
271 s.chars()
272 .map(|c| {
273 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
274 c
275 } else {
276 '_'
277 }
278 })
279 .collect()
280 };
281 let safe_session = sanitize(session);
282 let safe_owner = sanitize(&owner.chars().take(16).collect::<String>());
287 let name = full
288 .file_name()
289 .map(|n| n.to_string_lossy().to_string())
290 .unwrap_or_else(|| "upload".to_string());
291 let tmp = full.with_file_name(format!(".libfw-sess-{safe_owner}-{safe_session}-{name}"));
292
293 let exists = tokio::fs::try_exists(&tmp)
294 .await
295 .map_err(|e| StorageError::Other(e))?;
296 let file = if exists {
297 tokio::fs::OpenOptions::new()
302 .read(true)
303 .write(true)
304 .open(&tmp)
305 .await
306 .map_err(|e| StorageError::Other(e))?
307 } else {
308 match mode {
310 WriteMode::Create | WriteMode::Overwrite => {
311 if mode == WriteMode::Create
312 && tokio::fs::try_exists(&full)
313 .await
314 .map_err(|e| StorageError::Other(e))?
315 {
316 return Err(StorageError::AlreadyExists(path.to_string()));
317 }
318 tokio::fs::OpenOptions::new()
319 .write(true)
320 .create_new(true)
321 .open(&tmp)
322 .await
323 .map_err(|e| StorageError::Other(e))?
324 }
325 WriteMode::Resume { .. } => {
330 return Err(StorageError::Unsupported(
331 "session upload does not support contiguous resume; use the block probe".into(),
332 ))
333 }
334 }
335 };
336 let lock = self.lock_for(&tmp);
343 let target_guard = Some(lock.clone().lock_owned().await);
344
345 let blocks_path = blocks_path_for(&tmp);
348 if !exists {
353 let _ = tokio::fs::remove_file(&blocks_path).await;
354 }
355 let ranges = if exists {
356 read_ranges(&blocks_path).await?
357 } else {
358 Vec::new()
359 };
360 Ok(Box::new(FsSink {
361 file,
362 tmp: Some(tmp),
363 target: full,
364 rel: path.to_string(),
365 mode,
366 written: 0,
367 blocks_path: Some(blocks_path),
368 ranges,
369 target_guard,
370 }))
371 }
372
373 async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>, StorageError> {
374 let full = if path.is_empty() {
375 self.root.clone()
376 } else {
377 self.resolve(path)?
378 };
379 self.check_symlinks(&full).await?;
380 let mut entries = Vec::new();
381 let mut read_dir = tokio::fs::read_dir(&full)
382 .await
383 .map_err(|e| {
384 if e.kind() == std::io::ErrorKind::NotFound {
385 StorageError::NotFound(path.to_string())
386 } else {
387 StorageError::Other(e)
388 }
389 })?;
390 while let Some(entry) = read_dir
391 .next_entry()
392 .await
393 .map_err(|e| StorageError::Other(e))?
394 {
395 let name = entry.file_name().to_string_lossy().to_string();
396 let rel = if path.is_empty() {
397 name.clone()
398 } else {
399 format!("{path}/{name}")
400 };
401 let meta = entry
402 .metadata()
403 .await
404 .map_err(|e| StorageError::Other(e))?;
405 let mtime = meta
406 .modified()
407 .ok()
408 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
409 .map(|d| d.as_secs())
410 .unwrap_or(0);
411 entries.push(DirEntry {
412 path: rel,
413 is_dir: meta.is_dir(),
414 size: if meta.is_dir() { 0 } else { meta.len() },
415 mtime,
416 });
417 }
418 entries.sort_by(|a, b| a.path.cmp(&b.path));
419 Ok(entries)
420 }
421
422 async fn mkdir_all(&self, path: &str) -> Result<(), StorageError> {
423 let full = self.resolve(path)?;
424 self.check_symlinks(&full).await?;
425 tokio::fs::create_dir_all(&full)
426 .await
427 .map_err(|e| StorageError::Other(e))?;
428 Ok(())
429 }
430
431 async fn remove(&self, path: &str) -> Result<(), StorageError> {
432 let full = self.resolve(path)?;
433 self.check_symlinks(&full).await?;
434 let meta = tokio::fs::symlink_metadata(&full)
440 .await
441 .map_err(|e| StorageError::Other(e))?;
442 if meta.file_type().is_symlink() {
443 return Err(StorageError::Unsupported(
444 "refusing to remove through a symlink",
445 ));
446 }
447 if meta.is_dir() {
448 tokio::fs::remove_dir_all(&full)
449 .await
450 .map_err(|e| StorageError::Other(e))
451 } else {
452 tokio::fs::remove_file(&full)
453 .await
454 .map_err(|e| StorageError::Other(e))
455 }
456 }
457
458 async fn cleanup_stale_sessions(
459 &self,
460 max_age: std::time::Duration,
461 ) -> Result<usize, StorageError> {
462 let deadline = SystemTime::now()
468 .checked_sub(max_age)
469 .unwrap_or(UNIX_EPOCH);
470 let mut removed = 0usize;
471 let mut stack = vec![self.root.clone()];
472 while let Some(dir) = stack.pop() {
473 let mut rd = match tokio::fs::read_dir(&dir).await {
474 Ok(rd) => rd,
475 Err(_) => continue,
476 };
477 while let Ok(Some(entry)) = rd.next_entry().await {
478 let ft = match entry.file_type().await {
479 Ok(ft) => ft,
480 Err(_) => continue,
481 };
482 if ft.is_symlink() {
490 continue;
492 }
493 if ft.is_dir() {
494 stack.push(entry.path());
495 continue;
496 }
497 let name = entry.file_name().to_string_lossy().to_string();
498 if !name.starts_with(".libfw-sess-") {
499 continue;
500 }
501 let modified = entry
502 .metadata()
503 .await
504 .ok()
505 .and_then(|m| m.modified().ok())
506 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
507 .map(|d| UNIX_EPOCH + d)
508 .unwrap_or(UNIX_EPOCH);
509 if modified < deadline {
510 let _ = tokio::fs::remove_file(entry.path()).await;
511 let mut sidecar = entry.path().as_os_str().to_owned();
513 sidecar.push(".blocks");
514 let _ = tokio::fs::remove_file(PathBuf::from(sidecar)).await;
515 removed += 1;
516 }
517 }
518 }
519 Ok(removed)
520 }
521}
522
523static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
529
530fn temp_path_for(target: &Path) -> PathBuf {
531 let nanos = SystemTime::now()
532 .duration_since(UNIX_EPOCH)
533 .map(|d| d.as_nanos())
534 .unwrap_or(0);
535 let counter = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
536 let file_name = target
537 .file_name()
538 .map(|n| n.to_string_lossy().to_string())
539 .unwrap_or_else(|| "upload".to_string());
540 let tmp_name = format!(".libfw-tmp-{file_name}-{nanos}-{counter}");
541 target.with_file_name(tmp_name)
542}
543
544pub struct FsSink {
546 file: tokio::fs::File,
547 tmp: Option<PathBuf>,
548 target: PathBuf,
549 rel: String,
551 mode: WriteMode,
554 written: u64,
555 blocks_path: Option<PathBuf>,
559 ranges: Vec<ChunkRange>,
562 target_guard: Option<OwnedMutexGuard<()>>,
568}
569
570fn merge_range(ranges: &mut Vec<ChunkRange>, new: ChunkRange) {
573 if new.is_empty() {
574 return;
575 }
576 ranges.push(new);
577 ranges.sort_by_key(|r| r.start);
578 let mut merged: Vec<ChunkRange> = Vec::with_capacity(ranges.len());
579 for r in ranges.drain(..) {
580 if let Some(last) = merged.last_mut() {
581 if r.start <= last.end {
583 if r.end > last.end {
584 last.end = r.end;
585 }
586 continue;
587 }
588 }
589 merged.push(r);
590 }
591 *ranges = merged;
592}
593
594#[async_trait]
595impl UploadSink for FsSink {
596 async fn write(&mut self, buf: &[u8]) -> Result<(), StorageError> {
597 use tokio::io::AsyncWriteExt;
598 self.file
599 .write_all(buf)
600 .await
601 .map_err(|e| StorageError::write_failed(self.written, e))?;
602 self.written += buf.len() as u64;
603 Ok(())
604 }
605
606 async fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), StorageError> {
607 use tokio::io::{AsyncSeekExt, AsyncWriteExt};
608 self.file
609 .seek(SeekFrom::Start(offset))
610 .await
611 .map_err(|e| StorageError::write_failed(offset, e))?;
612 self.file
613 .write_all(buf)
614 .await
615 .map_err(|e| StorageError::write_failed(offset, e))?;
616 self.written = self.written.max(offset.saturating_add(buf.len() as u64));
619 if let Some(blocks) = self.blocks_path.clone() {
631 let _guard = self.target_guard.as_ref().expect("session sink holds a guard");
632 let mut current = read_ranges(&blocks).await?;
633 merge_range(&mut current, ChunkRange {
634 start: offset,
635 end: offset.saturating_add(buf.len() as u64),
636 });
637 persist_ranges(&blocks, ¤t).await?;
638 self.ranges = current;
639 }
640 Ok(())
641 }
642
643 async fn received_ranges(&mut self) -> Result<Vec<ChunkRange>, StorageError> {
644 Ok(self.ranges.clone())
645 }
646
647 async fn len(&self) -> Result<u64, StorageError> {
648 self.file
649 .metadata()
650 .await
651 .map(|m| m.len())
652 .map_err(|e| StorageError::Other(e))
653 }
654
655 async fn commit(self: Box<Self>) -> Result<FileMeta, StorageError> {
656 use tokio::io::AsyncWriteExt;
657 let FsSink {
658 mut file,
659 tmp,
660 target,
661 rel,
662 mode,
663 blocks_path,
664 ..
665 } = *self;
666 let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
667 file.flush().await.map_err(|e| StorageError::Other(e))?;
668 file.sync_all().await.map_err(|e| StorageError::Other(e))?;
669 drop(file);
670 if let Some(tmp) = tmp {
671 if matches!(mode, WriteMode::Create)
675 && tokio::fs::try_exists(&target)
676 .await
677 .map_err(|e| StorageError::Other(e))?
678 {
679 let _ = tokio::fs::remove_file(&tmp).await;
680 return Err(StorageError::AlreadyExists(rel));
681 }
682 tokio::fs::rename(&tmp, &target)
683 .await
684 .map_err(|e| StorageError::Other(e))?;
685 if let Some(parent) = target.parent() {
688 if let Ok(d) = tokio::fs::File::open(parent).await {
689 let _ = d.sync_all().await;
690 }
691 }
692 }
693 let meta = tokio::fs::metadata(&target)
694 .await
695 .map_err(|e| StorageError::Other(e))?;
696 Ok(file_meta_at(&rel, &meta))
697 }
698
699 async fn abort(self: Box<Self>) -> Result<(), StorageError> {
700 let FsSink {
701 file,
702 tmp,
703 blocks_path,
704 ..
705 } = *self;
706 drop(file);
707 if let Some(tmp) = tmp {
708 let _ = tokio::fs::remove_file(&tmp).await;
709 }
710 let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
711 Ok(())
712 }
713}
714
715fn blocks_path_for(tmp: &Path) -> PathBuf {
717 let mut name = tmp.as_os_str().to_owned();
718 name.push(".blocks");
719 PathBuf::from(name)
720}
721
722async fn read_ranges(blocks: &Path) -> Result<Vec<ChunkRange>, StorageError> {
728 match tokio::fs::read_to_string(blocks).await {
729 Ok(text) => serde_json::from_str::<Vec<ChunkRange>>(&text).map_err(|e| {
730 StorageError::Other(std::io::Error::other(format!(
731 "corrupt sidecar {}: {e}",
732 blocks.display()
733 )))
734 }),
735 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
736 Err(e) => Err(StorageError::Other(e)),
737 }
738}
739
740async fn persist_ranges(blocks: &Path, ranges: &[ChunkRange]) -> Result<(), StorageError> {
742 let text = serde_json::to_string(ranges).unwrap_or_else(|_| "[]".to_string());
743 tokio::fs::write(blocks, text)
744 .await
745 .map_err(|e| StorageError::Other(e))
746}
747
748async fn remove_blocks_sidecar(blocks: Option<&Path>) -> Result<(), StorageError> {
750 if let Some(blocks) = blocks {
751 let _ = tokio::fs::remove_file(blocks).await;
752 }
753 Ok(())
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use libfw_core::storage::StorageBackend;
760
761 fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
763 let file = std::fs::OpenOptions::new().write(true).open(path)?;
766 file.set_times(std::fs::FileTimes::new().set_modified(time))
767 }
768
769 #[tokio::test]
770 async fn write_read_roundtrip() {
771 let dir = tempfile::tempdir().unwrap();
772 let storage = FsStorage::new(dir.path());
773
774 let sink = storage
775 .write_stream("a/b.txt", WriteMode::Create)
776 .await
777 .unwrap();
778 let mut sink = sink;
779 sink.write(b"hello ").await.unwrap();
780 sink.write(b"world").await.unwrap();
781 let meta = sink.commit().await.unwrap();
782 assert_eq!(meta.size, 11);
783
784 let got = storage.file_meta("a/b.txt").await.unwrap().unwrap();
785 assert_eq!(got.size, 11);
786
787 let mut reader = storage
788 .read_stream("a/b.txt", RangeSpec::new(0, 11))
789 .await
790 .unwrap();
791 let mut buf = Vec::new();
792 reader.read_to_end(&mut buf).unwrap();
793 assert_eq!(buf, b"hello world");
794 }
795
796 #[tokio::test]
797 async fn create_rejects_existing() {
798 let dir = tempfile::tempdir().unwrap();
799 let storage = FsStorage::new(dir.path());
800 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
801 let mut sink = sink;
802 sink.write(b"x").await.unwrap();
803 sink.commit().await.unwrap();
804
805 let res = storage.write_stream("f.txt", WriteMode::Create).await;
806 assert!(matches!(res, Err(StorageError::AlreadyExists(_))));
807 }
808
809 #[tokio::test]
810 async fn resume_writes_at_offset() {
811 let dir = tempfile::tempdir().unwrap();
812 let storage = FsStorage::new(dir.path());
813
814 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
815 let mut sink = sink;
816 sink.write(b"ABCD").await.unwrap();
817 sink.commit().await.unwrap();
818
819 let mut sink = storage
820 .write_stream("f.txt", WriteMode::Resume { offset: 4 })
821 .await
822 .unwrap();
823 sink.write(b"EF").await.unwrap();
824 sink.commit().await.unwrap();
825
826 let mut reader = storage
827 .read_stream("f.txt", RangeSpec::full(6))
828 .await
829 .unwrap();
830 let mut buf = Vec::new();
831 reader.read_to_end(&mut buf).unwrap();
832 assert_eq!(buf, b"ABCDEF");
833 }
834
835 #[tokio::test]
836 async fn resume_offset_mismatch_fails() {
837 let dir = tempfile::tempdir().unwrap();
838 let storage = FsStorage::new(dir.path());
839 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
840 let mut sink = sink;
841 sink.write(b"AB").await.unwrap();
842 sink.commit().await.unwrap();
843
844 let res = storage.write_stream("f.txt", WriteMode::Resume { offset: 9 }).await;
845 assert!(res.is_err());
846 }
847
848 #[tokio::test]
849 async fn abort_leaves_no_partial_file() {
850 let dir = tempfile::tempdir().unwrap();
851 let storage = FsStorage::new(dir.path());
852 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
853 let mut sink = sink;
854 sink.write(b"partial").await.unwrap();
855 sink.abort().await.unwrap();
856 assert!(!dir.path().join("f.txt").exists());
857 }
858
859 #[tokio::test]
860 async fn list_dir_and_remove() {
861 let dir = tempfile::tempdir().unwrap();
862 let storage = FsStorage::new(dir.path());
863 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
864 std::fs::write(dir.path().join("sub/x.txt"), b"1").unwrap();
865 std::fs::write(dir.path().join("a.txt"), b"22").unwrap();
866
867 let entries = storage.list_dir("").await.unwrap();
868 assert_eq!(entries.len(), 2);
869 let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
870 assert_eq!(names, vec!["a.txt", "sub"]);
871
872 storage.remove("sub").await.unwrap();
873 assert!(!dir.path().join("sub").exists());
874 }
875
876 #[tokio::test]
877 async fn rejects_path_traversal() {
878 let dir = tempfile::tempdir().unwrap();
879 let storage = FsStorage::new(dir.path());
880 assert!(storage.file_meta("../etc/passwd").await.is_err());
881 assert!(storage.file_meta("/etc/passwd").await.is_err());
882 }
883
884 #[tokio::test]
885 async fn corrupt_session_sidecar_is_rejected() {
886 let dir = tempfile::tempdir().unwrap();
887 let storage = FsStorage::new(dir.path());
888 let tmp = dir.path().join(".libfw-sess-owner-session-f.txt");
889 std::fs::write(&tmp, b"partial").unwrap();
890 std::fs::write(tmp.with_extension("txt.blocks"), b"{not-json}").unwrap();
891
892 let res = storage
893 .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
894 .await;
895 assert!(res.is_err(), "malformed sidecar should not be accepted");
896 }
897
898 #[tokio::test]
899 async fn cleanup_stale_sessions_removes_old_temps_only() {
900 let dir = tempfile::tempdir().unwrap();
901 let storage = FsStorage::new(dir.path());
902
903 let stale = dir.path().join(".libfw-sess-oldid-a.bin");
905 std::fs::write(&stale, b"partial").unwrap();
906 std::fs::write(dir.path().join(".libfw-sess-oldid-a.bin.blocks"), b"[[0,7]]").unwrap();
907 let old = std::time::SystemTime::now()
910 .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
911 .unwrap();
912 assert!(
913 filetime_set(&stale, old).is_ok(),
914 "failed to age the stale session temp"
915 );
916
917 let fresh = dir.path().join(".libfw-sess-newid-a.bin");
919 std::fs::write(&fresh, b"partial").unwrap();
920
921 std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
923
924 let removed = storage
925 .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
926 .await
927 .unwrap();
928 assert_eq!(removed, 1, "only the stale temp is removed");
929 assert!(!stale.exists());
930 assert!(!dir.path().join(".libfw-sess-oldid-a.bin.blocks").exists());
931 assert!(fresh.exists(), "fresh temp survives");
932 assert!(dir.path().join("real.txt").exists(), "user file survives");
933 }
934
935 #[tokio::test]
936 async fn committed_meta_has_full_relative_path() {
937 let dir = tempfile::tempdir().unwrap();
938 let storage = FsStorage::new(dir.path());
939 let sink = storage
940 .write_stream("deep/nested/f.txt", WriteMode::Create)
941 .await
942 .unwrap();
943 let mut sink = sink;
944 sink.write(b"hi").await.unwrap();
945 let meta = sink.commit().await.unwrap();
946 assert_eq!(meta.path, "deep/nested/f.txt");
947 }
948
949 #[cfg(unix)]
950 #[tokio::test]
951 async fn rejects_symlink_traversal() {
952 use std::os::unix::fs::symlink;
953
954 let dir = tempfile::tempdir().unwrap();
955 let storage = FsStorage::new(dir.path());
956
957 let outside = tempfile::tempdir().unwrap();
960 std::fs::write(outside.path().join("secret.txt"), b"top-secret").unwrap();
961 symlink(outside.path(), dir.path().join("evil")).unwrap();
962
963 assert!(storage.read_stream("evil/secret.txt", RangeSpec::full(10)).await.is_err());
964 assert!(storage.file_meta("evil/secret.txt").await.is_err());
965 assert!(storage.write_stream("evil/new.txt", WriteMode::Create).await.is_err());
966 }
967}