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::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/// A [`StorageBackend`] rooted at a local directory.
22///
23/// Paths passed to the backend are treated as relative to `root`; any path
24/// escaping the root (absolute, `..`, symlink-traversing) is rejected.
25#[derive(Debug, Clone)]
26pub struct FsStorage {
27    root: PathBuf,
28    /// Per-target locks (keyed by temp/target path). Concurrent chunk
29    /// requests for *different* sessions/targets never serialize behind each
30    /// other — only writers of the same target (who share a `.blocks`
31    /// sidecar or an append-only resume file) take turns. Without this, a
32    /// read-merge-write of the shared sidecar loses updates (making the
33    /// commit coverage check reject a fully-written file).
34    locks: Arc<std::sync::Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>,
35}
36
37impl FsStorage {
38    /// Create a backend serving files under `root`.
39    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    /// Get (or create) the mutex guarding `path`.
47    fn lock_for(&self, path: &Path) -> Arc<tokio::sync::Mutex<()>> {
48        let mut map = self.locks.lock().expect("lock registry poisoned");
49        // Drop entries no sink holds anymore so the registry cannot grow
50        // without bound across many short-lived sessions.
51        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    /// Resolve a virtual path against the root (pure text — no I/O).
60    ///
61    /// Rejects absolute paths, `..` segments and `CurDir` (`.`) other than
62    /// the root-relative segments accepted by `Component::Normal`. Symlink
63    /// checking is async and is done by the callers via
64    /// [`FsStorage::check_symlinks`] when they need it (file/dir open paths
65    /// that may traverse existing directory structure).
66    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    /// Walk every component of `full` between `self.root` and the leaf,
89    /// rejecting any that is a symlink.
90    ///
91    /// Called asynchronously by the I/O methods that open existing paths so
92    /// the symlink check never blocks a tokio worker thread. New (not yet
93    /// existing) path tails are skipped — they cannot be symlinks.
94    async fn check_symlinks(&self, full: &Path) -> Result<(), StorageError> {
95        // Strip the root prefix to iterate only the virtual path components.
96        let rel = match full.strip_prefix(&self.root) {
97            Ok(r) => r,
98            Err(_) => return Ok(()), // shouldn't happen; treat as safe
99        };
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                // Not-yet-existing component: fine (new upload target).
111                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        // Restrict to exactly the requested range.
169        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                // Write to a temp file, rename on commit.
196                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                // Concurrent `Resume` writers of the same target would both
214                // observe the same on-disk length and interleave their
215                // appends. Hold the per-target lock for the sink's whole
216                // lifetime so they take turns (the loser re-checks the
217                // length only after the winner's commit/rejection).
218                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        // Both the session id and owner tag are embedded in the temp filename,
268        // so neither must inject path separators or `..`. Restrict to safe
269        // chars (alphanumeric, `-`, `_`).
270        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        // Embed the owner tag so that two authenticated users with the same
283        // file path and an identical (guessed/observed) session id cannot
284        // collide on the same temp file.  A short prefix of the owner hash
285        // is enough for discrimination without making filenames unwieldy.
286        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            // Subsequent chunk / resume of an in-flight session: open the
298            // shared temp for positional (seek + write) access. `mode` is
299            // ignored; the already-received ranges are reloaded from the
300            // sidecar so the client can resume only the missing parts.
301            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            // First request for this session: create the shared temp.
309            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                // Resumable sessions are driven by the per-block probe (the
326                // client asks "what ranges do you have?" and sends only the
327                // gaps), not by a contiguous offset append, so a legacy
328                // `Resume` mode is not applicable here.
329                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        // Chunks of the same session share the temp + sidecar, so they
337        // serialize on the temp's per-target lock (different sessions stay
338        // fully parallel). The guard is held for the sink's whole lifetime:
339        // concurrent first requests would otherwise both load the (missing)
340        // sidecar, then each chunk write's read-merge-write would clobber the
341        // other's received-range bookkeeping.
342        let lock = self.lock_for(&tmp);
343        let target_guard = Some(lock.clone().lock_owned().await);
344
345        // Load any already-received byte ranges from the sidecar so a pause /
346        // resume only re-sends the missing blocks.
347        let blocks_path = blocks_path_for(&tmp);
348        // A freshly-created temp means nothing has been received yet: a stale
349        // `.blocks` sidecar left behind by an earlier aborted attempt must not
350        // make the probe report phantom ranges (the client would then skip
351        // chunks and commit an empty / zero-filled file).
352        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        // Use `symlink_metadata` (not `metadata`) so a symlink that appeared
435        // since `resolve` checked is never followed — we refuse to remove
436        // *through* it. (The check-then-use race cannot be fully closed on
437        // all platforms without openat/O_NOFOLLOW; this narrows it for the
438        // destructive operation.)
439        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        // tus-style expiry: a client that vanishes mid-upload leaves its
463        // `.libfw-sess-<id>-<name>` temp (and `.blocks` sidecar) behind. Walk
464        // the root, removing the ones whose last write is older than
465        // `max_age`. Only session temps are touched — never committed user
466        // files — and symlinked directories are never followed.
467        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                // On Linux/macOS `FileType::is_dir()` and `is_symlink()` are
483                // mutually exclusive: a symlink to a directory reports
484                // `is_symlink()=true`, `is_dir()=false`. The old nested guard
485                // (`is_dir() && !is_symlink()`) was therefore always true when
486                // `is_dir()` was true, and symlink-to-directory entries were
487                // skipped by the outer `is_symlink()` check. Simplified below
488                // to make the intent unambiguous.
489                if ft.is_symlink() {
490                    // Never follow symlinks into directories.
491                    continue;
492                }
493                if ft.is_dir() {
494                    stack.push(entry.path());
495                    continue;
496                }
497                let name = entry.file_name().to_string_lossy().to_string();
498                if !name.starts_with(".libfw-sess-") {
499                    continue;
500                }
501                let modified = entry
502                    .metadata()
503                    .await
504                    .ok()
505                    .and_then(|m| m.modified().ok())
506                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
507                    .map(|d| UNIX_EPOCH + d)
508                    .unwrap_or(UNIX_EPOCH);
509                if modified < deadline {
510                    let _ = tokio::fs::remove_file(entry.path()).await;
511                    // Remove the parallel range sidecar (`<temp>.blocks`).
512                    let mut sidecar = entry.path().as_os_str().to_owned();
513                    sidecar.push(".blocks");
514                    let _ = tokio::fs::remove_file(PathBuf::from(sidecar)).await;
515                    removed += 1;
516                }
517            }
518        }
519        Ok(removed)
520    }
521}
522
523/// A temporary path for a target, unique per attempt.
524///
525/// Uniqueness combines a monotonic counter with a timestamp so two
526/// concurrent Create-mode uploads to the same target never collide on the
527/// same temp file (which would otherwise interleave/truncate each other).
528static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
529
530fn temp_path_for(target: &Path) -> PathBuf {
531    let nanos = SystemTime::now()
532        .duration_since(UNIX_EPOCH)
533        .map(|d| d.as_nanos())
534        .unwrap_or(0);
535    let counter = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
536    let file_name = target
537        .file_name()
538        .map(|n| n.to_string_lossy().to_string())
539        .unwrap_or_else(|| "upload".to_string());
540    let tmp_name = format!(".libfw-tmp-{file_name}-{nanos}-{counter}");
541    target.with_file_name(tmp_name)
542}
543
544/// Streaming write handle for the filesystem backend.
545pub struct FsSink {
546    file: tokio::fs::File,
547    tmp: Option<PathBuf>,
548    target: PathBuf,
549    /// The full virtual path (used for the committed `FileMeta`).
550    rel: String,
551    /// The mode the sink was opened with (used for the Create atomicity
552    /// check at commit time).
553    mode: WriteMode,
554    written: u64,
555    /// Optional sidecar path tracking received byte ranges for a resumable
556    /// "session" upload (parallel to the session temp file). `None` for
557    /// ordinary (non-session) sinks.
558    blocks_path: Option<PathBuf>,
559    /// In-memory copy of the received ranges (kept in sync with
560    /// `blocks_path`).
561    ranges: Vec<ChunkRange>,
562    /// Per-target guard held for the sink's whole lifetime. `Some` only for
563    /// session sinks (serializing sidecar read-merge-write per target) and
564    /// for `WriteMode::Resume` (serializing concurrent appends to the same
565    /// target). `None` for plain Create/Overwrite sinks, which write to a
566    /// unique temp and rename on commit — no shared state to guard.
567    target_guard: Option<OwnedMutexGuard<()>>,
568}
569
570/// Merge `new` (a `[start, end)` half-open range) into a sorted, disjoint
571/// list of ranges, coalescing overlaps/adjacencies. Returns the updated list.
572fn merge_range(ranges: &mut Vec<ChunkRange>, new: ChunkRange) {
573    if new.is_empty() {
574        return;
575    }
576    ranges.push(new);
577    ranges.sort_by_key(|r| r.start);
578    let mut merged: Vec<ChunkRange> = Vec::with_capacity(ranges.len());
579    for r in ranges.drain(..) {
580        if let Some(last) = merged.last_mut() {
581            // Overlapping or adjacent ranges coalesce.
582            if r.start <= last.end {
583                if r.end > last.end {
584                    last.end = r.end;
585                }
586                continue;
587            }
588        }
589        merged.push(r);
590    }
591    *ranges = merged;
592}
593
594#[async_trait]
595impl UploadSink for FsSink {
596    async fn write(&mut self, buf: &[u8]) -> Result<(), StorageError> {
597        use tokio::io::AsyncWriteExt;
598        self.file
599            .write_all(buf)
600            .await
601            .map_err(|e| StorageError::write_failed(self.written, e))?;
602        self.written += buf.len() as u64;
603        Ok(())
604    }
605
606    async fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), StorageError> {
607        use tokio::io::{AsyncSeekExt, AsyncWriteExt};
608        self.file
609            .seek(SeekFrom::Start(offset))
610            .await
611            .map_err(|e| StorageError::write_failed(offset, e))?;
612        self.file
613            .write_all(buf)
614            .await
615            .map_err(|e| StorageError::write_failed(offset, e))?;
616        // Track the highest extent written (used by `len`-style bookkeeping;
617        // the real size for commit validation comes from file metadata).
618        self.written = self.written.max(offset.saturating_add(buf.len() as u64));
619        // For a resumable session sink, record the received byte range in the
620        // sidecar so a later probe / resume knows this part is already on
621        // disk and only missing gaps need to be re-sent.
622        //
623        // The read-merge-write is serialized by the per-target guard this
624        // sink holds for its whole lifetime: concurrent chunks of the SAME
625        // session each hold a *stale* in-memory `ranges` copy loaded when
626        // their sink was opened, so a bare `persist_ranges` would clobber
627        // other chunks' ranges (lost update) and make the commit coverage
628        // check reject a fully-written file. Re-reading under the guard
629        // keeps every writer's merge based on the latest on-disk state.
630        if let Some(blocks) = self.blocks_path.clone() {
631            let _guard = self.target_guard.as_ref().expect("session sink holds a guard");
632            let mut current = read_ranges(&blocks).await?;
633            merge_range(&mut current, ChunkRange {
634                start: offset,
635                end: offset.saturating_add(buf.len() as u64),
636            });
637            persist_ranges(&blocks, &current).await?;
638            self.ranges = current;
639        }
640        Ok(())
641    }
642
643    async fn received_ranges(&mut self) -> Result<Vec<ChunkRange>, StorageError> {
644        Ok(self.ranges.clone())
645    }
646
647    async fn len(&self) -> Result<u64, StorageError> {
648        self.file
649            .metadata()
650            .await
651            .map(|m| m.len())
652            .map_err(|e| StorageError::Other(e))
653    }
654
655    async fn commit(self: Box<Self>) -> Result<FileMeta, StorageError> {
656        use tokio::io::AsyncWriteExt;
657        let FsSink {
658            mut file,
659            tmp,
660            target,
661            rel,
662            mode,
663            blocks_path,
664            ..
665        } = *self;
666        let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
667        file.flush().await.map_err(|e| StorageError::Other(e))?;
668        file.sync_all().await.map_err(|e| StorageError::Other(e))?;
669        drop(file);
670        if let Some(tmp) = tmp {
671            // Create mode must never clobber a target that appeared while
672            // we were streaming (closes the check-then-rename TOCTOU for
673            // the common non-concurrent-writer case).
674            if matches!(mode, WriteMode::Create)
675                && tokio::fs::try_exists(&target)
676                    .await
677                    .map_err(|e| StorageError::Other(e))?
678            {
679                let _ = tokio::fs::remove_file(&tmp).await;
680                return Err(StorageError::AlreadyExists(rel));
681            }
682            tokio::fs::rename(&tmp, &target)
683                .await
684                .map_err(|e| StorageError::Other(e))?;
685            // Durability: fsync the parent directory so the rename itself
686            // survives a crash.
687            if let Some(parent) = target.parent() {
688                if let Ok(d) = tokio::fs::File::open(parent).await {
689                    let _ = d.sync_all().await;
690                }
691            }
692        }
693        let meta = tokio::fs::metadata(&target)
694            .await
695            .map_err(|e| StorageError::Other(e))?;
696        Ok(file_meta_at(&rel, &meta))
697    }
698
699    async fn abort(self: Box<Self>) -> Result<(), StorageError> {
700        let FsSink {
701            file,
702            tmp,
703            blocks_path,
704            ..
705        } = *self;
706        drop(file);
707        if let Some(tmp) = tmp {
708            let _ = tokio::fs::remove_file(&tmp).await;
709        }
710        let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
711        Ok(())
712    }
713}
714
715/// Sidecar filename for a session temp (e.g. `<temp>.blocks`).
716fn blocks_path_for(tmp: &Path) -> PathBuf {
717    let mut name = tmp.as_os_str().to_owned();
718    name.push(".blocks");
719    PathBuf::from(name)
720}
721
722/// Read the persisted received ranges for a session temp sidecar.
723///
724/// An invalid or tampered sidecar is a protocol error: the server must refuse
725/// to continue instead of silently treating the upload as if it had no
726/// received data and accidentally re-sending/overwriting valid chunks.
727async fn read_ranges(blocks: &Path) -> Result<Vec<ChunkRange>, StorageError> {
728    match tokio::fs::read_to_string(blocks).await {
729        Ok(text) => serde_json::from_str::<Vec<ChunkRange>>(&text).map_err(|e| {
730            // This message reaches clients as a 500 body, so it must not
731            // disclose server-side filesystem paths.
732            StorageError::Other(std::io::Error::other(format!(
733                "corrupt session state: {e}"
734            )))
735        }),
736        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
737        Err(e) => Err(StorageError::Other(e)),
738    }
739}
740
741/// Persist the received byte ranges to a session temp sidecar.
742///
743/// Write-then-rename: [`read_ranges`] treats a malformed sidecar as fatal, so
744/// a torn write (crash or error mid-update) must never be observable.
745/// Same-directory rename is atomic on POSIX and replaces on Windows; the
746/// `.tmp` sibling is itself a `.libfw-sess-*` file, so the stale-session
747/// sweeper collects any leftovers.
748async fn persist_ranges(blocks: &Path, ranges: &[ChunkRange]) -> Result<(), StorageError> {
749    let text = serde_json::to_string(ranges).unwrap_or_else(|_| "[]".to_string());
750    let mut tmp = blocks.as_os_str().to_owned();
751    tmp.push(".tmp");
752    let tmp = PathBuf::from(tmp);
753    tokio::fs::write(&tmp, text)
754        .await
755        .map_err(StorageError::Other)?;
756    tokio::fs::rename(&tmp, blocks)
757        .await
758        .map_err(StorageError::Other)?;
759    Ok(())
760}
761
762/// Remove a session temp sidecar (best-effort; missing file is fine).
763async fn remove_blocks_sidecar(blocks: Option<&Path>) -> Result<(), StorageError> {
764    if let Some(blocks) = blocks {
765        let _ = tokio::fs::remove_file(blocks).await;
766    }
767    Ok(())
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use libfw_core::storage::StorageBackend;
774
775    /// Force a file's mtime (used to age a fake session temp).
776    fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
777        // Open writable: on Windows, setting file times through a read-only
778        // handle is refused.
779        let file = std::fs::OpenOptions::new().write(true).open(path)?;
780        file.set_times(std::fs::FileTimes::new().set_modified(time))
781    }
782
783    #[tokio::test]
784    async fn write_read_roundtrip() {
785        let dir = tempfile::tempdir().unwrap();
786        let storage = FsStorage::new(dir.path());
787
788        let sink = storage
789            .write_stream("a/b.txt", WriteMode::Create)
790            .await
791            .unwrap();
792        let mut sink = sink;
793        sink.write(b"hello ").await.unwrap();
794        sink.write(b"world").await.unwrap();
795        let meta = sink.commit().await.unwrap();
796        assert_eq!(meta.size, 11);
797
798        let got = storage.file_meta("a/b.txt").await.unwrap().unwrap();
799        assert_eq!(got.size, 11);
800
801        let mut reader = storage
802            .read_stream("a/b.txt", RangeSpec::new(0, 11))
803            .await
804            .unwrap();
805        let mut buf = Vec::new();
806        reader.read_to_end(&mut buf).unwrap();
807        assert_eq!(buf, b"hello world");
808    }
809
810    #[tokio::test]
811    async fn create_rejects_existing() {
812        let dir = tempfile::tempdir().unwrap();
813        let storage = FsStorage::new(dir.path());
814        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
815        let mut sink = sink;
816        sink.write(b"x").await.unwrap();
817        sink.commit().await.unwrap();
818
819        let res = storage.write_stream("f.txt", WriteMode::Create).await;
820        assert!(matches!(res, Err(StorageError::AlreadyExists(_))));
821    }
822
823    #[tokio::test]
824    async fn resume_writes_at_offset() {
825        let dir = tempfile::tempdir().unwrap();
826        let storage = FsStorage::new(dir.path());
827
828        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
829        let mut sink = sink;
830        sink.write(b"ABCD").await.unwrap();
831        sink.commit().await.unwrap();
832
833        let mut sink = storage
834            .write_stream("f.txt", WriteMode::Resume { offset: 4 })
835            .await
836            .unwrap();
837        sink.write(b"EF").await.unwrap();
838        sink.commit().await.unwrap();
839
840        let mut reader = storage
841            .read_stream("f.txt", RangeSpec::full(6))
842            .await
843            .unwrap();
844        let mut buf = Vec::new();
845        reader.read_to_end(&mut buf).unwrap();
846        assert_eq!(buf, b"ABCDEF");
847    }
848
849    #[tokio::test]
850    async fn resume_offset_mismatch_fails() {
851        let dir = tempfile::tempdir().unwrap();
852        let storage = FsStorage::new(dir.path());
853        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
854        let mut sink = sink;
855        sink.write(b"AB").await.unwrap();
856        sink.commit().await.unwrap();
857
858        let res = storage.write_stream("f.txt", WriteMode::Resume { offset: 9 }).await;
859        assert!(res.is_err());
860    }
861
862    #[tokio::test]
863    async fn abort_leaves_no_partial_file() {
864        let dir = tempfile::tempdir().unwrap();
865        let storage = FsStorage::new(dir.path());
866        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
867        let mut sink = sink;
868        sink.write(b"partial").await.unwrap();
869        sink.abort().await.unwrap();
870        assert!(!dir.path().join("f.txt").exists());
871    }
872
873    #[tokio::test]
874    async fn list_dir_and_remove() {
875        let dir = tempfile::tempdir().unwrap();
876        let storage = FsStorage::new(dir.path());
877        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
878        std::fs::write(dir.path().join("sub/x.txt"), b"1").unwrap();
879        std::fs::write(dir.path().join("a.txt"), b"22").unwrap();
880
881        let entries = storage.list_dir("").await.unwrap();
882        assert_eq!(entries.len(), 2);
883        let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
884        assert_eq!(names, vec!["a.txt", "sub"]);
885
886        storage.remove("sub").await.unwrap();
887        assert!(!dir.path().join("sub").exists());
888    }
889
890    #[tokio::test]
891    async fn rejects_path_traversal() {
892        let dir = tempfile::tempdir().unwrap();
893        let storage = FsStorage::new(dir.path());
894        assert!(storage.file_meta("../etc/passwd").await.is_err());
895        assert!(storage.file_meta("/etc/passwd").await.is_err());
896    }
897
898    #[tokio::test]
899    async fn corrupt_session_sidecar_is_rejected() {
900        let dir = tempfile::tempdir().unwrap();
901        let storage = FsStorage::new(dir.path());
902        let tmp = dir.path().join(".libfw-sess-owner-session-f.txt");
903        std::fs::write(&tmp, b"partial").unwrap();
904        std::fs::write(tmp.with_extension("txt.blocks"), b"{not-json}").unwrap();
905
906        let res = storage
907            .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
908            .await;
909        let err = match res {
910            Ok(_) => panic!("malformed sidecar should not be accepted"),
911            Err(e) => e,
912        };
913        // The message is surfaced to clients as a 500 body — it must not leak
914        // the server-side storage root.
915        let msg = err.to_string();
916        assert!(msg.contains("corrupt session state"), "{msg}");
917        assert!(
918            !msg.contains(&dir.path().display().to_string()),
919            "error leaked the server path: {msg}"
920        );
921    }
922
923    #[tokio::test]
924    async fn session_sidecar_is_written_atomically() {
925        let dir = tempfile::tempdir().unwrap();
926        let storage = FsStorage::new(dir.path());
927        let mut sink = storage
928            .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
929            .await
930            .unwrap();
931
932        sink.write_at(4, b"abcd").await.unwrap();
933        assert_eq!(
934            sink.received_ranges().await.unwrap(),
935            vec![ChunkRange { start: 4, end: 8 }]
936        );
937        sink.write_at(8, b"ef").await.unwrap();
938
939        // The sidecar always holds complete, parseable JSON…
940        let blocks = dir.path().join(".libfw-sess-owner-session-f.txt.blocks");
941        let text = std::fs::read_to_string(&blocks).unwrap();
942        let ranges: Vec<ChunkRange> = serde_json::from_str(&text).unwrap();
943        assert_eq!(ranges, vec![ChunkRange { start: 4, end: 10 }]);
944        // …and the write-then-rename leaves no temporary sibling behind.
945        assert!(!dir
946            .path()
947            .join(".libfw-sess-owner-session-f.txt.blocks.tmp")
948            .exists());
949        sink.abort().await.unwrap();
950    }
951
952    #[tokio::test]
953    async fn cleanup_stale_sessions_removes_old_temps_only() {
954        let dir = tempfile::tempdir().unwrap();
955        let storage = FsStorage::new(dir.path());
956
957        // A stale session temp (very old mtime) + its range sidecar.
958        let stale = dir.path().join(".libfw-sess-oldid-a.bin");
959        std::fs::write(&stale, b"partial").unwrap();
960        std::fs::write(dir.path().join(".libfw-sess-oldid-a.bin.blocks"), b"[[0,7]]").unwrap();
961        // Age it well beyond the 1-hour TTL (7 days old). Windows/FAT can
962        // clamp very old timestamps, so use a recent-but-stale date.
963        let old = std::time::SystemTime::now()
964            .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
965            .unwrap();
966        assert!(
967            filetime_set(&stale, old).is_ok(),
968            "failed to age the stale session temp"
969        );
970
971        // A fresh session temp (now) must be kept.
972        let fresh = dir.path().join(".libfw-sess-newid-a.bin");
973        std::fs::write(&fresh, b"partial").unwrap();
974
975        // A committed user file must never be touched.
976        std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
977
978        let removed = storage
979            .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
980            .await
981            .unwrap();
982        assert_eq!(removed, 1, "only the stale temp is removed");
983        assert!(!stale.exists());
984        assert!(!dir.path().join(".libfw-sess-oldid-a.bin.blocks").exists());
985        assert!(fresh.exists(), "fresh temp survives");
986        assert!(dir.path().join("real.txt").exists(), "user file survives");
987    }
988
989    #[tokio::test]
990    async fn committed_meta_has_full_relative_path() {
991        let dir = tempfile::tempdir().unwrap();
992        let storage = FsStorage::new(dir.path());
993        let sink = storage
994            .write_stream("deep/nested/f.txt", WriteMode::Create)
995            .await
996            .unwrap();
997        let mut sink = sink;
998        sink.write(b"hi").await.unwrap();
999        let meta = sink.commit().await.unwrap();
1000        assert_eq!(meta.path, "deep/nested/f.txt");
1001    }
1002
1003    #[cfg(unix)]
1004    #[tokio::test]
1005    async fn rejects_symlink_traversal() {
1006        use std::os::unix::fs::symlink;
1007
1008        let dir = tempfile::tempdir().unwrap();
1009        let storage = FsStorage::new(dir.path());
1010
1011        // A symlink planted inside the root pointing outside it must not let
1012        // reads/writes escape the mount root.
1013        let outside = tempfile::tempdir().unwrap();
1014        std::fs::write(outside.path().join("secret.txt"), b"top-secret").unwrap();
1015        symlink(outside.path(), dir.path().join("evil")).unwrap();
1016
1017        assert!(storage.read_stream("evil/secret.txt", RangeSpec::full(10)).await.is_err());
1018        assert!(storage.file_meta("evil/secret.txt").await.is_err());
1019        assert!(storage.write_stream("evil/new.txt", WriteMode::Create).await.is_err());
1020    }
1021}