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!(
733 "corrupt session state: {e}"
734 )))
735 }),
736 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
737 Err(e) => Err(StorageError::Other(e)),
738 }
739}
740
741async fn persist_ranges(blocks: &Path, ranges: &[ChunkRange]) -> Result<(), StorageError> {
749 let text = serde_json::to_string(ranges).unwrap_or_else(|_| "[]".to_string());
750 let mut tmp = blocks.as_os_str().to_owned();
751 tmp.push(".tmp");
752 let tmp = PathBuf::from(tmp);
753 tokio::fs::write(&tmp, text)
754 .await
755 .map_err(StorageError::Other)?;
756 tokio::fs::rename(&tmp, blocks)
757 .await
758 .map_err(StorageError::Other)?;
759 Ok(())
760}
761
762async fn remove_blocks_sidecar(blocks: Option<&Path>) -> Result<(), StorageError> {
764 if let Some(blocks) = blocks {
765 let _ = tokio::fs::remove_file(blocks).await;
766 }
767 Ok(())
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use libfw_core::storage::StorageBackend;
774
775 fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
777 let file = std::fs::OpenOptions::new().write(true).open(path)?;
780 file.set_times(std::fs::FileTimes::new().set_modified(time))
781 }
782
783 #[tokio::test]
784 async fn write_read_roundtrip() {
785 let dir = tempfile::tempdir().unwrap();
786 let storage = FsStorage::new(dir.path());
787
788 let sink = storage
789 .write_stream("a/b.txt", WriteMode::Create)
790 .await
791 .unwrap();
792 let mut sink = sink;
793 sink.write(b"hello ").await.unwrap();
794 sink.write(b"world").await.unwrap();
795 let meta = sink.commit().await.unwrap();
796 assert_eq!(meta.size, 11);
797
798 let got = storage.file_meta("a/b.txt").await.unwrap().unwrap();
799 assert_eq!(got.size, 11);
800
801 let mut reader = storage
802 .read_stream("a/b.txt", RangeSpec::new(0, 11))
803 .await
804 .unwrap();
805 let mut buf = Vec::new();
806 reader.read_to_end(&mut buf).unwrap();
807 assert_eq!(buf, b"hello world");
808 }
809
810 #[tokio::test]
811 async fn create_rejects_existing() {
812 let dir = tempfile::tempdir().unwrap();
813 let storage = FsStorage::new(dir.path());
814 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
815 let mut sink = sink;
816 sink.write(b"x").await.unwrap();
817 sink.commit().await.unwrap();
818
819 let res = storage.write_stream("f.txt", WriteMode::Create).await;
820 assert!(matches!(res, Err(StorageError::AlreadyExists(_))));
821 }
822
823 #[tokio::test]
824 async fn resume_writes_at_offset() {
825 let dir = tempfile::tempdir().unwrap();
826 let storage = FsStorage::new(dir.path());
827
828 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
829 let mut sink = sink;
830 sink.write(b"ABCD").await.unwrap();
831 sink.commit().await.unwrap();
832
833 let mut sink = storage
834 .write_stream("f.txt", WriteMode::Resume { offset: 4 })
835 .await
836 .unwrap();
837 sink.write(b"EF").await.unwrap();
838 sink.commit().await.unwrap();
839
840 let mut reader = storage
841 .read_stream("f.txt", RangeSpec::full(6))
842 .await
843 .unwrap();
844 let mut buf = Vec::new();
845 reader.read_to_end(&mut buf).unwrap();
846 assert_eq!(buf, b"ABCDEF");
847 }
848
849 #[tokio::test]
850 async fn resume_offset_mismatch_fails() {
851 let dir = tempfile::tempdir().unwrap();
852 let storage = FsStorage::new(dir.path());
853 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
854 let mut sink = sink;
855 sink.write(b"AB").await.unwrap();
856 sink.commit().await.unwrap();
857
858 let res = storage.write_stream("f.txt", WriteMode::Resume { offset: 9 }).await;
859 assert!(res.is_err());
860 }
861
862 #[tokio::test]
863 async fn abort_leaves_no_partial_file() {
864 let dir = tempfile::tempdir().unwrap();
865 let storage = FsStorage::new(dir.path());
866 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
867 let mut sink = sink;
868 sink.write(b"partial").await.unwrap();
869 sink.abort().await.unwrap();
870 assert!(!dir.path().join("f.txt").exists());
871 }
872
873 #[tokio::test]
874 async fn list_dir_and_remove() {
875 let dir = tempfile::tempdir().unwrap();
876 let storage = FsStorage::new(dir.path());
877 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
878 std::fs::write(dir.path().join("sub/x.txt"), b"1").unwrap();
879 std::fs::write(dir.path().join("a.txt"), b"22").unwrap();
880
881 let entries = storage.list_dir("").await.unwrap();
882 assert_eq!(entries.len(), 2);
883 let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
884 assert_eq!(names, vec!["a.txt", "sub"]);
885
886 storage.remove("sub").await.unwrap();
887 assert!(!dir.path().join("sub").exists());
888 }
889
890 #[tokio::test]
891 async fn rejects_path_traversal() {
892 let dir = tempfile::tempdir().unwrap();
893 let storage = FsStorage::new(dir.path());
894 assert!(storage.file_meta("../etc/passwd").await.is_err());
895 assert!(storage.file_meta("/etc/passwd").await.is_err());
896 }
897
898 #[tokio::test]
899 async fn corrupt_session_sidecar_is_rejected() {
900 let dir = tempfile::tempdir().unwrap();
901 let storage = FsStorage::new(dir.path());
902 let tmp = dir.path().join(".libfw-sess-owner-session-f.txt");
903 std::fs::write(&tmp, b"partial").unwrap();
904 std::fs::write(tmp.with_extension("txt.blocks"), b"{not-json}").unwrap();
905
906 let res = storage
907 .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
908 .await;
909 let err = match res {
910 Ok(_) => panic!("malformed sidecar should not be accepted"),
911 Err(e) => e,
912 };
913 let msg = err.to_string();
916 assert!(msg.contains("corrupt session state"), "{msg}");
917 assert!(
918 !msg.contains(&dir.path().display().to_string()),
919 "error leaked the server path: {msg}"
920 );
921 }
922
923 #[tokio::test]
924 async fn session_sidecar_is_written_atomically() {
925 let dir = tempfile::tempdir().unwrap();
926 let storage = FsStorage::new(dir.path());
927 let mut sink = storage
928 .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
929 .await
930 .unwrap();
931
932 sink.write_at(4, b"abcd").await.unwrap();
933 assert_eq!(
934 sink.received_ranges().await.unwrap(),
935 vec![ChunkRange { start: 4, end: 8 }]
936 );
937 sink.write_at(8, b"ef").await.unwrap();
938
939 let blocks = dir.path().join(".libfw-sess-owner-session-f.txt.blocks");
941 let text = std::fs::read_to_string(&blocks).unwrap();
942 let ranges: Vec<ChunkRange> = serde_json::from_str(&text).unwrap();
943 assert_eq!(ranges, vec![ChunkRange { start: 4, end: 10 }]);
944 assert!(!dir
946 .path()
947 .join(".libfw-sess-owner-session-f.txt.blocks.tmp")
948 .exists());
949 sink.abort().await.unwrap();
950 }
951
952 #[tokio::test]
953 async fn cleanup_stale_sessions_removes_old_temps_only() {
954 let dir = tempfile::tempdir().unwrap();
955 let storage = FsStorage::new(dir.path());
956
957 let stale = dir.path().join(".libfw-sess-oldid-a.bin");
959 std::fs::write(&stale, b"partial").unwrap();
960 std::fs::write(dir.path().join(".libfw-sess-oldid-a.bin.blocks"), b"[[0,7]]").unwrap();
961 let old = std::time::SystemTime::now()
964 .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
965 .unwrap();
966 assert!(
967 filetime_set(&stale, old).is_ok(),
968 "failed to age the stale session temp"
969 );
970
971 let fresh = dir.path().join(".libfw-sess-newid-a.bin");
973 std::fs::write(&fresh, b"partial").unwrap();
974
975 std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
977
978 let removed = storage
979 .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
980 .await
981 .unwrap();
982 assert_eq!(removed, 1, "only the stale temp is removed");
983 assert!(!stale.exists());
984 assert!(!dir.path().join(".libfw-sess-oldid-a.bin.blocks").exists());
985 assert!(fresh.exists(), "fresh temp survives");
986 assert!(dir.path().join("real.txt").exists(), "user file survives");
987 }
988
989 #[tokio::test]
990 async fn committed_meta_has_full_relative_path() {
991 let dir = tempfile::tempdir().unwrap();
992 let storage = FsStorage::new(dir.path());
993 let sink = storage
994 .write_stream("deep/nested/f.txt", WriteMode::Create)
995 .await
996 .unwrap();
997 let mut sink = sink;
998 sink.write(b"hi").await.unwrap();
999 let meta = sink.commit().await.unwrap();
1000 assert_eq!(meta.path, "deep/nested/f.txt");
1001 }
1002
1003 #[cfg(unix)]
1004 #[tokio::test]
1005 async fn rejects_symlink_traversal() {
1006 use std::os::unix::fs::symlink;
1007
1008 let dir = tempfile::tempdir().unwrap();
1009 let storage = FsStorage::new(dir.path());
1010
1011 let outside = tempfile::tempdir().unwrap();
1014 std::fs::write(outside.path().join("secret.txt"), b"top-secret").unwrap();
1015 symlink(outside.path(), dir.path().join("evil")).unwrap();
1016
1017 assert!(storage.read_stream("evil/secret.txt", RangeSpec::full(10)).await.is_err());
1018 assert!(storage.file_meta("evil/secret.txt").await.is_err());
1019 assert!(storage.write_stream("evil/new.txt", WriteMode::Create).await.is_err());
1020 }
1021}