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, rejecting traversal.
42    ///
43    /// Both textual `..` segments and symlinked path components are
44    /// rejected so a read/write can never escape the mount root through a
45    /// symlink planted inside it.
46    fn resolve(&self, path: &str) -> Result<PathBuf, StorageError> {
47        let rel = Path::new(path);
48        if rel.is_absolute() {
49            return Err(StorageError::Unsupported("absolute paths are not allowed"));
50        }
51        let mut joined = self.root.clone();
52        for component in rel.components() {
53            match component {
54                Component::Normal(seg) => {
55                    joined.push(seg);
56                    // Reject any component that is itself a symlink.
57                    match std::fs::symlink_metadata(&joined) {
58                        Ok(m) if m.file_type().is_symlink() => {
59                            return Err(StorageError::Unsupported(
60                                "path must not traverse a symlink",
61                            ))
62                        }
63                        Ok(_) => {}
64                        // A not-yet-existing component is fine (e.g. a new
65                        // upload target); it will be validated as it is
66                        // created component by component.
67                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
68                        Err(e) => return Err(StorageError::Other(e)),
69                    }
70                }
71                Component::CurDir => {}
72                _ => {
73                    return Err(StorageError::Unsupported(
74                        "path must not contain '..' or special components",
75                    ))
76                }
77            }
78        }
79        Ok(joined)
80    }
81}
82
83fn file_meta_at(rel: &str, meta: &std::fs::Metadata) -> FileMeta {
84    let mtime = meta
85        .modified()
86        .ok()
87        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
88        .map(|d| d.as_secs())
89        .unwrap_or(0);
90    FileMeta {
91        path: rel.to_string(),
92        size: meta.len(),
93        mtime,
94        etag: etag_from_size_mtime(meta.len(), mtime),
95    }
96}
97
98#[async_trait]
99impl StorageBackend for FsStorage {
100    async fn file_meta(&self, path: &str) -> Result<Option<FileMeta>, StorageError> {
101        let full = self.resolve(path)?;
102        let meta = match tokio::fs::metadata(&full).await {
103            Ok(m) => m,
104            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
105            Err(e) => return Err(StorageError::Other(e)),
106        };
107        if meta.is_dir() {
108            return Err(StorageError::Unsupported("path is a directory"));
109        }
110        Ok(Some(file_meta_at(path, &meta)))
111    }
112
113    async fn read_stream(
114        &self,
115        path: &str,
116        range: RangeSpec,
117    ) -> Result<Box<dyn Read + Send>, StorageError> {
118        let full = self.resolve(path)?;
119        let mut file = tokio::fs::File::open(&full)
120            .await
121            .map_err(|e| StorageError::Other(e))?;
122        if range.start > 0 {
123            tokio::io::AsyncSeekExt::seek(&mut file, SeekFrom::Start(range.start))
124                .await
125                .map_err(|e| StorageError::Other(e))?;
126        }
127        let std_file = file
128            .try_into_std()
129            .map_err(|e| StorageError::Other(std::io::Error::other(format!("{e:?}"))))?;
130        // Restrict to exactly the requested range.
131        let limited = std_file.take(range.len());
132        Ok(Box::new(limited))
133    }
134
135    async fn write_stream(
136        &self,
137        path: &str,
138        mode: WriteMode,
139    ) -> Result<Box<dyn UploadSink>, StorageError> {
140        let full = self.resolve(path)?;
141        if let Some(parent) = full.parent() {
142            tokio::fs::create_dir_all(parent)
143                .await
144                .map_err(|e| StorageError::Other(e))?;
145        }
146
147        match mode {
148            WriteMode::Create | WriteMode::Overwrite => {
149                if mode == WriteMode::Create
150                    && tokio::fs::try_exists(&full)
151                        .await
152                        .map_err(|e| StorageError::Other(e))?
153                {
154                    return Err(StorageError::AlreadyExists(path.to_string()));
155                }
156                // Write to a temp file, rename on commit.
157                let tmp = temp_path_for(&full);
158                let file = tokio::fs::File::create(&tmp)
159                    .await
160                    .map_err(|e| StorageError::Other(e))?;
161                Ok(Box::new(FsSink {
162                    file,
163                    tmp: Some(tmp),
164                    target: full,
165                    rel: path.to_string(),
166                    mode,
167                    written: 0,
168                    blocks_path: None,
169                    ranges: Vec::new(),
170                    sidecar_lock: self.sidecar_lock.clone(),
171                }))
172            }
173            WriteMode::Resume { offset } => {
174                let file = tokio::fs::OpenOptions::new()
175                    .write(true)
176                    .append(true)
177                    .open(&full)
178                    .await
179                    .map_err(|e| StorageError::Other(e))?;
180                let current = file
181                    .metadata()
182                    .await
183                    .map_err(|e| StorageError::Other(e))?
184                    .len();
185                if current != offset {
186                    return Err(StorageError::write_failed(
187                        offset,
188                        std::io::Error::other(format!(
189                            "existing file is {current} bytes, expected {offset}"
190                        )),
191                    ));
192                }
193                Ok(Box::new(FsSink {
194                    file,
195                    tmp: None,
196                    target: full,
197                    rel: path.to_string(),
198                    mode,
199                    written: offset,
200                    blocks_path: None,
201                    ranges: Vec::new(),
202                    sidecar_lock: self.sidecar_lock.clone(),
203                }))
204            }
205        }
206    }
207
208    async fn write_stream_session(
209        &self,
210        path: &str,
211        session: &str,
212        mode: WriteMode,
213    ) -> Result<Box<dyn UploadSink>, StorageError> {
214        let full = self.resolve(path)?;
215        if let Some(parent) = full.parent() {
216            tokio::fs::create_dir_all(parent)
217                .await
218                .map_err(|e| StorageError::Other(e))?;
219        }
220        // The session string is embedded in a temp filename, so it must never
221        // be able to inject path separators or `..` (a malicious client could
222        // otherwise write outside the mount root). Restrict to safe chars.
223        let safe: String = session
224            .chars()
225            .map(|c| {
226                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
227                    c
228                } else {
229                    '_'
230                }
231            })
232            .collect();
233        let name = full
234            .file_name()
235            .map(|n| n.to_string_lossy().to_string())
236            .unwrap_or_else(|| "upload".to_string());
237        let tmp = full.with_file_name(format!(".libfw-sess-{safe}-{name}"));
238
239        let exists = tokio::fs::try_exists(&tmp)
240            .await
241            .map_err(|e| StorageError::Other(e))?;
242        let file = if exists {
243            // Subsequent chunk / resume of an in-flight session: open the
244            // shared temp for positional (seek + write) access. `mode` is
245            // ignored; the already-received ranges are reloaded from the
246            // sidecar so the client can resume only the missing parts.
247            tokio::fs::OpenOptions::new()
248                .read(true)
249                .write(true)
250                .open(&tmp)
251                .await
252                .map_err(|e| StorageError::Other(e))?
253        } else {
254            // First request for this session: create the shared temp.
255            match mode {
256                WriteMode::Create | WriteMode::Overwrite => {
257                    if mode == WriteMode::Create
258                        && tokio::fs::try_exists(&full)
259                            .await
260                            .map_err(|e| StorageError::Other(e))?
261                    {
262                        return Err(StorageError::AlreadyExists(path.to_string()));
263                    }
264                    tokio::fs::OpenOptions::new()
265                        .write(true)
266                        .create_new(true)
267                        .open(&tmp)
268                        .await
269                        .map_err(|e| StorageError::Other(e))?
270                }
271                // Resumable sessions are driven by the per-block probe (the
272                // client asks "what ranges do you have?" and sends only the
273                // gaps), not by a contiguous offset append, so a legacy
274                // `Resume` mode is not applicable here.
275                WriteMode::Resume { .. } => {
276                    return Err(StorageError::Unsupported(
277                        "session upload does not support contiguous resume; use the block probe".into(),
278                    ))
279                }
280            }
281        };
282        // Load any already-received byte ranges from the sidecar so a pause /
283        // resume only re-sends the missing blocks.
284        let blocks_path = blocks_path_for(&tmp);
285        // A freshly-created temp means nothing has been received yet: a stale
286        // `.blocks` sidecar left behind by an earlier aborted attempt must not
287        // make the probe report phantom ranges (the client would then skip
288        // chunks and commit an empty / zero-filled file).
289        if !exists {
290            let _ = tokio::fs::remove_file(&blocks_path).await;
291        }
292        let ranges = if exists {
293            read_ranges(&blocks_path).await
294        } else {
295            Vec::new()
296        };
297        Ok(Box::new(FsSink {
298            file,
299            tmp: Some(tmp),
300            target: full,
301            rel: path.to_string(),
302            mode,
303            written: 0,
304            blocks_path: Some(blocks_path),
305            ranges,
306            sidecar_lock: self.sidecar_lock.clone(),
307        }))
308    }
309
310    async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>, StorageError> {
311        let full = if path.is_empty() {
312            self.root.clone()
313        } else {
314            self.resolve(path)?
315        };
316        let mut entries = Vec::new();
317        let mut read_dir = tokio::fs::read_dir(&full)
318            .await
319            .map_err(|e| {
320                if e.kind() == std::io::ErrorKind::NotFound {
321                    StorageError::NotFound(path.to_string())
322                } else {
323                    StorageError::Other(e)
324                }
325            })?;
326        while let Some(entry) = read_dir
327            .next_entry()
328            .await
329            .map_err(|e| StorageError::Other(e))?
330        {
331            let name = entry.file_name().to_string_lossy().to_string();
332            let rel = if path.is_empty() {
333                name.clone()
334            } else {
335                format!("{path}/{name}")
336            };
337            let meta = entry
338                .metadata()
339                .await
340                .map_err(|e| StorageError::Other(e))?;
341            let mtime = meta
342                .modified()
343                .ok()
344                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
345                .map(|d| d.as_secs())
346                .unwrap_or(0);
347            entries.push(DirEntry {
348                path: rel,
349                is_dir: meta.is_dir(),
350                size: if meta.is_dir() { 0 } else { meta.len() },
351                mtime,
352            });
353        }
354        entries.sort_by(|a, b| a.path.cmp(&b.path));
355        Ok(entries)
356    }
357
358    async fn mkdir_all(&self, path: &str) -> Result<(), StorageError> {
359        let full = self.resolve(path)?;
360        tokio::fs::create_dir_all(&full)
361            .await
362            .map_err(|e| StorageError::Other(e))?;
363        Ok(())
364    }
365
366    async fn remove(&self, path: &str) -> Result<(), StorageError> {
367        let full = self.resolve(path)?;
368        // Use `symlink_metadata` (not `metadata`) so a symlink that appeared
369        // since `resolve` checked is never followed — we refuse to remove
370        // *through* it. (The check-then-use race cannot be fully closed on
371        // all platforms without openat/O_NOFOLLOW; this narrows it for the
372        // destructive operation.)
373        let meta = tokio::fs::symlink_metadata(&full)
374            .await
375            .map_err(|e| StorageError::Other(e))?;
376        if meta.file_type().is_symlink() {
377            return Err(StorageError::Unsupported(
378                "refusing to remove through a symlink",
379            ));
380        }
381        if meta.is_dir() {
382            tokio::fs::remove_dir_all(&full)
383                .await
384                .map_err(|e| StorageError::Other(e))
385        } else {
386            tokio::fs::remove_file(&full)
387                .await
388                .map_err(|e| StorageError::Other(e))
389        }
390    }
391
392    async fn cleanup_stale_sessions(
393        &self,
394        max_age: std::time::Duration,
395    ) -> Result<usize, StorageError> {
396        // tus-style expiry: a client that vanishes mid-upload leaves its
397        // `.libfw-sess-<id>-<name>` temp (and `.blocks` sidecar) behind. Walk
398        // the root, removing the ones whose last write is older than
399        // `max_age`. Only session temps are touched — never committed user
400        // files — and symlinked directories are never followed.
401        let deadline = SystemTime::now()
402            .checked_sub(max_age)
403            .unwrap_or(UNIX_EPOCH);
404        let mut removed = 0usize;
405        let mut stack = vec![self.root.clone()];
406        while let Some(dir) = stack.pop() {
407            let mut rd = match tokio::fs::read_dir(&dir).await {
408                Ok(rd) => rd,
409                Err(_) => continue,
410            };
411            while let Ok(Some(entry)) = rd.next_entry().await {
412                let ft = match entry.file_type().await {
413                    Ok(ft) => ft,
414                    Err(_) => continue,
415                };
416                if ft.is_dir() {
417                    if !ft.is_symlink() {
418                        stack.push(entry.path());
419                    }
420                    continue;
421                }
422                if ft.is_symlink() {
423                    continue;
424                }
425                let name = entry.file_name().to_string_lossy().to_string();
426                if !name.starts_with(".libfw-sess-") {
427                    continue;
428                }
429                let modified = entry
430                    .metadata()
431                    .await
432                    .ok()
433                    .and_then(|m| m.modified().ok())
434                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
435                    .map(|d| UNIX_EPOCH + d)
436                    .unwrap_or(UNIX_EPOCH);
437                if modified < deadline {
438                    let _ = tokio::fs::remove_file(entry.path()).await;
439                    // Remove the parallel range sidecar (`<temp>.blocks`).
440                    let mut sidecar = entry.path().as_os_str().to_owned();
441                    sidecar.push(".blocks");
442                    let _ = tokio::fs::remove_file(PathBuf::from(sidecar)).await;
443                    removed += 1;
444                }
445            }
446        }
447        Ok(removed)
448    }
449}
450
451/// A temporary path for a target, unique per attempt.
452///
453/// Uniqueness combines a monotonic counter with a timestamp so two
454/// concurrent Create-mode uploads to the same target never collide on the
455/// same temp file (which would otherwise interleave/truncate each other).
456static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
457
458fn temp_path_for(target: &Path) -> PathBuf {
459    let nanos = SystemTime::now()
460        .duration_since(UNIX_EPOCH)
461        .map(|d| d.as_nanos())
462        .unwrap_or(0);
463    let counter = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
464    let file_name = target
465        .file_name()
466        .map(|n| n.to_string_lossy().to_string())
467        .unwrap_or_else(|| "upload".to_string());
468    let tmp_name = format!(".libfw-tmp-{file_name}-{nanos}-{counter}");
469    target.with_file_name(tmp_name)
470}
471
472/// Streaming write handle for the filesystem backend.
473pub struct FsSink {
474    file: tokio::fs::File,
475    tmp: Option<PathBuf>,
476    target: PathBuf,
477    /// The full virtual path (used for the committed `FileMeta`).
478    rel: String,
479    /// The mode the sink was opened with (used for the Create atomicity
480    /// check at commit time).
481    mode: WriteMode,
482    written: u64,
483    /// Optional sidecar path tracking received byte ranges for a resumable
484    /// "session" upload (parallel to the session temp file). `None` for
485    /// ordinary (non-session) sinks.
486    blocks_path: Option<PathBuf>,
487    /// In-memory copy of the received ranges (kept in sync with
488    /// `blocks_path`).
489    ranges: Vec<ChunkRange>,
490    /// Shared lock serializing sidecar read-merge-write (see `FsStorage`).
491    sidecar_lock: Arc<tokio::sync::Mutex<()>>,
492}
493
494/// Merge `new` (a `[start, end)` half-open range) into a sorted, disjoint
495/// list of ranges, coalescing overlaps/adjacencies. Returns the updated list.
496fn merge_range(ranges: &mut Vec<ChunkRange>, new: ChunkRange) {
497    if new.is_empty() {
498        return;
499    }
500    ranges.push(new);
501    ranges.sort_by_key(|r| r.start);
502    let mut merged: Vec<ChunkRange> = Vec::with_capacity(ranges.len());
503    for r in ranges.drain(..) {
504        if let Some(last) = merged.last_mut() {
505            // Overlapping or adjacent ranges coalesce.
506            if r.start <= last.end {
507                if r.end > last.end {
508                    last.end = r.end;
509                }
510                continue;
511            }
512        }
513        merged.push(r);
514    }
515    *ranges = merged;
516}
517
518#[async_trait]
519impl UploadSink for FsSink {
520    async fn write(&mut self, buf: &[u8]) -> Result<(), StorageError> {
521        use tokio::io::AsyncWriteExt;
522        self.file
523            .write_all(buf)
524            .await
525            .map_err(|e| StorageError::write_failed(self.written, e))?;
526        self.written += buf.len() as u64;
527        Ok(())
528    }
529
530    async fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), StorageError> {
531        use tokio::io::{AsyncSeekExt, AsyncWriteExt};
532        self.file
533            .seek(SeekFrom::Start(offset))
534            .await
535            .map_err(|e| StorageError::write_failed(offset, e))?;
536        self.file
537            .write_all(buf)
538            .await
539            .map_err(|e| StorageError::write_failed(offset, e))?;
540        // Track the highest extent written (used by `len`-style bookkeeping;
541        // the real size for commit validation comes from file metadata).
542        self.written = self.written.max(offset.saturating_add(buf.len() as u64));
543        // For a resumable session sink, record the received byte range in the
544        // sidecar so a later probe / resume knows this part is already on
545        // disk and only missing gaps need to be re-sent.
546        //
547        // The read-merge-write is serialized under the storage-wide lock:
548        // concurrent chunk requests each hold a *stale* in-memory `ranges`
549        // copy loaded when their sink was opened, so a bare `persist_ranges`
550        // would clobber other chunks' ranges (lost update) and make the
551        // commit coverage check reject a fully-written file.
552        if let Some(blocks) = self.blocks_path.clone() {
553            let guard = self.sidecar_lock.lock().await;
554            let mut current = read_ranges(&blocks).await;
555            merge_range(&mut current, ChunkRange {
556                start: offset,
557                end: offset.saturating_add(buf.len() as u64),
558            });
559            persist_ranges(&blocks, &current).await?;
560            self.ranges = current;
561            drop(guard);
562        }
563        Ok(())
564    }
565
566    async fn received_ranges(&mut self) -> Result<Vec<ChunkRange>, StorageError> {
567        Ok(self.ranges.clone())
568    }
569
570    async fn len(&self) -> Result<u64, StorageError> {
571        self.file
572            .metadata()
573            .await
574            .map(|m| m.len())
575            .map_err(|e| StorageError::Other(e))
576    }
577
578    async fn commit(self: Box<Self>) -> Result<FileMeta, StorageError> {
579        use tokio::io::AsyncWriteExt;
580        let FsSink {
581            mut file,
582            tmp,
583            target,
584            rel,
585            mode,
586            blocks_path,
587            ..
588        } = *self;
589        let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
590        file.flush().await.map_err(|e| StorageError::Other(e))?;
591        file.sync_all().await.map_err(|e| StorageError::Other(e))?;
592        drop(file);
593        if let Some(tmp) = tmp {
594            // Create mode must never clobber a target that appeared while
595            // we were streaming (closes the check-then-rename TOCTOU for
596            // the common non-concurrent-writer case).
597            if matches!(mode, WriteMode::Create)
598                && tokio::fs::try_exists(&target)
599                    .await
600                    .map_err(|e| StorageError::Other(e))?
601            {
602                let _ = tokio::fs::remove_file(&tmp).await;
603                return Err(StorageError::AlreadyExists(rel));
604            }
605            tokio::fs::rename(&tmp, &target)
606                .await
607                .map_err(|e| StorageError::Other(e))?;
608            // Durability: fsync the parent directory so the rename itself
609            // survives a crash.
610            if let Some(parent) = target.parent() {
611                if let Ok(d) = tokio::fs::File::open(parent).await {
612                    let _ = d.sync_all().await;
613                }
614            }
615        }
616        let meta = tokio::fs::metadata(&target)
617            .await
618            .map_err(|e| StorageError::Other(e))?;
619        Ok(file_meta_at(&rel, &meta))
620    }
621
622    async fn abort(self: Box<Self>) -> Result<(), StorageError> {
623        let FsSink {
624            file,
625            tmp,
626            blocks_path,
627            ..
628        } = *self;
629        drop(file);
630        if let Some(tmp) = tmp {
631            let _ = tokio::fs::remove_file(&tmp).await;
632        }
633        let _ = remove_blocks_sidecar(blocks_path.as_deref()).await;
634        Ok(())
635    }
636}
637
638/// Sidecar filename for a session temp (e.g. `<temp>.blocks`).
639fn blocks_path_for(tmp: &Path) -> PathBuf {
640    let mut name = tmp.as_os_str().to_owned();
641    name.push(".blocks");
642    PathBuf::from(name)
643}
644
645/// Read the persisted received byte ranges for a session temp sidecar.
646async fn read_ranges(blocks: &Path) -> Vec<ChunkRange> {
647    match tokio::fs::read_to_string(blocks).await {
648        Ok(text) => serde_json::from_str::<Vec<ChunkRange>>(&text).unwrap_or_default(),
649        Err(_) => Vec::new(),
650    }
651}
652
653/// Persist the received byte ranges to a session temp sidecar (best-effort).
654async fn persist_ranges(blocks: &Path, ranges: &[ChunkRange]) -> Result<(), StorageError> {
655    let text = serde_json::to_string(ranges).unwrap_or_else(|_| "[]".to_string());
656    tokio::fs::write(blocks, text)
657        .await
658        .map_err(|e| StorageError::Other(e))
659}
660
661/// Remove a session temp sidecar (best-effort; missing file is fine).
662async fn remove_blocks_sidecar(blocks: Option<&Path>) -> Result<(), StorageError> {
663    if let Some(blocks) = blocks {
664        let _ = tokio::fs::remove_file(blocks).await;
665    }
666    Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use libfw_core::storage::StorageBackend;
673
674    /// Force a file's mtime (used to age a fake session temp).
675    fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
676        // Open writable: on Windows, setting file times through a read-only
677        // handle is refused.
678        let file = std::fs::OpenOptions::new().write(true).open(path)?;
679        file.set_times(std::fs::FileTimes::new().set_modified(time))
680    }
681
682    #[tokio::test]
683    async fn write_read_roundtrip() {
684        let dir = tempfile::tempdir().unwrap();
685        let storage = FsStorage::new(dir.path());
686
687        let sink = storage
688            .write_stream("a/b.txt", WriteMode::Create)
689            .await
690            .unwrap();
691        let mut sink = sink;
692        sink.write(b"hello ").await.unwrap();
693        sink.write(b"world").await.unwrap();
694        let meta = sink.commit().await.unwrap();
695        assert_eq!(meta.size, 11);
696
697        let got = storage.file_meta("a/b.txt").await.unwrap().unwrap();
698        assert_eq!(got.size, 11);
699
700        let mut reader = storage
701            .read_stream("a/b.txt", RangeSpec::new(0, 11))
702            .await
703            .unwrap();
704        let mut buf = Vec::new();
705        reader.read_to_end(&mut buf).unwrap();
706        assert_eq!(buf, b"hello world");
707    }
708
709    #[tokio::test]
710    async fn create_rejects_existing() {
711        let dir = tempfile::tempdir().unwrap();
712        let storage = FsStorage::new(dir.path());
713        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
714        let mut sink = sink;
715        sink.write(b"x").await.unwrap();
716        sink.commit().await.unwrap();
717
718        let res = storage.write_stream("f.txt", WriteMode::Create).await;
719        assert!(matches!(res, Err(StorageError::AlreadyExists(_))));
720    }
721
722    #[tokio::test]
723    async fn resume_writes_at_offset() {
724        let dir = tempfile::tempdir().unwrap();
725        let storage = FsStorage::new(dir.path());
726
727        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
728        let mut sink = sink;
729        sink.write(b"ABCD").await.unwrap();
730        sink.commit().await.unwrap();
731
732        let mut sink = storage
733            .write_stream("f.txt", WriteMode::Resume { offset: 4 })
734            .await
735            .unwrap();
736        sink.write(b"EF").await.unwrap();
737        sink.commit().await.unwrap();
738
739        let mut reader = storage
740            .read_stream("f.txt", RangeSpec::full(6))
741            .await
742            .unwrap();
743        let mut buf = Vec::new();
744        reader.read_to_end(&mut buf).unwrap();
745        assert_eq!(buf, b"ABCDEF");
746    }
747
748    #[tokio::test]
749    async fn resume_offset_mismatch_fails() {
750        let dir = tempfile::tempdir().unwrap();
751        let storage = FsStorage::new(dir.path());
752        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
753        let mut sink = sink;
754        sink.write(b"AB").await.unwrap();
755        sink.commit().await.unwrap();
756
757        let res = storage.write_stream("f.txt", WriteMode::Resume { offset: 9 }).await;
758        assert!(res.is_err());
759    }
760
761    #[tokio::test]
762    async fn abort_leaves_no_partial_file() {
763        let dir = tempfile::tempdir().unwrap();
764        let storage = FsStorage::new(dir.path());
765        let sink = storage.write_stream("f.txt", WriteMode::Create).await.unwrap();
766        let mut sink = sink;
767        sink.write(b"partial").await.unwrap();
768        sink.abort().await.unwrap();
769        assert!(!dir.path().join("f.txt").exists());
770    }
771
772    #[tokio::test]
773    async fn list_dir_and_remove() {
774        let dir = tempfile::tempdir().unwrap();
775        let storage = FsStorage::new(dir.path());
776        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
777        std::fs::write(dir.path().join("sub/x.txt"), b"1").unwrap();
778        std::fs::write(dir.path().join("a.txt"), b"22").unwrap();
779
780        let entries = storage.list_dir("").await.unwrap();
781        assert_eq!(entries.len(), 2);
782        let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
783        assert_eq!(names, vec!["a.txt", "sub"]);
784
785        storage.remove("sub").await.unwrap();
786        assert!(!dir.path().join("sub").exists());
787    }
788
789    #[tokio::test]
790    async fn rejects_path_traversal() {
791        let dir = tempfile::tempdir().unwrap();
792        let storage = FsStorage::new(dir.path());
793        assert!(storage.file_meta("../etc/passwd").await.is_err());
794        assert!(storage.file_meta("/etc/passwd").await.is_err());
795    }
796
797    #[tokio::test]
798    async fn cleanup_stale_sessions_removes_old_temps_only() {
799        let dir = tempfile::tempdir().unwrap();
800        let storage = FsStorage::new(dir.path());
801
802        // A stale session temp (very old mtime) + its range sidecar.
803        let stale = dir.path().join(".libfw-sess-oldid-a.bin");
804        std::fs::write(&stale, b"partial").unwrap();
805        std::fs::write(dir.path().join(".libfw-sess-oldid-a.bin.blocks"), b"[[0,7]]").unwrap();
806        // Age it well beyond the 1-hour TTL (7 days old). Windows/FAT can
807        // clamp very old timestamps, so use a recent-but-stale date.
808        let old = std::time::SystemTime::now()
809            .checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
810            .unwrap();
811        assert!(
812            filetime_set(&stale, old).is_ok(),
813            "failed to age the stale session temp"
814        );
815
816        // A fresh session temp (now) must be kept.
817        let fresh = dir.path().join(".libfw-sess-newid-a.bin");
818        std::fs::write(&fresh, b"partial").unwrap();
819
820        // A committed user file must never be touched.
821        std::fs::write(dir.path().join("real.txt"), b"real").unwrap();
822
823        let removed = storage
824            .cleanup_stale_sessions(std::time::Duration::from_secs(3600))
825            .await
826            .unwrap();
827        assert_eq!(removed, 1, "only the stale temp is removed");
828        assert!(!stale.exists());
829        assert!(!dir.path().join(".libfw-sess-oldid-a.bin.blocks").exists());
830        assert!(fresh.exists(), "fresh temp survives");
831        assert!(dir.path().join("real.txt").exists(), "user file survives");
832    }
833
834    #[tokio::test]
835    async fn committed_meta_has_full_relative_path() {
836        let dir = tempfile::tempdir().unwrap();
837        let storage = FsStorage::new(dir.path());
838        let sink = storage
839            .write_stream("deep/nested/f.txt", WriteMode::Create)
840            .await
841            .unwrap();
842        let mut sink = sink;
843        sink.write(b"hi").await.unwrap();
844        let meta = sink.commit().await.unwrap();
845        assert_eq!(meta.path, "deep/nested/f.txt");
846    }
847
848    #[cfg(unix)]
849    #[tokio::test]
850    async fn rejects_symlink_traversal() {
851        use std::os::unix::fs::symlink;
852
853        let dir = tempfile::tempdir().unwrap();
854        let storage = FsStorage::new(dir.path());
855
856        // A symlink planted inside the root pointing outside it must not let
857        // reads/writes escape the mount root.
858        let outside = tempfile::tempdir().unwrap();
859        std::fs::write(outside.path().join("secret.txt"), b"top-secret").unwrap();
860        symlink(outside.path(), dir.path().join("evil")).unwrap();
861
862        assert!(storage.read_stream("evil/secret.txt", RangeSpec::full(10)).await.is_err());
863        assert!(storage.file_meta("evil/secret.txt").await.is_err());
864        assert!(storage.write_stream("evil/new.txt", WriteMode::Create).await.is_err());
865    }
866}