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