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() {
489 continue;
491 }
492 if ft.is_dir() {
493 stack.push(entry.path());
494 continue;
495 }
496 let name = entry.file_name().to_string_lossy().to_string();
497 if !name.starts_with(".libfw-sess-") {
498 continue;
499 }
500 let modified = entry
501 .metadata()
502 .await
503 .ok()
504 .and_then(|m| m.modified().ok())
505 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
506 .map(|d| UNIX_EPOCH + d)
507 .unwrap_or(UNIX_EPOCH);
508 if modified < deadline {
509 let is_sidecar = name.ends_with(".blocks");
514 let mut sidecar = entry.path().as_os_str().to_owned();
515 sidecar.push(".blocks");
516 let _ = tokio::fs::remove_file(PathBuf::from(sidecar)).await;
517 if tokio::fs::remove_file(entry.path()).await.is_ok() && !is_sidecar {
518 removed += 1;
519 }
520 }
521 }
522 }
523 Ok(removed)
524 }
525}
526
527static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
533
534fn temp_path_for(target: &Path) -> PathBuf {
535 let nanos = SystemTime::now()
536 .duration_since(UNIX_EPOCH)
537 .map(|d| d.as_nanos())
538 .unwrap_or(0);
539 let counter = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
540 let file_name = target
541 .file_name()
542 .map(|n| n.to_string_lossy().to_string())
543 .unwrap_or_else(|| "upload".to_string());
544 let tmp_name = format!(".libfw-tmp-{file_name}-{nanos}-{counter}");
545 target.with_file_name(tmp_name)
546}
547
548pub struct FsSink {
550 file: tokio::fs::File,
551 tmp: Option<PathBuf>,
552 target: PathBuf,
553 rel: String,
555 mode: WriteMode,
558 written: u64,
559 blocks_path: Option<PathBuf>,
563 ranges: Vec<ChunkRange>,
566 target_guard: Option<OwnedMutexGuard<()>>,
572}
573
574fn merge_range(ranges: &mut Vec<ChunkRange>, new: ChunkRange) {
577 if new.is_empty() {
578 return;
579 }
580 ranges.push(new);
581 ranges.sort_by_key(|r| r.start);
582 let mut merged: Vec<ChunkRange> = Vec::with_capacity(ranges.len());
583 for r in ranges.drain(..) {
584 if let Some(last) = merged.last_mut() {
585 if r.start <= last.end {
587 if r.end > last.end {
588 last.end = r.end;
589 }
590 continue;
591 }
592 }
593 merged.push(r);
594 }
595 *ranges = merged;
596}
597
598#[async_trait]
599impl UploadSink for FsSink {
600 async fn write(&mut self, buf: &[u8]) -> Result<(), StorageError> {
601 use tokio::io::AsyncWriteExt;
602 self.file
603 .write_all(buf)
604 .await
605 .map_err(|e| StorageError::write_failed(self.written, e))?;
606 self.written += buf.len() as u64;
607 Ok(())
608 }
609
610 async fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), StorageError> {
611 use tokio::io::{AsyncSeekExt, AsyncWriteExt};
612 self.file
613 .seek(SeekFrom::Start(offset))
614 .await
615 .map_err(|e| StorageError::write_failed(offset, e))?;
616 self.file
617 .write_all(buf)
618 .await
619 .map_err(|e| StorageError::write_failed(offset, e))?;
620 self.written = self.written.max(offset.saturating_add(buf.len() as u64));
623 if let Some(blocks) = self.blocks_path.clone() {
635 let _guard = self.target_guard.as_ref().expect("session sink holds a guard");
636 let mut current = read_ranges(&blocks).await?;
637 merge_range(&mut current, ChunkRange {
638 start: offset,
639 end: offset.saturating_add(buf.len() as u64),
640 });
641 persist_ranges(&blocks, ¤t).await?;
642 self.ranges = current;
643 }
644 Ok(())
645 }
646
647 async fn received_ranges(&mut self) -> Result<Vec<ChunkRange>, StorageError> {
648 Ok(self.ranges.clone())
649 }
650
651 async fn len(&self) -> Result<u64, StorageError> {
652 self.file
653 .metadata()
654 .await
655 .map(|m| m.len())
656 .map_err(|e| StorageError::Other(e))
657 }
658
659 async fn commit(self: Box<Self>) -> Result<FileMeta, StorageError> {
660 use tokio::io::AsyncWriteExt;
661 let FsSink {
662 mut file,
663 tmp,
664 target,
665 rel,
666 mode,
667 blocks_path,
668 ..
669 } = *self;
670 let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
671 file.flush().await.map_err(|e| StorageError::Other(e))?;
672 file.sync_all().await.map_err(|e| StorageError::Other(e))?;
673 drop(file);
674 if let Some(tmp) = tmp {
675 if matches!(mode, WriteMode::Create)
679 && tokio::fs::try_exists(&target)
680 .await
681 .map_err(|e| StorageError::Other(e))?
682 {
683 let _ = tokio::fs::remove_file(&tmp).await;
684 return Err(StorageError::AlreadyExists(rel));
685 }
686 tokio::fs::rename(&tmp, &target)
687 .await
688 .map_err(|e| StorageError::Other(e))?;
689 if let Some(parent) = target.parent() {
692 if let Ok(d) = tokio::fs::File::open(parent).await {
693 let _ = d.sync_all().await;
694 }
695 }
696 }
697 let meta = tokio::fs::metadata(&target)
698 .await
699 .map_err(|e| StorageError::Other(e))?;
700 Ok(file_meta_at(&rel, &meta))
701 }
702
703 async fn abort(self: Box<Self>) -> Result<(), StorageError> {
704 let FsSink {
705 file,
706 tmp,
707 blocks_path,
708 ..
709 } = *self;
710 drop(file);
711 if let Some(tmp) = tmp {
712 let _ = tokio::fs::remove_file(&tmp).await;
713 }
714 let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
715 Ok(())
716 }
717}
718
719fn blocks_path_for(tmp: &Path) -> PathBuf {
721 let mut name = tmp.as_os_str().to_owned();
722 name.push(".blocks");
723 PathBuf::from(name)
724}
725
726async fn read_ranges(blocks: &Path) -> Result<Vec<ChunkRange>, StorageError> {
732 match tokio::fs::read_to_string(blocks).await {
733 Ok(text) => serde_json::from_str::<Vec<ChunkRange>>(&text).map_err(|e| {
734 StorageError::Other(std::io::Error::other(format!(
737 "corrupt session state: {e}"
738 )))
739 }),
740 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
741 Err(e) => Err(StorageError::Other(e)),
742 }
743}
744
745async fn persist_ranges(blocks: &Path, ranges: &[ChunkRange]) -> Result<(), StorageError> {
753 let text = serde_json::to_string(ranges).unwrap_or_else(|_| "[]".to_string());
754 let mut tmp = blocks.as_os_str().to_owned();
755 tmp.push(".tmp");
756 let tmp = PathBuf::from(tmp);
757 tokio::fs::write(&tmp, text)
758 .await
759 .map_err(StorageError::Other)?;
760 tokio::fs::rename(&tmp, blocks)
761 .await
762 .map_err(StorageError::Other)?;
763 Ok(())
764}
765
766async fn remove_blocks_sidecar(blocks: Option<&Path>) -> Result<(), StorageError> {
768 if let Some(blocks) = blocks {
769 let _ = tokio::fs::remove_file(blocks).await;
770 }
771 Ok(())
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use libfw_core::storage::StorageBackend;
778
779 fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
781 let file = std::fs::OpenOptions::new().write(true).open(path)?;
784 file.set_times(std::fs::FileTimes::new().set_modified(time))
785 }
786
787 #[tokio::test]
788 async fn write_read_roundtrip() {
789 let dir = tempfile::tempdir().unwrap();
790 let storage = FsStorage::new(dir.path());
791
792 let sink = storage
793 .write_stream("a/b.txt", WriteMode::Create)
794 .await
795 .unwrap();
796 let mut sink = sink;
797 sink.write(b"hello ").await.unwrap();
798 sink.write(b"world").await.unwrap();
799 let meta = sink.commit().await.unwrap();
800 assert_eq!(meta.size, 11);
801
802 let got = storage.file_meta("a/b.txt").await.unwrap().unwrap();
803 assert_eq!(got.size, 11);
804
805 let mut reader = storage
806 .read_stream("a/b.txt", RangeSpec::new(0, 11))
807 .await
808 .unwrap();
809 let mut buf = Vec::new();
810 reader.read_to_end(&mut buf).unwrap();
811 assert_eq!(buf, b"hello world");
812 }
813
814 #[tokio::test]
815 async fn create_rejects_existing() {
816 let dir = tempfile::tempdir().unwrap();
817 let storage = FsStorage::new(dir.path());
818 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
819 let mut sink = sink;
820 sink.write(b"x").await.unwrap();
821 sink.commit().await.unwrap();
822
823 let res = storage.write_stream("f.txt", WriteMode::Create).await;
824 assert!(matches!(res, Err(StorageError::AlreadyExists(_))));
825 }
826
827 #[tokio::test]
828 async fn resume_writes_at_offset() {
829 let dir = tempfile::tempdir().unwrap();
830 let storage = FsStorage::new(dir.path());
831
832 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
833 let mut sink = sink;
834 sink.write(b"ABCD").await.unwrap();
835 sink.commit().await.unwrap();
836
837 let mut sink = storage
838 .write_stream("f.txt", WriteMode::Resume { offset: 4 })
839 .await
840 .unwrap();
841 sink.write(b"EF").await.unwrap();
842 sink.commit().await.unwrap();
843
844 let mut reader = storage
845 .read_stream("f.txt", RangeSpec::full(6))
846 .await
847 .unwrap();
848 let mut buf = Vec::new();
849 reader.read_to_end(&mut buf).unwrap();
850 assert_eq!(buf, b"ABCDEF");
851 }
852
853 #[tokio::test]
854 async fn resume_offset_mismatch_fails() {
855 let dir = tempfile::tempdir().unwrap();
856 let storage = FsStorage::new(dir.path());
857 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
858 let mut sink = sink;
859 sink.write(b"AB").await.unwrap();
860 sink.commit().await.unwrap();
861
862 let res = storage.write_stream("f.txt", WriteMode::Resume { offset: 9 }).await;
863 assert!(res.is_err());
864 }
865
866 #[tokio::test]
867 async fn abort_leaves_no_partial_file() {
868 let dir = tempfile::tempdir().unwrap();
869 let storage = FsStorage::new(dir.path());
870 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
871 let mut sink = sink;
872 sink.write(b"partial").await.unwrap();
873 sink.abort().await.unwrap();
874 assert!(!dir.path().join("f.txt").exists());
875 }
876
877 #[tokio::test]
878 async fn list_dir_and_remove() {
879 let dir = tempfile::tempdir().unwrap();
880 let storage = FsStorage::new(dir.path());
881 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
882 std::fs::write(dir.path().join("sub/x.txt"), b"1").unwrap();
883 std::fs::write(dir.path().join("a.txt"), b"22").unwrap();
884
885 let entries = storage.list_dir("").await.unwrap();
886 assert_eq!(entries.len(), 2);
887 let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
888 assert_eq!(names, vec!["a.txt", "sub"]);
889
890 storage.remove("sub").await.unwrap();
891 assert!(!dir.path().join("sub").exists());
892 }
893
894 #[tokio::test]
895 async fn rejects_path_traversal() {
896 let dir = tempfile::tempdir().unwrap();
897 let storage = FsStorage::new(dir.path());
898 assert!(storage.file_meta("../etc/passwd").await.is_err());
899 assert!(storage.file_meta("/etc/passwd").await.is_err());
900 }
901
902 #[tokio::test]
903 async fn corrupt_session_sidecar_is_rejected() {
904 let dir = tempfile::tempdir().unwrap();
905 let storage = FsStorage::new(dir.path());
906 let tmp = dir.path().join(".libfw-sess-owner-session-f.txt");
907 std::fs::write(&tmp, b"partial").unwrap();
908 std::fs::write(tmp.with_extension("txt.blocks"), b"{not-json}").unwrap();
909
910 let res = storage
911 .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
912 .await;
913 let err = match res {
914 Ok(_) => panic!("malformed sidecar should not be accepted"),
915 Err(e) => e,
916 };
917 let msg = err.to_string();
920 assert!(msg.contains("corrupt session state"), "{msg}");
921 assert!(
922 !msg.contains(&dir.path().display().to_string()),
923 "error leaked the server path: {msg}"
924 );
925 }
926
927 #[tokio::test]
928 async fn session_sidecar_is_written_atomically() {
929 let dir = tempfile::tempdir().unwrap();
930 let storage = FsStorage::new(dir.path());
931 let mut sink = storage
932 .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
933 .await
934 .unwrap();
935
936 sink.write_at(4, b"abcd").await.unwrap();
937 assert_eq!(
938 sink.received_ranges().await.unwrap(),
939 vec![ChunkRange { start: 4, end: 8 }]
940 );
941 sink.write_at(8, b"ef").await.unwrap();
942
943 let blocks = dir.path().join(".libfw-sess-owner-session-f.txt.blocks");
945 let text = std::fs::read_to_string(&blocks).unwrap();
946 let ranges: Vec<ChunkRange> = serde_json::from_str(&text).unwrap();
947 assert_eq!(ranges, vec![ChunkRange { start: 4, end: 10 }]);
948 assert!(!dir
950 .path()
951 .join(".libfw-sess-owner-session-f.txt.blocks.tmp")
952 .exists());
953 sink.abort().await.unwrap();
954 }
955
956 #[tokio::test]
957 async fn cleanup_stale_sessions_removes_old_temps_only() {
958 let dir = tempfile::tempdir().unwrap();
959 let storage = FsStorage::new(dir.path());
960
961 let stale = dir.path().join(".libfw-sess-oldid-a.bin");
963 std::fs::write(&stale, b"partial").unwrap();
964 std::fs::write(dir.path().join(".libfw-sess-oldid-a.bin.blocks"), b"[[0,7]]").unwrap();
965 let old = std::time::SystemTime::now()
968 .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
969 .unwrap();
970 assert!(
971 filetime_set(&stale, old).is_ok(),
972 "failed to age the stale session temp"
973 );
974
975 let fresh = dir.path().join(".libfw-sess-newid-a.bin");
977 std::fs::write(&fresh, b"partial").unwrap();
978
979 std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
981
982 let removed = storage
983 .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
984 .await
985 .unwrap();
986 assert_eq!(removed, 1, "only the stale temp is removed");
987 assert!(!stale.exists());
988 assert!(!dir.path().join(".libfw-sess-oldid-a.bin.blocks").exists());
989 assert!(fresh.exists(), "fresh temp survives");
990 assert!(dir.path().join("real.txt").exists(), "user file survives");
991 }
992
993 #[tokio::test]
994 async fn cleanup_stale_sessions_sweeps_orphaned_sidecars_without_counting_them() {
995 let dir = tempfile::tempdir().unwrap();
996 let storage = FsStorage::new(dir.path());
997
998 let orphan = dir.path().join(".libfw-sess-goneid-a.bin.blocks");
1002 std::fs::write(&orphan, b"[[0,7]]").unwrap();
1003 let old = std::time::SystemTime::now()
1004 .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
1005 .unwrap();
1006 assert!(filetime_set(&orphan, old).is_ok(), "failed to age the orphan");
1007
1008 let fresh_orphan = dir.path().join(".libfw-sess-newid-a.bin.blocks");
1010 std::fs::write(&fresh_orphan, b"[[0,7]]").unwrap();
1011
1012 let removed = storage
1013 .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
1014 .await
1015 .unwrap();
1016 assert_eq!(removed, 0, "an orphaned sidecar is not a session");
1017 assert!(!orphan.exists(), "the stale orphan is swept");
1018 assert!(fresh_orphan.exists(), "fresh orphan survives");
1019 }
1020
1021 #[tokio::test]
1022 async fn committed_meta_has_full_relative_path() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let storage = FsStorage::new(dir.path());
1025 let sink = storage
1026 .write_stream("deep/nested/f.txt", WriteMode::Create)
1027 .await
1028 .unwrap();
1029 let mut sink = sink;
1030 sink.write(b"hi").await.unwrap();
1031 let meta = sink.commit().await.unwrap();
1032 assert_eq!(meta.path, "deep/nested/f.txt");
1033 }
1034
1035 #[cfg(unix)]
1036 #[tokio::test]
1037 async fn rejects_symlink_traversal() {
1038 use std::os::unix::fs::symlink;
1039
1040 let dir = tempfile::tempdir().unwrap();
1041 let storage = FsStorage::new(dir.path());
1042
1043 let outside = tempfile::tempdir().unwrap();
1046 std::fs::write(outside.path().join("secret.txt"), b"top-secret").unwrap();
1047 symlink(outside.path(), dir.path().join("evil")).unwrap();
1048
1049 assert!(storage.read_stream("evil/secret.txt", RangeSpec::full(10)).await.is_err());
1050 assert!(storage.file_meta("evil/secret.txt").await.is_err());
1051 assert!(storage.write_stream("evil/new.txt", WriteMode::Create).await.is_err());
1052 }
1053}