1use std::io::{Read, SeekFrom};
8use std::path::{Component, Path, PathBuf};
9use std::sync::Arc;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use async_trait::async_trait;
13use libfw_core::metadata::{etag_from_size_mtime, ChunkRange, FileMeta};
14use libfw_core::range::RangeSpec;
15use libfw_core::storage::{DirEntry, StorageBackend, UploadSink, WriteMode};
16use libfw_core::StorageError;
17
18#[derive(Debug, Clone)]
23pub struct FsStorage {
24 root: PathBuf,
25 sidecar_lock: Arc<tokio::sync::Mutex<()>>,
30}
31
32impl FsStorage {
33 pub fn new(root: impl Into<PathBuf>) -> Self {
35 FsStorage {
36 root: root.into(),
37 sidecar_lock: Arc::new(tokio::sync::Mutex::new(())),
38 }
39 }
40
41 fn resolve(&self, path: &str) -> Result<PathBuf, StorageError> {
49 let rel = Path::new(path);
50 if rel.is_absolute() {
51 return Err(StorageError::Unsupported("absolute paths are not allowed"));
52 }
53 let mut joined = self.root.clone();
54 for component in rel.components() {
55 match component {
56 Component::Normal(seg) => {
57 joined.push(seg);
58 }
59 Component::CurDir => {}
60 _ => {
61 return Err(StorageError::Unsupported(
62 "path must not contain '..' or special components",
63 ))
64 }
65 }
66 }
67 Ok(joined)
68 }
69
70 async fn check_symlinks(&self, full: &Path) -> Result<(), StorageError> {
77 let rel = match full.strip_prefix(&self.root) {
79 Ok(r) => r,
80 Err(_) => return Ok(()), };
82 let mut probe = self.root.clone();
83 for component in rel.components() {
84 probe.push(component);
85 match tokio::fs::symlink_metadata(&probe).await {
86 Ok(m) if m.file_type().is_symlink() => {
87 return Err(StorageError::Unsupported(
88 "path must not traverse a symlink",
89 ))
90 }
91 Ok(_) => {}
92 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
94 Err(e) => return Err(StorageError::Other(e)),
95 }
96 }
97 Ok(())
98 }
99}
100
101fn file_meta_at(rel: &str, meta: &std::fs::Metadata) -> FileMeta {
102 let mtime = meta
103 .modified()
104 .ok()
105 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
106 .map(|d| d.as_secs())
107 .unwrap_or(0);
108 FileMeta {
109 path: rel.to_string(),
110 size: meta.len(),
111 mtime,
112 etag: etag_from_size_mtime(meta.len(), mtime),
113 }
114}
115
116#[async_trait]
117impl StorageBackend for FsStorage {
118 async fn file_meta(&self, path: &str) -> Result<Option<FileMeta>, StorageError> {
119 let full = self.resolve(path)?;
120 self.check_symlinks(&full).await?;
121 let meta = match tokio::fs::metadata(&full).await {
122 Ok(m) => m,
123 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
124 Err(e) => return Err(StorageError::Other(e)),
125 };
126 if meta.is_dir() {
127 return Err(StorageError::Unsupported("path is a directory"));
128 }
129 Ok(Some(file_meta_at(path, &meta)))
130 }
131
132 async fn read_stream(
133 &self,
134 path: &str,
135 range: RangeSpec,
136 ) -> Result<Box<dyn Read + Send>, StorageError> {
137 let full = self.resolve(path)?;
138 self.check_symlinks(&full).await?;
139 let mut file = tokio::fs::File::open(&full)
140 .await
141 .map_err(|e| StorageError::Other(e))?;
142 if range.start > 0 {
143 tokio::io::AsyncSeekExt::seek(&mut file, SeekFrom::Start(range.start))
144 .await
145 .map_err(|e| StorageError::Other(e))?;
146 }
147 let std_file = file
148 .try_into_std()
149 .map_err(|e| StorageError::Other(std::io::Error::other(format!("{e:?}"))))?;
150 let limited = std_file.take(range.len());
152 Ok(Box::new(limited))
153 }
154
155 async fn write_stream(
156 &self,
157 path: &str,
158 mode: WriteMode,
159 ) -> Result<Box<dyn UploadSink>, StorageError> {
160 let full = self.resolve(path)?;
161 self.check_symlinks(&full).await?;
162 if let Some(parent) = full.parent() {
163 tokio::fs::create_dir_all(parent)
164 .await
165 .map_err(|e| StorageError::Other(e))?;
166 }
167
168 match mode {
169 WriteMode::Create | WriteMode::Overwrite => {
170 if mode == WriteMode::Create
171 && tokio::fs::try_exists(&full)
172 .await
173 .map_err(|e| StorageError::Other(e))?
174 {
175 return Err(StorageError::AlreadyExists(path.to_string()));
176 }
177 let tmp = temp_path_for(&full);
179 let file = tokio::fs::File::create(&tmp)
180 .await
181 .map_err(|e| StorageError::Other(e))?;
182 Ok(Box::new(FsSink {
183 file,
184 tmp: Some(tmp),
185 target: full,
186 rel: path.to_string(),
187 mode,
188 written: 0,
189 blocks_path: None,
190 ranges: Vec::new(),
191 sidecar_lock: self.sidecar_lock.clone(),
192 }))
193 }
194 WriteMode::Resume { offset } => {
195 let file = tokio::fs::OpenOptions::new()
196 .write(true)
197 .append(true)
198 .open(&full)
199 .await
200 .map_err(|e| StorageError::Other(e))?;
201 let current = file
202 .metadata()
203 .await
204 .map_err(|e| StorageError::Other(e))?
205 .len();
206 if current != offset {
207 return Err(StorageError::write_failed(
208 offset,
209 std::io::Error::other(format!(
210 "existing file is {current} bytes, expected {offset}"
211 )),
212 ));
213 }
214 Ok(Box::new(FsSink {
215 file,
216 tmp: None,
217 target: full,
218 rel: path.to_string(),
219 mode,
220 written: offset,
221 blocks_path: None,
222 ranges: Vec::new(),
223 sidecar_lock: self.sidecar_lock.clone(),
224 }))
225 }
226 }
227 }
228
229 async fn write_stream_session(
230 &self,
231 path: &str,
232 session: &str,
233 owner: &str,
234 mode: WriteMode,
235 ) -> Result<Box<dyn UploadSink>, StorageError> {
236 let full = self.resolve(path)?;
237 self.check_symlinks(&full).await?;
238 if let Some(parent) = full.parent() {
239 tokio::fs::create_dir_all(parent)
240 .await
241 .map_err(|e| StorageError::Other(e))?;
242 }
243 let sanitize = |s: &str| -> String {
247 s.chars()
248 .map(|c| {
249 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
250 c
251 } else {
252 '_'
253 }
254 })
255 .collect()
256 };
257 let safe_session = sanitize(session);
258 let safe_owner = sanitize(&owner.chars().take(16).collect::<String>());
263 let name = full
264 .file_name()
265 .map(|n| n.to_string_lossy().to_string())
266 .unwrap_or_else(|| "upload".to_string());
267 let tmp = full.with_file_name(format!(".libfw-sess-{safe_owner}-{safe_session}-{name}"));
268
269 let exists = tokio::fs::try_exists(&tmp)
270 .await
271 .map_err(|e| StorageError::Other(e))?;
272 let file = if exists {
273 tokio::fs::OpenOptions::new()
278 .read(true)
279 .write(true)
280 .open(&tmp)
281 .await
282 .map_err(|e| StorageError::Other(e))?
283 } else {
284 match mode {
286 WriteMode::Create | WriteMode::Overwrite => {
287 if mode == WriteMode::Create
288 && tokio::fs::try_exists(&full)
289 .await
290 .map_err(|e| StorageError::Other(e))?
291 {
292 return Err(StorageError::AlreadyExists(path.to_string()));
293 }
294 tokio::fs::OpenOptions::new()
295 .write(true)
296 .create_new(true)
297 .open(&tmp)
298 .await
299 .map_err(|e| StorageError::Other(e))?
300 }
301 WriteMode::Resume { .. } => {
306 return Err(StorageError::Unsupported(
307 "session upload does not support contiguous resume; use the block probe".into(),
308 ))
309 }
310 }
311 };
312 let blocks_path = blocks_path_for(&tmp);
315 if !exists {
320 let _ = tokio::fs::remove_file(&blocks_path).await;
321 }
322 let ranges = if exists {
323 read_ranges(&blocks_path).await
324 } else {
325 Vec::new()
326 };
327 Ok(Box::new(FsSink {
328 file,
329 tmp: Some(tmp),
330 target: full,
331 rel: path.to_string(),
332 mode,
333 written: 0,
334 blocks_path: Some(blocks_path),
335 ranges,
336 sidecar_lock: self.sidecar_lock.clone(),
337 }))
338 }
339
340 async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>, StorageError> {
341 let full = if path.is_empty() {
342 self.root.clone()
343 } else {
344 self.resolve(path)?
345 };
346 self.check_symlinks(&full).await?;
347 let mut entries = Vec::new();
348 let mut read_dir = tokio::fs::read_dir(&full)
349 .await
350 .map_err(|e| {
351 if e.kind() == std::io::ErrorKind::NotFound {
352 StorageError::NotFound(path.to_string())
353 } else {
354 StorageError::Other(e)
355 }
356 })?;
357 while let Some(entry) = read_dir
358 .next_entry()
359 .await
360 .map_err(|e| StorageError::Other(e))?
361 {
362 let name = entry.file_name().to_string_lossy().to_string();
363 let rel = if path.is_empty() {
364 name.clone()
365 } else {
366 format!("{path}/{name}")
367 };
368 let meta = entry
369 .metadata()
370 .await
371 .map_err(|e| StorageError::Other(e))?;
372 let mtime = meta
373 .modified()
374 .ok()
375 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
376 .map(|d| d.as_secs())
377 .unwrap_or(0);
378 entries.push(DirEntry {
379 path: rel,
380 is_dir: meta.is_dir(),
381 size: if meta.is_dir() { 0 } else { meta.len() },
382 mtime,
383 });
384 }
385 entries.sort_by(|a, b| a.path.cmp(&b.path));
386 Ok(entries)
387 }
388
389 async fn mkdir_all(&self, path: &str) -> Result<(), StorageError> {
390 let full = self.resolve(path)?;
391 self.check_symlinks(&full).await?;
392 tokio::fs::create_dir_all(&full)
393 .await
394 .map_err(|e| StorageError::Other(e))?;
395 Ok(())
396 }
397
398 async fn remove(&self, path: &str) -> Result<(), StorageError> {
399 let full = self.resolve(path)?;
400 self.check_symlinks(&full).await?;
401 let meta = tokio::fs::symlink_metadata(&full)
407 .await
408 .map_err(|e| StorageError::Other(e))?;
409 if meta.file_type().is_symlink() {
410 return Err(StorageError::Unsupported(
411 "refusing to remove through a symlink",
412 ));
413 }
414 if meta.is_dir() {
415 tokio::fs::remove_dir_all(&full)
416 .await
417 .map_err(|e| StorageError::Other(e))
418 } else {
419 tokio::fs::remove_file(&full)
420 .await
421 .map_err(|e| StorageError::Other(e))
422 }
423 }
424
425 async fn cleanup_stale_sessions(
426 &self,
427 max_age: std::time::Duration,
428 ) -> Result<usize, StorageError> {
429 let deadline = SystemTime::now()
435 .checked_sub(max_age)
436 .unwrap_or(UNIX_EPOCH);
437 let mut removed = 0usize;
438 let mut stack = vec![self.root.clone()];
439 while let Some(dir) = stack.pop() {
440 let mut rd = match tokio::fs::read_dir(&dir).await {
441 Ok(rd) => rd,
442 Err(_) => continue,
443 };
444 while let Ok(Some(entry)) = rd.next_entry().await {
445 let ft = match entry.file_type().await {
446 Ok(ft) => ft,
447 Err(_) => continue,
448 };
449 if ft.is_symlink() {
457 continue;
459 }
460 if ft.is_dir() {
461 stack.push(entry.path());
462 continue;
463 }
464 let name = entry.file_name().to_string_lossy().to_string();
465 if !name.starts_with(".libfw-sess-") {
466 continue;
467 }
468 let modified = entry
469 .metadata()
470 .await
471 .ok()
472 .and_then(|m| m.modified().ok())
473 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
474 .map(|d| UNIX_EPOCH + d)
475 .unwrap_or(UNIX_EPOCH);
476 if modified < deadline {
477 let _ = tokio::fs::remove_file(entry.path()).await;
478 let mut sidecar = entry.path().as_os_str().to_owned();
480 sidecar.push(".blocks");
481 let _ = tokio::fs::remove_file(PathBuf::from(sidecar)).await;
482 removed += 1;
483 }
484 }
485 }
486 Ok(removed)
487 }
488}
489
490static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
496
497fn temp_path_for(target: &Path) -> PathBuf {
498 let nanos = SystemTime::now()
499 .duration_since(UNIX_EPOCH)
500 .map(|d| d.as_nanos())
501 .unwrap_or(0);
502 let counter = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
503 let file_name = target
504 .file_name()
505 .map(|n| n.to_string_lossy().to_string())
506 .unwrap_or_else(|| "upload".to_string());
507 let tmp_name = format!(".libfw-tmp-{file_name}-{nanos}-{counter}");
508 target.with_file_name(tmp_name)
509}
510
511pub struct FsSink {
513 file: tokio::fs::File,
514 tmp: Option<PathBuf>,
515 target: PathBuf,
516 rel: String,
518 mode: WriteMode,
521 written: u64,
522 blocks_path: Option<PathBuf>,
526 ranges: Vec<ChunkRange>,
529 sidecar_lock: Arc<tokio::sync::Mutex<()>>,
531}
532
533fn merge_range(ranges: &mut Vec<ChunkRange>, new: ChunkRange) {
536 if new.is_empty() {
537 return;
538 }
539 ranges.push(new);
540 ranges.sort_by_key(|r| r.start);
541 let mut merged: Vec<ChunkRange> = Vec::with_capacity(ranges.len());
542 for r in ranges.drain(..) {
543 if let Some(last) = merged.last_mut() {
544 if r.start <= last.end {
546 if r.end > last.end {
547 last.end = r.end;
548 }
549 continue;
550 }
551 }
552 merged.push(r);
553 }
554 *ranges = merged;
555}
556
557#[async_trait]
558impl UploadSink for FsSink {
559 async fn write(&mut self, buf: &[u8]) -> Result<(), StorageError> {
560 use tokio::io::AsyncWriteExt;
561 self.file
562 .write_all(buf)
563 .await
564 .map_err(|e| StorageError::write_failed(self.written, e))?;
565 self.written += buf.len() as u64;
566 Ok(())
567 }
568
569 async fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), StorageError> {
570 use tokio::io::{AsyncSeekExt, AsyncWriteExt};
571 self.file
572 .seek(SeekFrom::Start(offset))
573 .await
574 .map_err(|e| StorageError::write_failed(offset, e))?;
575 self.file
576 .write_all(buf)
577 .await
578 .map_err(|e| StorageError::write_failed(offset, e))?;
579 self.written = self.written.max(offset.saturating_add(buf.len() as u64));
582 if let Some(blocks) = self.blocks_path.clone() {
592 let guard = self.sidecar_lock.lock().await;
593 let mut current = read_ranges(&blocks).await;
594 merge_range(&mut current, ChunkRange {
595 start: offset,
596 end: offset.saturating_add(buf.len() as u64),
597 });
598 persist_ranges(&blocks, ¤t).await?;
599 self.ranges = current;
600 drop(guard);
601 }
602 Ok(())
603 }
604
605 async fn received_ranges(&mut self) -> Result<Vec<ChunkRange>, StorageError> {
606 Ok(self.ranges.clone())
607 }
608
609 async fn len(&self) -> Result<u64, StorageError> {
610 self.file
611 .metadata()
612 .await
613 .map(|m| m.len())
614 .map_err(|e| StorageError::Other(e))
615 }
616
617 async fn commit(self: Box<Self>) -> Result<FileMeta, StorageError> {
618 use tokio::io::AsyncWriteExt;
619 let FsSink {
620 mut file,
621 tmp,
622 target,
623 rel,
624 mode,
625 blocks_path,
626 ..
627 } = *self;
628 let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
629 file.flush().await.map_err(|e| StorageError::Other(e))?;
630 file.sync_all().await.map_err(|e| StorageError::Other(e))?;
631 drop(file);
632 if let Some(tmp) = tmp {
633 if matches!(mode, WriteMode::Create)
637 && tokio::fs::try_exists(&target)
638 .await
639 .map_err(|e| StorageError::Other(e))?
640 {
641 let _ = tokio::fs::remove_file(&tmp).await;
642 return Err(StorageError::AlreadyExists(rel));
643 }
644 tokio::fs::rename(&tmp, &target)
645 .await
646 .map_err(|e| StorageError::Other(e))?;
647 if let Some(parent) = target.parent() {
650 if let Ok(d) = tokio::fs::File::open(parent).await {
651 let _ = d.sync_all().await;
652 }
653 }
654 }
655 let meta = tokio::fs::metadata(&target)
656 .await
657 .map_err(|e| StorageError::Other(e))?;
658 Ok(file_meta_at(&rel, &meta))
659 }
660
661 async fn abort(self: Box<Self>) -> Result<(), StorageError> {
662 let FsSink {
663 file,
664 tmp,
665 blocks_path,
666 ..
667 } = *self;
668 drop(file);
669 if let Some(tmp) = tmp {
670 let _ = tokio::fs::remove_file(&tmp).await;
671 }
672 let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
673 Ok(())
674 }
675}
676
677fn blocks_path_for(tmp: &Path) -> PathBuf {
679 let mut name = tmp.as_os_str().to_owned();
680 name.push(".blocks");
681 PathBuf::from(name)
682}
683
684async fn read_ranges(blocks: &Path) -> Vec<ChunkRange> {
686 match tokio::fs::read_to_string(blocks).await {
687 Ok(text) => serde_json::from_str::<Vec<ChunkRange>>(&text).unwrap_or_default(),
688 Err(_) => Vec::new(),
689 }
690}
691
692async fn persist_ranges(blocks: &Path, ranges: &[ChunkRange]) -> Result<(), StorageError> {
694 let text = serde_json::to_string(ranges).unwrap_or_else(|_| "[]".to_string());
695 tokio::fs::write(blocks, text)
696 .await
697 .map_err(|e| StorageError::Other(e))
698}
699
700async fn remove_blocks_sidecar(blocks: Option<&Path>) -> Result<(), StorageError> {
702 if let Some(blocks) = blocks {
703 let _ = tokio::fs::remove_file(blocks).await;
704 }
705 Ok(())
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use libfw_core::storage::StorageBackend;
712
713 fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
715 let file = std::fs::OpenOptions::new().write(true).open(path)?;
718 file.set_times(std::fs::FileTimes::new().set_modified(time))
719 }
720
721 #[tokio::test]
722 async fn write_read_roundtrip() {
723 let dir = tempfile::tempdir().unwrap();
724 let storage = FsStorage::new(dir.path());
725
726 let sink = storage
727 .write_stream("a/b.txt", WriteMode::Create)
728 .await
729 .unwrap();
730 let mut sink = sink;
731 sink.write(b"hello ").await.unwrap();
732 sink.write(b"world").await.unwrap();
733 let meta = sink.commit().await.unwrap();
734 assert_eq!(meta.size, 11);
735
736 let got = storage.file_meta("a/b.txt").await.unwrap().unwrap();
737 assert_eq!(got.size, 11);
738
739 let mut reader = storage
740 .read_stream("a/b.txt", RangeSpec::new(0, 11))
741 .await
742 .unwrap();
743 let mut buf = Vec::new();
744 reader.read_to_end(&mut buf).unwrap();
745 assert_eq!(buf, b"hello world");
746 }
747
748 #[tokio::test]
749 async fn create_rejects_existing() {
750 let dir = tempfile::tempdir().unwrap();
751 let storage = FsStorage::new(dir.path());
752 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
753 let mut sink = sink;
754 sink.write(b"x").await.unwrap();
755 sink.commit().await.unwrap();
756
757 let res = storage.write_stream("f.txt", WriteMode::Create).await;
758 assert!(matches!(res, Err(StorageError::AlreadyExists(_))));
759 }
760
761 #[tokio::test]
762 async fn resume_writes_at_offset() {
763 let dir = tempfile::tempdir().unwrap();
764 let storage = FsStorage::new(dir.path());
765
766 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
767 let mut sink = sink;
768 sink.write(b"ABCD").await.unwrap();
769 sink.commit().await.unwrap();
770
771 let mut sink = storage
772 .write_stream("f.txt", WriteMode::Resume { offset: 4 })
773 .await
774 .unwrap();
775 sink.write(b"EF").await.unwrap();
776 sink.commit().await.unwrap();
777
778 let mut reader = storage
779 .read_stream("f.txt", RangeSpec::full(6))
780 .await
781 .unwrap();
782 let mut buf = Vec::new();
783 reader.read_to_end(&mut buf).unwrap();
784 assert_eq!(buf, b"ABCDEF");
785 }
786
787 #[tokio::test]
788 async fn resume_offset_mismatch_fails() {
789 let dir = tempfile::tempdir().unwrap();
790 let storage = FsStorage::new(dir.path());
791 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
792 let mut sink = sink;
793 sink.write(b"AB").await.unwrap();
794 sink.commit().await.unwrap();
795
796 let res = storage.write_stream("f.txt", WriteMode::Resume { offset: 9 }).await;
797 assert!(res.is_err());
798 }
799
800 #[tokio::test]
801 async fn abort_leaves_no_partial_file() {
802 let dir = tempfile::tempdir().unwrap();
803 let storage = FsStorage::new(dir.path());
804 let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
805 let mut sink = sink;
806 sink.write(b"partial").await.unwrap();
807 sink.abort().await.unwrap();
808 assert!(!dir.path().join("f.txt").exists());
809 }
810
811 #[tokio::test]
812 async fn list_dir_and_remove() {
813 let dir = tempfile::tempdir().unwrap();
814 let storage = FsStorage::new(dir.path());
815 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
816 std::fs::write(dir.path().join("sub/x.txt"), b"1").unwrap();
817 std::fs::write(dir.path().join("a.txt"), b"22").unwrap();
818
819 let entries = storage.list_dir("").await.unwrap();
820 assert_eq!(entries.len(), 2);
821 let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
822 assert_eq!(names, vec!["a.txt", "sub"]);
823
824 storage.remove("sub").await.unwrap();
825 assert!(!dir.path().join("sub").exists());
826 }
827
828 #[tokio::test]
829 async fn rejects_path_traversal() {
830 let dir = tempfile::tempdir().unwrap();
831 let storage = FsStorage::new(dir.path());
832 assert!(storage.file_meta("../etc/passwd").await.is_err());
833 assert!(storage.file_meta("/etc/passwd").await.is_err());
834 }
835
836 #[tokio::test]
837 async fn cleanup_stale_sessions_removes_old_temps_only() {
838 let dir = tempfile::tempdir().unwrap();
839 let storage = FsStorage::new(dir.path());
840
841 let stale = dir.path().join(".libfw-sess-oldid-a.bin");
843 std::fs::write(&stale, b"partial").unwrap();
844 std::fs::write(dir.path().join(".libfw-sess-oldid-a.bin.blocks"), b"[[0,7]]").unwrap();
845 let old = std::time::SystemTime::now()
848 .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
849 .unwrap();
850 assert!(
851 filetime_set(&stale, old).is_ok(),
852 "failed to age the stale session temp"
853 );
854
855 let fresh = dir.path().join(".libfw-sess-newid-a.bin");
857 std::fs::write(&fresh, b"partial").unwrap();
858
859 std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
861
862 let removed = storage
863 .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
864 .await
865 .unwrap();
866 assert_eq!(removed, 1, "only the stale temp is removed");
867 assert!(!stale.exists());
868 assert!(!dir.path().join(".libfw-sess-oldid-a.bin.blocks").exists());
869 assert!(fresh.exists(), "fresh temp survives");
870 assert!(dir.path().join("real.txt").exists(), "user file survives");
871 }
872
873 #[tokio::test]
874 async fn committed_meta_has_full_relative_path() {
875 let dir = tempfile::tempdir().unwrap();
876 let storage = FsStorage::new(dir.path());
877 let sink = storage
878 .write_stream("deep/nested/f.txt", WriteMode::Create)
879 .await
880 .unwrap();
881 let mut sink = sink;
882 sink.write(b"hi").await.unwrap();
883 let meta = sink.commit().await.unwrap();
884 assert_eq!(meta.path, "deep/nested/f.txt");
885 }
886
887 #[cfg(unix)]
888 #[tokio::test]
889 async fn rejects_symlink_traversal() {
890 use std::os::unix::fs::symlink;
891
892 let dir = tempfile::tempdir().unwrap();
893 let storage = FsStorage::new(dir.path());
894
895 let outside = tempfile::tempdir().unwrap();
898 std::fs::write(outside.path().join("secret.txt"), b"top-secret").unwrap();
899 symlink(outside.path(), dir.path().join("evil")).unwrap();
900
901 assert!(storage.read_stream("evil/secret.txt", RangeSpec::full(10)).await.is_err());
902 assert!(storage.file_meta("evil/secret.txt").await.is_err());
903 assert!(storage.write_stream("evil/new.txt", WriteMode::Create).await.is_err());
904 }
905}