Skip to main content

libfw_server/
storage.rs

1//! Local filesystem storage backend for `libfw-server`.
2//!
3//! Writes go to a temporary file next to the destination and are
4//! atomically renamed into place on [`UploadSink::commit`], so a failed or
5//! aborted upload never leaves a partial target behind.
6
7use 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/// A [`StorageBackend`] rooted at a local directory.
19///
20/// Paths passed to the backend are treated as relative to `root`; any path
21/// escaping the root (absolute, `..`, symlink-traversing) is rejected.
22#[derive(Debug, Clone)]
23pub struct FsStorage {
24    root: PathBuf,
25    /// Serializes read-merge-write of the per-session `.blocks` sidecars so
26    /// concurrent chunk requests never clobber each other's received-range
27    /// bookkeeping (a lost update here makes the commit coverage check reject
28    /// a fully-written file).
29    sidecar_lock: Arc<tokio::sync::Mutex<()>>,
30}
31
32impl FsStorage {
33    /// Create a backend serving files under `root`.
34    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    /// Resolve a virtual path against the root (pure text — no I/O).
42    ///
43    /// Rejects absolute paths, `..` segments and `CurDir` (`.`) other than
44    /// the root-relative segments accepted by `Component::Normal`. Symlink
45    /// checking is async and is done by the callers via
46    /// [`FsStorage::check_symlinks`] when they need it (file/dir open paths
47    /// that may traverse existing directory structure).
48    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    /// Walk every component of `full` between `self.root` and the leaf,
71    /// rejecting any that is a symlink.
72    ///
73    /// Called asynchronously by the I/O methods that open existing paths so
74    /// the symlink check never blocks a tokio worker thread. New (not yet
75    /// existing) path tails are skipped — they cannot be symlinks.
76    async fn check_symlinks(&self, full: &Path) -> Result<(), StorageError> {
77        // Strip the root prefix to iterate only the virtual path components.
78        let rel = match full.strip_prefix(&self.root) {
79            Ok(r) => r,
80            Err(_) => return Ok(()), // shouldn't happen; treat as safe
81        };
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                // Not-yet-existing component: fine (new upload target).
93                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        // Restrict to exactly the requested range.
151        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                // Write to a temp file, rename on commit.
178                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        // Both the session id and owner tag are embedded in the temp filename,
244        // so neither must inject path separators or `..`. Restrict to safe
245        // chars (alphanumeric, `-`, `_`).
246        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        // Embed the owner tag so that two authenticated users with the same
259        // file path and an identical (guessed/observed) session id cannot
260        // collide on the same temp file.  A short prefix of the owner hash
261        // is enough for discrimination without making filenames unwieldy.
262        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            // Subsequent chunk / resume of an in-flight session: open the
274            // shared temp for positional (seek + write) access. `mode` is
275            // ignored; the already-received ranges are reloaded from the
276            // sidecar so the client can resume only the missing parts.
277            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            // First request for this session: create the shared temp.
285            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                // Resumable sessions are driven by the per-block probe (the
302                // client asks "what ranges do you have?" and sends only the
303                // gaps), not by a contiguous offset append, so a legacy
304                // `Resume` mode is not applicable here.
305                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        // Load any already-received byte ranges from the sidecar so a pause /
313        // resume only re-sends the missing blocks.
314        let blocks_path = blocks_path_for(&tmp);
315        // A freshly-created temp means nothing has been received yet: a stale
316        // `.blocks` sidecar left behind by an earlier aborted attempt must not
317        // make the probe report phantom ranges (the client would then skip
318        // chunks and commit an empty / zero-filled file).
319        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        // Use `symlink_metadata` (not `metadata`) so a symlink that appeared
402        // since `resolve` checked is never followed — we refuse to remove
403        // *through* it. (The check-then-use race cannot be fully closed on
404        // all platforms without openat/O_NOFOLLOW; this narrows it for the
405        // destructive operation.)
406        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        // tus-style expiry: a client that vanishes mid-upload leaves its
430        // `.libfw-sess-<id>-<name>` temp (and `.blocks` sidecar) behind. Walk
431        // the root, removing the ones whose last write is older than
432        // `max_age`. Only session temps are touched — never committed user
433        // files — and symlinked directories are never followed.
434        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                // On Linux/macOS `FileType::is_dir()` and `is_symlink()` are
450                // mutually exclusive: a symlink to a directory reports
451                // `is_symlink()=true`, `is_dir()=false`. The old nested guard
452                // (`is_dir() && !is_symlink()`) was therefore always true when
453                // `is_dir()` was true, and symlink-to-directory entries were
454                // skipped by the outer `is_symlink()` check. Simplified below
455                // to make the intent unambiguous.
456                if ft.is_symlink() {
457                    // Never follow symlinks into directories.
458                    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                    // Remove the parallel range sidecar (`<temp>.blocks`).
479                    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
490/// A temporary path for a target, unique per attempt.
491///
492/// Uniqueness combines a monotonic counter with a timestamp so two
493/// concurrent Create-mode uploads to the same target never collide on the
494/// same temp file (which would otherwise interleave/truncate each other).
495static 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
511/// Streaming write handle for the filesystem backend.
512pub struct FsSink {
513    file: tokio::fs::File,
514    tmp: Option<PathBuf>,
515    target: PathBuf,
516    /// The full virtual path (used for the committed `FileMeta`).
517    rel: String,
518    /// The mode the sink was opened with (used for the Create atomicity
519    /// check at commit time).
520    mode: WriteMode,
521    written: u64,
522    /// Optional sidecar path tracking received byte ranges for a resumable
523    /// "session" upload (parallel to the session temp file). `None` for
524    /// ordinary (non-session) sinks.
525    blocks_path: Option<PathBuf>,
526    /// In-memory copy of the received ranges (kept in sync with
527    /// `blocks_path`).
528    ranges: Vec<ChunkRange>,
529    /// Shared lock serializing sidecar read-merge-write (see `FsStorage`).
530    sidecar_lock: Arc<tokio::sync::Mutex<()>>,
531}
532
533/// Merge `new` (a `[start, end)` half-open range) into a sorted, disjoint
534/// list of ranges, coalescing overlaps/adjacencies. Returns the updated list.
535fn 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            // Overlapping or adjacent ranges coalesce.
545            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        // Track the highest extent written (used by `len`-style bookkeeping;
580        // the real size for commit validation comes from file metadata).
581        self.written = self.written.max(offset.saturating_add(buf.len() as u64));
582        // For a resumable session sink, record the received byte range in the
583        // sidecar so a later probe / resume knows this part is already on
584        // disk and only missing gaps need to be re-sent.
585        //
586        // The read-merge-write is serialized under the storage-wide lock:
587        // concurrent chunk requests each hold a *stale* in-memory `ranges`
588        // copy loaded when their sink was opened, so a bare `persist_ranges`
589        // would clobber other chunks' ranges (lost update) and make the
590        // commit coverage check reject a fully-written file.
591        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, &current).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            // Create mode must never clobber a target that appeared while
634            // we were streaming (closes the check-then-rename TOCTOU for
635            // the common non-concurrent-writer case).
636            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            // Durability: fsync the parent directory so the rename itself
648            // survives a crash.
649            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
677/// Sidecar filename for a session temp (e.g. `<temp>.blocks`).
678fn 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
684/// Read the persisted received byte ranges for a session temp sidecar.
685async 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
692/// Persist the received byte ranges to a session temp sidecar (best-effort).
693async 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
700/// Remove a session temp sidecar (best-effort; missing file is fine).
701async 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    /// Force a file's mtime (used to age a fake session temp).
714    fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
715        // Open writable: on Windows, setting file times through a read-only
716        // handle is refused.
717        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        // A stale session temp (very old mtime) + its range sidecar.
842        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        // Age it well beyond the 1-hour TTL (7 days old). Windows/FAT can
846        // clamp very old timestamps, so use a recent-but-stale date.
847        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        // A fresh session temp (now) must be kept.
856        let fresh = dir.path().join(".libfw-sess-newid-a.bin");
857        std::fs::write(&fresh, b"partial").unwrap();
858
859        // A committed user file must never be touched.
860        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        // A symlink planted inside the root pointing outside it must not let
896        // reads/writes escape the mount root.
897        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}