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
328                // `WriteMode::Resume` request has no meaning 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 `is_dir()` and `is_symlink()` are mutually
483                // exclusive: a symlink to a directory reports
484                // `is_symlink()=true`, `is_dir()=false`. The symlink check
485                // below therefore rules out every symlinked directory
486                // before the walk descends into it, and the two tests can
487                // stay independent.
488                if ft.is_symlink() {
489                    // Never follow symlinks into directories.
490                    continue;
491                }
492                if ft.is_dir() {
493                    stack.push(entry.path());
494                    continue;
495                }
496                let name = entry.file_name().to_string_lossy().to_string();
497                if !name.starts_with(".libfw-sess-") {
498                    continue;
499                }
500                let modified = entry
501                    .metadata()
502                    .await
503                    .ok()
504                    .and_then(|m| m.modified().ok())
505                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
506                    .map(|d| UNIX_EPOCH + d)
507                    .unwrap_or(UNIX_EPOCH);
508                if modified < deadline {
509                    // A sidecar is itself a `.libfw-sess-*` entry, so the walk
510                    // reaches it too: it goes with its temp, and an orphaned
511                    // one is still swept on its own pass. Only a temp counts,
512                    // keeping the result a number of sessions.
513                    let is_sidecar = name.ends_with(".blocks");
514                    let mut sidecar = entry.path().as_os_str().to_owned();
515                    sidecar.push(".blocks");
516                    let _ = tokio::fs::remove_file(PathBuf::from(sidecar)).await;
517                    if tokio::fs::remove_file(entry.path()).await.is_ok() && !is_sidecar {
518                        removed += 1;
519                    }
520                }
521            }
522        }
523        Ok(removed)
524    }
525}
526
527/// A temporary path for a target, unique per attempt.
528///
529/// Uniqueness combines a monotonic counter with a timestamp so two
530/// concurrent Create-mode uploads to the same target never collide on the
531/// same temp file (which would otherwise interleave/truncate each other).
532static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
533
534fn temp_path_for(target: &Path) -> PathBuf {
535    let nanos = SystemTime::now()
536        .duration_since(UNIX_EPOCH)
537        .map(|d| d.as_nanos())
538        .unwrap_or(0);
539    let counter = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
540    let file_name = target
541        .file_name()
542        .map(|n| n.to_string_lossy().to_string())
543        .unwrap_or_else(|| "upload".to_string());
544    let tmp_name = format!(".libfw-tmp-{file_name}-{nanos}-{counter}");
545    target.with_file_name(tmp_name)
546}
547
548/// Streaming write handle for the filesystem backend.
549pub struct FsSink {
550    file: tokio::fs::File,
551    tmp: Option<PathBuf>,
552    target: PathBuf,
553    /// The full virtual path (used for the committed `FileMeta`).
554    rel: String,
555    /// The mode the sink was opened with (used for the Create atomicity
556    /// check at commit time).
557    mode: WriteMode,
558    written: u64,
559    /// Optional sidecar path tracking received byte ranges for a resumable
560    /// "session" upload (parallel to the session temp file). `None` for
561    /// ordinary (non-session) sinks.
562    blocks_path: Option<PathBuf>,
563    /// In-memory copy of the received ranges (kept in sync with
564    /// `blocks_path`).
565    ranges: Vec<ChunkRange>,
566    /// Per-target guard held for the sink's whole lifetime. `Some` only for
567    /// session sinks (serializing sidecar read-merge-write per target) and
568    /// for `WriteMode::Resume` (serializing concurrent appends to the same
569    /// target). `None` for plain Create/Overwrite sinks, which write to a
570    /// unique temp and rename on commit — no shared state to guard.
571    target_guard: Option<OwnedMutexGuard<()>>,
572}
573
574/// Merge `new` (a `[start, end)` half-open range) into a sorted, disjoint
575/// list of ranges, coalescing overlaps/adjacencies. Returns the updated list.
576fn merge_range(ranges: &mut Vec<ChunkRange>, new: ChunkRange) {
577    if new.is_empty() {
578        return;
579    }
580    ranges.push(new);
581    ranges.sort_by_key(|r| r.start);
582    let mut merged: Vec<ChunkRange> = Vec::with_capacity(ranges.len());
583    for r in ranges.drain(..) {
584        if let Some(last) = merged.last_mut() {
585            // Overlapping or adjacent ranges coalesce.
586            if r.start <= last.end {
587                if r.end > last.end {
588                    last.end = r.end;
589                }
590                continue;
591            }
592        }
593        merged.push(r);
594    }
595    *ranges = merged;
596}
597
598#[async_trait]
599impl UploadSink for FsSink {
600    async fn write(&mut self, buf: &[u8]) -> Result<(), StorageError> {
601        use tokio::io::AsyncWriteExt;
602        self.file
603            .write_all(buf)
604            .await
605            .map_err(|e| StorageError::write_failed(self.written, e))?;
606        self.written += buf.len() as u64;
607        Ok(())
608    }
609
610    async fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), StorageError> {
611        use tokio::io::{AsyncSeekExt, AsyncWriteExt};
612        self.file
613            .seek(SeekFrom::Start(offset))
614            .await
615            .map_err(|e| StorageError::write_failed(offset, e))?;
616        self.file
617            .write_all(buf)
618            .await
619            .map_err(|e| StorageError::write_failed(offset, e))?;
620        // Track the highest extent written (used by `len`-style bookkeeping;
621        // the real size for commit validation comes from file metadata).
622        self.written = self.written.max(offset.saturating_add(buf.len() as u64));
623        // For a resumable session sink, record the received byte range in the
624        // sidecar so a later probe / resume knows this part is already on
625        // disk and only missing gaps need to be re-sent.
626        //
627        // The read-merge-write is serialized by the per-target guard this
628        // sink holds for its whole lifetime: concurrent chunks of the SAME
629        // session each hold a *stale* in-memory `ranges` copy loaded when
630        // their sink was opened, so a bare `persist_ranges` would clobber
631        // other chunks' ranges (lost update) and make the commit coverage
632        // check reject a fully-written file. Re-reading under the guard
633        // keeps every writer's merge based on the latest on-disk state.
634        if let Some(blocks) = self.blocks_path.clone() {
635            let _guard = self.target_guard.as_ref().expect("session sink holds a guard");
636            let mut current = read_ranges(&blocks).await?;
637            merge_range(&mut current, ChunkRange {
638                start: offset,
639                end: offset.saturating_add(buf.len() as u64),
640            });
641            persist_ranges(&blocks, &current).await?;
642            self.ranges = current;
643        }
644        Ok(())
645    }
646
647    async fn received_ranges(&mut self) -> Result<Vec<ChunkRange>, StorageError> {
648        Ok(self.ranges.clone())
649    }
650
651    async fn len(&self) -> Result<u64, StorageError> {
652        self.file
653            .metadata()
654            .await
655            .map(|m| m.len())
656            .map_err(|e| StorageError::Other(e))
657    }
658
659    async fn commit(self: Box<Self>) -> Result<FileMeta, StorageError> {
660        use tokio::io::AsyncWriteExt;
661        let FsSink {
662            mut file,
663            tmp,
664            target,
665            rel,
666            mode,
667            blocks_path,
668            ..
669        } = *self;
670        let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
671        file.flush().await.map_err(|e| StorageError::Other(e))?;
672        file.sync_all().await.map_err(|e| StorageError::Other(e))?;
673        drop(file);
674        if let Some(tmp) = tmp {
675            // Create mode must never clobber a target that appeared while
676            // we were streaming (closes the check-then-rename TOCTOU for
677            // the common non-concurrent-writer case).
678            if matches!(mode, WriteMode::Create)
679                && tokio::fs::try_exists(&target)
680                    .await
681                    .map_err(|e| StorageError::Other(e))?
682            {
683                let _ = tokio::fs::remove_file(&tmp).await;
684                return Err(StorageError::AlreadyExists(rel));
685            }
686            tokio::fs::rename(&tmp, &target)
687                .await
688                .map_err(|e| StorageError::Other(e))?;
689            // Durability: fsync the parent directory so the rename itself
690            // survives a crash.
691            if let Some(parent) = target.parent() {
692                if let Ok(d) = tokio::fs::File::open(parent).await {
693                    let _ = d.sync_all().await;
694                }
695            }
696        }
697        let meta = tokio::fs::metadata(&target)
698            .await
699            .map_err(|e| StorageError::Other(e))?;
700        Ok(file_meta_at(&rel, &meta))
701    }
702
703    async fn abort(self: Box<Self>) -> Result<(), StorageError> {
704        let FsSink {
705            file,
706            tmp,
707            blocks_path,
708            ..
709        } = *self;
710        drop(file);
711        if let Some(tmp) = tmp {
712            let _ = tokio::fs::remove_file(&tmp).await;
713        }
714        let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
715        Ok(())
716    }
717}
718
719/// Sidecar filename for a session temp (e.g. `<temp>.blocks`).
720fn blocks_path_for(tmp: &Path) -> PathBuf {
721    let mut name = tmp.as_os_str().to_owned();
722    name.push(".blocks");
723    PathBuf::from(name)
724}
725
726/// Read the persisted received ranges for a session temp sidecar.
727///
728/// An invalid or tampered sidecar is a protocol error: the server must refuse
729/// to continue instead of silently treating the upload as if it had no
730/// received data and accidentally re-sending/overwriting valid chunks.
731async fn read_ranges(blocks: &Path) -> Result<Vec<ChunkRange>, StorageError> {
732    match tokio::fs::read_to_string(blocks).await {
733        Ok(text) => serde_json::from_str::<Vec<ChunkRange>>(&text).map_err(|e| {
734            // This message reaches clients as a 500 body, so it must not
735            // disclose server-side filesystem paths.
736            StorageError::Other(std::io::Error::other(format!(
737                "corrupt session state: {e}"
738            )))
739        }),
740        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
741        Err(e) => Err(StorageError::Other(e)),
742    }
743}
744
745/// Persist the received byte ranges to a session temp sidecar.
746///
747/// Write-then-rename: [`read_ranges`] treats a malformed sidecar as fatal, so
748/// a torn write (crash or error mid-update) must never be observable.
749/// Same-directory rename is atomic on POSIX and replaces on Windows; the
750/// `.tmp` sibling is itself a `.libfw-sess-*` file, so the stale-session
751/// sweeper collects any leftovers.
752async fn persist_ranges(blocks: &Path, ranges: &[ChunkRange]) -> Result<(), StorageError> {
753    let text = serde_json::to_string(ranges).unwrap_or_else(|_| "[]".to_string());
754    let mut tmp = blocks.as_os_str().to_owned();
755    tmp.push(".tmp");
756    let tmp = PathBuf::from(tmp);
757    tokio::fs::write(&tmp, text)
758        .await
759        .map_err(StorageError::Other)?;
760    tokio::fs::rename(&tmp, blocks)
761        .await
762        .map_err(StorageError::Other)?;
763    Ok(())
764}
765
766/// Remove a session temp sidecar (best-effort; missing file is fine).
767async fn remove_blocks_sidecar(blocks: Option<&Path>) -> Result<(), StorageError> {
768    if let Some(blocks) = blocks {
769        let _ = tokio::fs::remove_file(blocks).await;
770    }
771    Ok(())
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777    use libfw_core::storage::StorageBackend;
778
779    /// Force a file's mtime (used to age a fake session temp).
780    fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
781        // Open writable: on Windows, setting file times through a read-only
782        // handle is refused.
783        let file = std::fs::OpenOptions::new().write(true).open(path)?;
784        file.set_times(std::fs::FileTimes::new().set_modified(time))
785    }
786
787    #[tokio::test]
788    async fn write_read_roundtrip() {
789        let dir = tempfile::tempdir().unwrap();
790        let storage = FsStorage::new(dir.path());
791
792        let sink = storage
793            .write_stream("a/b.txt", WriteMode::Create)
794            .await
795            .unwrap();
796        let mut sink = sink;
797        sink.write(b"hello ").await.unwrap();
798        sink.write(b"world").await.unwrap();
799        let meta = sink.commit().await.unwrap();
800        assert_eq!(meta.size, 11);
801
802        let got = storage.file_meta("a/b.txt").await.unwrap().unwrap();
803        assert_eq!(got.size, 11);
804
805        let mut reader = storage
806            .read_stream("a/b.txt", RangeSpec::new(0, 11))
807            .await
808            .unwrap();
809        let mut buf = Vec::new();
810        reader.read_to_end(&mut buf).unwrap();
811        assert_eq!(buf, b"hello world");
812    }
813
814    #[tokio::test]
815    async fn create_rejects_existing() {
816        let dir = tempfile::tempdir().unwrap();
817        let storage = FsStorage::new(dir.path());
818        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
819        let mut sink = sink;
820        sink.write(b"x").await.unwrap();
821        sink.commit().await.unwrap();
822
823        let res = storage.write_stream("f.txt", WriteMode::Create).await;
824        assert!(matches!(res, Err(StorageError::AlreadyExists(_))));
825    }
826
827    #[tokio::test]
828    async fn resume_writes_at_offset() {
829        let dir = tempfile::tempdir().unwrap();
830        let storage = FsStorage::new(dir.path());
831
832        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
833        let mut sink = sink;
834        sink.write(b"ABCD").await.unwrap();
835        sink.commit().await.unwrap();
836
837        let mut sink = storage
838            .write_stream("f.txt", WriteMode::Resume { offset: 4 })
839            .await
840            .unwrap();
841        sink.write(b"EF").await.unwrap();
842        sink.commit().await.unwrap();
843
844        let mut reader = storage
845            .read_stream("f.txt", RangeSpec::full(6))
846            .await
847            .unwrap();
848        let mut buf = Vec::new();
849        reader.read_to_end(&mut buf).unwrap();
850        assert_eq!(buf, b"ABCDEF");
851    }
852
853    #[tokio::test]
854    async fn resume_offset_mismatch_fails() {
855        let dir = tempfile::tempdir().unwrap();
856        let storage = FsStorage::new(dir.path());
857        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
858        let mut sink = sink;
859        sink.write(b"AB").await.unwrap();
860        sink.commit().await.unwrap();
861
862        let res = storage.write_stream("f.txt", WriteMode::Resume { offset: 9 }).await;
863        assert!(res.is_err());
864    }
865
866    #[tokio::test]
867    async fn abort_leaves_no_partial_file() {
868        let dir = tempfile::tempdir().unwrap();
869        let storage = FsStorage::new(dir.path());
870        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
871        let mut sink = sink;
872        sink.write(b"partial").await.unwrap();
873        sink.abort().await.unwrap();
874        assert!(!dir.path().join("f.txt").exists());
875    }
876
877    #[tokio::test]
878    async fn list_dir_and_remove() {
879        let dir = tempfile::tempdir().unwrap();
880        let storage = FsStorage::new(dir.path());
881        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
882        std::fs::write(dir.path().join("sub/x.txt"), b"1").unwrap();
883        std::fs::write(dir.path().join("a.txt"), b"22").unwrap();
884
885        let entries = storage.list_dir("").await.unwrap();
886        assert_eq!(entries.len(), 2);
887        let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
888        assert_eq!(names, vec!["a.txt", "sub"]);
889
890        storage.remove("sub").await.unwrap();
891        assert!(!dir.path().join("sub").exists());
892    }
893
894    #[tokio::test]
895    async fn rejects_path_traversal() {
896        let dir = tempfile::tempdir().unwrap();
897        let storage = FsStorage::new(dir.path());
898        assert!(storage.file_meta("../etc/passwd").await.is_err());
899        assert!(storage.file_meta("/etc/passwd").await.is_err());
900    }
901
902    #[tokio::test]
903    async fn corrupt_session_sidecar_is_rejected() {
904        let dir = tempfile::tempdir().unwrap();
905        let storage = FsStorage::new(dir.path());
906        let tmp = dir.path().join(".libfw-sess-owner-session-f.txt");
907        std::fs::write(&tmp, b"partial").unwrap();
908        std::fs::write(tmp.with_extension("txt.blocks"), b"{not-json}").unwrap();
909
910        let res = storage
911            .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
912            .await;
913        let err = match res {
914            Ok(_) => panic!("malformed sidecar should not be accepted"),
915            Err(e) => e,
916        };
917        // The message is surfaced to clients as a 500 body — it must not leak
918        // the server-side storage root.
919        let msg = err.to_string();
920        assert!(msg.contains("corrupt session state"), "{msg}");
921        assert!(
922            !msg.contains(&dir.path().display().to_string()),
923            "error leaked the server path: {msg}"
924        );
925    }
926
927    #[tokio::test]
928    async fn session_sidecar_is_written_atomically() {
929        let dir = tempfile::tempdir().unwrap();
930        let storage = FsStorage::new(dir.path());
931        let mut sink = storage
932            .write_stream_session("f.txt", "session", "owner", WriteMode::Create)
933            .await
934            .unwrap();
935
936        sink.write_at(4, b"abcd").await.unwrap();
937        assert_eq!(
938            sink.received_ranges().await.unwrap(),
939            vec![ChunkRange { start: 4, end: 8 }]
940        );
941        sink.write_at(8, b"ef").await.unwrap();
942
943        // The sidecar always holds complete, parseable JSON…
944        let blocks = dir.path().join(".libfw-sess-owner-session-f.txt.blocks");
945        let text = std::fs::read_to_string(&blocks).unwrap();
946        let ranges: Vec<ChunkRange> = serde_json::from_str(&text).unwrap();
947        assert_eq!(ranges, vec![ChunkRange { start: 4, end: 10 }]);
948        // …and the write-then-rename leaves no temporary sibling behind.
949        assert!(!dir
950            .path()
951            .join(".libfw-sess-owner-session-f.txt.blocks.tmp")
952            .exists());
953        sink.abort().await.unwrap();
954    }
955
956    #[tokio::test]
957    async fn cleanup_stale_sessions_removes_old_temps_only() {
958        let dir = tempfile::tempdir().unwrap();
959        let storage = FsStorage::new(dir.path());
960
961        // A stale session temp (very old mtime) + its range sidecar.
962        let stale = dir.path().join(".libfw-sess-oldid-a.bin");
963        std::fs::write(&stale, b"partial").unwrap();
964        std::fs::write(dir.path().join(".libfw-sess-oldid-a.bin.blocks"), b"[[0,7]]").unwrap();
965        // Age it well beyond the 1-hour TTL (7 days old). Windows/FAT can
966        // clamp very old timestamps, so use a recent-but-stale date.
967        let old = std::time::SystemTime::now()
968            .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
969            .unwrap();
970        assert!(
971            filetime_set(&stale, old).is_ok(),
972            "failed to age the stale session temp"
973        );
974
975        // A fresh session temp (now) must be kept.
976        let fresh = dir.path().join(".libfw-sess-newid-a.bin");
977        std::fs::write(&fresh, b"partial").unwrap();
978
979        // A committed user file must never be touched.
980        std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
981
982        let removed = storage
983            .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
984            .await
985            .unwrap();
986        assert_eq!(removed, 1, "only the stale temp is removed");
987        assert!(!stale.exists());
988        assert!(!dir.path().join(".libfw-sess-oldid-a.bin.blocks").exists());
989        assert!(fresh.exists(), "fresh temp survives");
990        assert!(dir.path().join("real.txt").exists(), "user file survives");
991    }
992
993    #[tokio::test]
994    async fn cleanup_stale_sessions_sweeps_orphaned_sidecars_without_counting_them() {
995        let dir = tempfile::tempdir().unwrap();
996        let storage = FsStorage::new(dir.path());
997
998        // A sidecar whose temp is already gone (a crash between the two
999        // writes): it must still be swept, or it leaks for the process
1000        // lifetime, but it is not a session in its own right.
1001        let orphan = dir.path().join(".libfw-sess-goneid-a.bin.blocks");
1002        std::fs::write(&orphan, b"[[0,7]]").unwrap();
1003        let old = std::time::SystemTime::now()
1004            .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
1005            .unwrap();
1006        assert!(filetime_set(&orphan, old).is_ok(), "failed to age the orphan");
1007
1008        // A fresh orphan must survive until it goes stale.
1009        let fresh_orphan = dir.path().join(".libfw-sess-newid-a.bin.blocks");
1010        std::fs::write(&fresh_orphan, b"[[0,7]]").unwrap();
1011
1012        let removed = storage
1013            .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
1014            .await
1015            .unwrap();
1016        assert_eq!(removed, 0, "an orphaned sidecar is not a session");
1017        assert!(!orphan.exists(), "the stale orphan is swept");
1018        assert!(fresh_orphan.exists(), "fresh orphan survives");
1019    }
1020
1021    #[tokio::test]
1022    async fn committed_meta_has_full_relative_path() {
1023        let dir = tempfile::tempdir().unwrap();
1024        let storage = FsStorage::new(dir.path());
1025        let sink = storage
1026            .write_stream("deep/nested/f.txt", WriteMode::Create)
1027            .await
1028            .unwrap();
1029        let mut sink = sink;
1030        sink.write(b"hi").await.unwrap();
1031        let meta = sink.commit().await.unwrap();
1032        assert_eq!(meta.path, "deep/nested/f.txt");
1033    }
1034
1035    #[cfg(unix)]
1036    #[tokio::test]
1037    async fn rejects_symlink_traversal() {
1038        use std::os::unix::fs::symlink;
1039
1040        let dir = tempfile::tempdir().unwrap();
1041        let storage = FsStorage::new(dir.path());
1042
1043        // A symlink planted inside the root pointing outside it must not let
1044        // reads/writes escape the mount root.
1045        let outside = tempfile::tempdir().unwrap();
1046        std::fs::write(outside.path().join("secret.txt"), b"top-secret").unwrap();
1047        symlink(outside.path(), dir.path().join("evil")).unwrap();
1048
1049        assert!(storage.read_stream("evil/secret.txt", RangeSpec::full(10)).await.is_err());
1050        assert!(storage.file_meta("evil/secret.txt").await.is_err());
1051        assert!(storage.write_stream("evil/new.txt", WriteMode::Create).await.is_err());
1052    }
1053}