Skip to main content

ts_runtime/
taildrop.rs

1//! Taildrop file store — the receiving half of Tailscale's peer-to-peer file transfer.
2//!
3//! A peer sends a file to this node via the peerAPI route `PUT /v0/put/<name>` (handled in
4//! `peerapi`). This module owns the on-disk store those puts land in, faithfully mirroring
5//! Go's `taildrop.manager`:
6//!
7//! - Incoming bytes are written to a per-transfer **partial** file (`<base>.partial`) so an
8//!   interrupted transfer never exposes a truncated file under its real name, and can be resumed
9//!   from an offset.
10//! - On successful completion the partial is **atomically renamed** to the final base name. If the
11//!   final name already exists, a non-clobbering ` (n)` suffix is chosen (Go `nextFilename`).
12//! - File names are strictly validated ([`validate_base_name`](crate::taildrop::validate_base_name)) to defeat path traversal and
13//!   reserved-suffix abuse before any path is constructed — this is the security boundary.
14//!
15//! # Anti-abuse / safety
16//!
17//! Every name is validated to be a single, local, non-traversing path component before it touches
18//! the filesystem; a name containing `/`, `\`, `..`, a NUL, control chars, or the reserved
19//! `.partial` / `.deleted` suffixes is rejected with [`TaildropError::InvalidFileName`](crate::taildrop::TaildropError::InvalidFileName). The store
20//! root is fixed at construction; all I/O is confined to it by joining only validated base names.
21
22use std::{
23    io::{self, Seek, Write},
24    path::{Path, PathBuf},
25};
26
27use tokio::io::{AsyncRead, AsyncReadExt};
28
29/// Suffix for in-progress transfers. A completed transfer is renamed off this suffix; a name
30/// ending in it is itself never accepted as a base name (Go `partialSuffix`).
31const PARTIAL_SUFFIX: &str = ".partial";
32/// Suffix Go uses to tombstone files pending deletion on platforms with async close; we reject it
33/// as a base name for parity so a sender can't create one (Go `deletedSuffix`).
34const DELETED_SUFFIX: &str = ".deleted";
35/// Maximum base-name length in bytes (Go `validateBaseName`: 255).
36const MAX_BASE_NAME_LEN: usize = 255;
37
38/// Errors from the Taildrop file store.
39#[derive(Debug)]
40pub enum TaildropError {
41    /// The requested file name is invalid (traversal, reserved suffix, empty, too long, bad runes).
42    /// Maps to peerAPI `400 Bad Request`.
43    InvalidFileName,
44    /// A transfer for this exact base name is already in progress. Maps to peerAPI `409 Conflict`.
45    FileExists,
46    /// Underlying filesystem I/O failure. Maps to peerAPI `500`.
47    Io(io::Error),
48}
49
50impl core::fmt::Display for TaildropError {
51    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52        match self {
53            TaildropError::InvalidFileName => write!(f, "invalid taildrop file name"),
54            TaildropError::FileExists => {
55                write!(f, "a transfer for this file is already in progress")
56            }
57            TaildropError::Io(e) => write!(f, "taildrop I/O error: {e}"),
58        }
59    }
60}
61
62impl std::error::Error for TaildropError {
63    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64        match self {
65            TaildropError::Io(e) => Some(e),
66            _ => None,
67        }
68    }
69}
70
71impl From<io::Error> for TaildropError {
72    fn from(e: io::Error) -> Self {
73        TaildropError::Io(e)
74    }
75}
76
77/// A waiting (fully-received) Taildrop file, as reported to the embedder. Mirrors Go
78/// `apitype.WaitingFile` (default field-name JSON marshalling: `Name`, `Size`).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct WaitingFile {
81    /// The file's base name.
82    pub name: String,
83    /// The file's size in bytes.
84    pub size: u64,
85}
86
87/// Validate a Taildrop base name, mirroring Go `taildrop.validateBaseName`.
88///
89/// Returns the name unchanged when it is a safe, single, local path component; otherwise `None`.
90/// Rejection rules (any one fails): empty or `> 255` bytes; leading/trailing ASCII space; contains
91/// a path separator (`/` or `\`), a NUL, or an ASCII control char; is `.` or `..`; equals a cleaned
92/// path other than itself (catches embedded `..`/`.` segments and absolute paths); or ends in the
93/// reserved `.partial` / `.deleted` suffixes.
94pub fn validate_base_name(name: &str) -> Option<&str> {
95    if name.is_empty() || name.len() > MAX_BASE_NAME_LEN {
96        return None;
97    }
98    if name.starts_with(' ') || name.ends_with(' ') {
99        return None;
100    }
101    if name == "." || name == ".." {
102        return None;
103    }
104    if name.ends_with(PARTIAL_SUFFIX) || name.ends_with(DELETED_SUFFIX) {
105        return None;
106    }
107    // Reject any separator, NUL, or control character outright. This is the core traversal guard:
108    // with no `/`, `\`, or `..` segment possible, the name can only ever be a leaf in the store dir.
109    for ch in name.chars() {
110        if ch == '/' || ch == '\\' || ch == '\0' || ch.is_control() {
111            return None;
112        }
113    }
114    // Defense in depth: a name that does not survive `Path` normalization as a single normal
115    // component is rejected (catches `..`, absolute paths, and any platform-specific oddity).
116    let p = Path::new(name);
117    let mut comps = p.components();
118    match (comps.next(), comps.next()) {
119        (Some(std::path::Component::Normal(c)), None) if c == name => Some(name),
120        _ => None,
121    }
122}
123
124/// Choose a non-clobbering final name for `base` within `dir`, mirroring Go `nextFilename`:
125/// `foo.txt` -> `foo (1).txt` -> `foo (2).txt` ... inserting ` (n)` before the extension. Returns
126/// the first candidate (incl. `base` itself) whose path does not yet exist. Bounded to avoid an
127/// unbounded loop on a pathological directory.
128fn next_available_name(dir: &Path, base: &str) -> String {
129    if !dir.join(base).exists() {
130        return base.to_string();
131    }
132    let (stem, ext) = match base.rsplit_once('.') {
133        // Keep the dot with the extension; an empty stem (dotfile like ".bashrc") has no split.
134        Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
135        _ => (base, String::new()),
136    };
137    for n in 1..=10_000u32 {
138        let candidate = format!("{stem} ({n}){ext}");
139        if !dir.join(&candidate).exists() {
140            return candidate;
141        }
142    }
143    // Pathological fallback: suffix with a high counter; extremely unlikely to be reached.
144    format!("{stem} (overflow){ext}")
145}
146
147/// A Taildrop file store rooted at a fixed directory. All operations are confined to this root by
148/// joining only [`validate_base_name`]-validated names.
149#[derive(Debug, Clone)]
150pub struct TaildropStore {
151    root: PathBuf,
152}
153
154impl TaildropStore {
155    /// Create a store rooted at `root`, creating the directory (and parents) if needed.
156    pub fn new(root: impl Into<PathBuf>) -> Result<Self, TaildropError> {
157        let root = root.into();
158        std::fs::create_dir_all(&root)?;
159        Ok(Self { root })
160    }
161
162    /// The partial-file path for an already-validated base name.
163    fn partial_path(&self, base: &str) -> PathBuf {
164        self.root.join(format!("{base}{PARTIAL_SUFFIX}"))
165    }
166
167    /// Receive a file named `name` from `reader`, writing to `<name>.partial` then atomically
168    /// renaming to a non-clobbering final name on success. Mirrors Go `manager.PutFile`.
169    ///
170    /// `offset` lets a resumed transfer append past already-written bytes (the partial is opened, the
171    /// write starts at `offset`, and any bytes already on disk past `offset` are truncated away).
172    /// `expected_len` is the declared total length of the completed file (the request's
173    /// `Content-Length` plus `offset`); the transfer is finalized only if exactly that many bytes are
174    /// present. Returns the total number of bytes in the completed file.
175    ///
176    /// Fail-closed: an invalid name is rejected before any path is built; an in-progress partial for
177    /// the same name yields [`TaildropError::FileExists`]; an out-of-range resume `offset` (past the
178    /// current partial length) is rejected; an I/O error mid-transfer — or a body that ends before
179    /// `expected_len` (a short/interrupted stream) — leaves the `.partial` on disk and the final name
180    /// is never created. This matches Go `feature/taildrop/send.go`, which errors when the copied
181    /// length does not equal the declared length rather than publishing a truncated file.
182    ///
183    /// The retained `.partial` is resumable only by a peer that issues a ranged retry (an `offset > 0`
184    /// PUT); a sender that always restarts at `offset == 0` will instead hit the in-progress-conflict
185    /// path ([`TaildropError::FileExists`]) until the stale partial is cleared. There is no automatic
186    /// reaper for an abandoned partial yet (Go's `fileDeleter` GCs one after ~1h); tracked separately.
187    pub async fn put_file<R>(
188        &self,
189        name: &str,
190        mut reader: R,
191        offset: u64,
192        expected_len: u64,
193    ) -> Result<u64, TaildropError>
194    where
195        R: AsyncRead + Unpin,
196    {
197        let base = validate_base_name(name).ok_or(TaildropError::InvalidFileName)?;
198        let partial = self.partial_path(base);
199
200        // A fresh transfer (offset 0) must not collide with another in-flight transfer of the same
201        // name; a resume (offset > 0) reopens the existing partial. File handles are std (the tokio
202        // `fs` feature is intentionally not enabled in this crate); the body is read async off the
203        // overlay stream and written to the blocking handle in a bounded loop.
204        let mut file = if offset == 0 {
205            match std::fs::OpenOptions::new()
206                .write(true)
207                .create_new(true)
208                .open(&partial)
209            {
210                Ok(f) => f,
211                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
212                    return Err(TaildropError::FileExists);
213                }
214                Err(e) => return Err(e.into()),
215            }
216        } else {
217            let mut f = std::fs::OpenOptions::new().write(true).open(&partial)?;
218            // Bound the resume offset to the current partial length and truncate any bytes past it,
219            // matching Go `feature/taildrop/fileops_fs.go` (`OpenWriter` rejects `offset > curr` and
220            // `Truncate(offset)`s). Without the bound a too-large offset would leave a zero-filled
221            // sparse hole; without the truncate a shorter resumed body would leave a prior attempt's
222            // stale tail past the new end. `metadata().len()` is the partial's current size.
223            let current = f.metadata()?.len();
224            if offset > current {
225                return Err(TaildropError::Io(io::Error::new(
226                    io::ErrorKind::InvalidInput,
227                    "taildrop resume offset is past the end of the partial file",
228                )));
229            }
230            f.set_len(offset)?;
231            f.seek(io::SeekFrom::Start(offset))?;
232            f
233        };
234
235        let mut copied: u64 = 0;
236        let mut buf = [0u8; 64 * 1024];
237        loop {
238            let n = reader.read(&mut buf).await?;
239            if n == 0 {
240                break;
241            }
242            // Each `write_all` only pushes the chunk into the page cache (microseconds); the
243            // genuinely blocking cost is the terminal `flush`/`sync_all`/`rename` below, which we
244            // hand to a blocking thread so a flood of concurrent transfers can't starve the tokio
245            // worker pool on fsync (see `peerapi::MAX_INFLIGHT`).
246            file.write_all(&buf[..n])?;
247            copied += n as u64;
248        }
249
250        // Length check (Go `send.go`: error when `copyLength != length`). A body that ended before the
251        // declared length — an interrupted/short stream — must NOT be finalized as a complete file;
252        // leave the `.partial` on disk (with the bytes received so far) so a Range-capable peer can
253        // resume it. `checked_add` rather than a bare `+`: `offset` is an attacker-supplied header and
254        // the bound above already rejects an `offset` past the (real, on-disk) partial length, so this
255        // cannot overflow in practice — but treat an overflow as a length mismatch rather than a panic.
256        let total = match offset.checked_add(copied) {
257            Some(t) if t == expected_len => t,
258            _ => {
259                return Err(TaildropError::Io(io::Error::new(
260                    io::ErrorKind::UnexpectedEof,
261                    format!(
262                        "taildrop body ended early: got {copied} of {expected_len} expected bytes \
263                         at offset {offset}; leaving partial for resume"
264                    ),
265                )));
266            }
267        };
268
269        // Finalize off the async runtime: `sync_all` (fsync) and `rename` are the dominant blocking
270        // operations, so run them on a blocking thread. The `File` and both paths are owned by the
271        // closure (`Send + 'static`), and `next_available_name` (which `stat`s candidates) goes with
272        // them. Fail-closed: any I/O error — or a join failure — propagates without ever publishing
273        // the final name, leaving the `.partial` in place for a later resume.
274        let root = self.root.clone();
275        let base = base.to_string();
276        tokio::task::spawn_blocking(move || -> io::Result<()> {
277            file.flush()?;
278            file.sync_all()?;
279            drop(file);
280
281            // Atomically publish under a non-clobbering final name.
282            let final_name = next_available_name(&root, &base);
283            let final_path = root.join(&final_name);
284            std::fs::rename(&partial, &final_path)?;
285            Ok(())
286        })
287        .await
288        .map_err(|join_err| {
289            // A panicked/cancelled finalize task: surface as I/O so the caller maps it to a 500 and
290            // the partial is left untouched (never publishes the final name).
291            TaildropError::Io(io::Error::other(format!(
292                "taildrop finalize task failed: {join_err}"
293            )))
294        })??;
295
296        Ok(total)
297    }
298
299    /// List fully-received (non-partial) files, sorted by name (Go `WaitingFiles`).
300    pub fn waiting_files(&self) -> Result<Vec<WaitingFile>, TaildropError> {
301        let mut out = Vec::new();
302        let entries = match std::fs::read_dir(&self.root) {
303            Ok(e) => e,
304            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
305            Err(e) => return Err(e.into()),
306        };
307        for entry in entries {
308            let entry = entry?;
309            let meta = entry.metadata()?;
310            if !meta.is_file() {
311                continue;
312            }
313            let Ok(name) = entry.file_name().into_string() else {
314                continue;
315            };
316            // Skip in-progress / tombstoned files.
317            if name.ends_with(PARTIAL_SUFFIX) || name.ends_with(DELETED_SUFFIX) {
318                continue;
319            }
320            out.push(WaitingFile {
321                name,
322                size: meta.len(),
323            });
324        }
325        out.sort_by(|a, b| a.name.cmp(&b.name));
326        Ok(out)
327    }
328
329    /// Delete a fully-received file by base name (Go `DeleteFile`). The name is validated first, so a
330    /// traversal attempt can never escape the store root.
331    pub fn delete_file(&self, name: &str) -> Result<(), TaildropError> {
332        let base = validate_base_name(name).ok_or(TaildropError::InvalidFileName)?;
333        std::fs::remove_file(self.root.join(base))?;
334        Ok(())
335    }
336
337    /// Open a fully-received file by base name for reading, returning the handle and its size (Go
338    /// `OpenFile`). The name is validated first.
339    pub fn open_file(&self, name: &str) -> Result<(std::fs::File, u64), TaildropError> {
340        let base = validate_base_name(name).ok_or(TaildropError::InvalidFileName)?;
341        let f = std::fs::File::open(self.root.join(base))?;
342        let size = f.metadata()?.len();
343        Ok((f, size))
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn tmp_root() -> PathBuf {
352        // A per-call atomic counter guarantees uniqueness across tests that run concurrently in the
353        // same binary. A timestamp alone is NOT enough: `SystemTime` resolution is coarse on some
354        // platforms, so two tests starting in the same tick would collide on one dir and stomp each
355        // other's files (the cause of intermittent taildrop-test flakiness under parallel runs).
356        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
357        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
358        let mut p = std::env::temp_dir();
359        p.push(format!("taildrop-test-{}-{n}", std::process::id()));
360        p
361    }
362
363    #[test]
364    fn validate_rejects_traversal_and_reserved() {
365        // Valid leaf names.
366        assert_eq!(validate_base_name("photo.jpg"), Some("photo.jpg"));
367        assert_eq!(
368            validate_base_name("a file with spaces.txt"),
369            Some("a file with spaces.txt")
370        );
371        assert_eq!(validate_base_name(".bashrc"), Some(".bashrc"));
372
373        // Traversal / separators.
374        assert_eq!(validate_base_name("../etc/passwd"), None);
375        assert_eq!(validate_base_name("a/b"), None);
376        assert_eq!(validate_base_name("a\\b"), None);
377        assert_eq!(validate_base_name("/abs"), None);
378        assert_eq!(validate_base_name(".."), None);
379        assert_eq!(validate_base_name("."), None);
380
381        // NUL / control.
382        assert_eq!(validate_base_name("a\0b"), None);
383        assert_eq!(validate_base_name("a\nb"), None);
384
385        // Reserved suffixes.
386        assert_eq!(validate_base_name("x.partial"), None);
387        assert_eq!(validate_base_name("x.deleted"), None);
388
389        // Edges.
390        assert_eq!(validate_base_name(""), None);
391        assert_eq!(validate_base_name(" leading"), None);
392        assert_eq!(validate_base_name("trailing "), None);
393        assert_eq!(validate_base_name(&"a".repeat(256)), None);
394        assert_eq!(
395            validate_base_name(&"a".repeat(255)).map(|s| s.len()),
396            Some(255)
397        );
398    }
399
400    #[tokio::test]
401    async fn put_file_writes_then_atomically_renames() {
402        let root = tmp_root();
403        let store = TaildropStore::new(&root).unwrap();
404
405        let data = b"hello taildrop";
406        let n = store
407            .put_file("greeting.txt", &data[..], 0, data.len() as u64)
408            .await
409            .unwrap();
410        assert_eq!(n, data.len() as u64);
411
412        // The final file exists; no .partial remains.
413        let body = std::fs::read(root.join("greeting.txt")).unwrap();
414        assert_eq!(body, data);
415        assert!(!root.join("greeting.txt.partial").exists());
416
417        let wf = store.waiting_files().unwrap();
418        assert_eq!(wf.len(), 1);
419        assert_eq!(wf[0].name, "greeting.txt");
420        assert_eq!(wf[0].size, data.len() as u64);
421
422        std::fs::remove_dir_all(&root).ok();
423    }
424
425    #[tokio::test]
426    async fn put_file_resumes_from_offset() {
427        let root = tmp_root();
428        let store = TaildropStore::new(&root).unwrap();
429
430        // Pre-write a prefix into the `.partial` directly, simulating bytes already received by an
431        // earlier (interrupted) transfer.
432        let prefix = b"the first half ";
433        let partial = root.join("resume.txt.partial");
434        std::fs::write(&partial, prefix).unwrap();
435
436        // Resume at offset == the prefix length: `put_file` opens the existing partial, seeks past
437        // the prefix, and appends the rest.
438        let rest = b"and the second half";
439        let total = store
440            .put_file(
441                "resume.txt",
442                &rest[..],
443                prefix.len() as u64,
444                (prefix.len() + rest.len()) as u64,
445            )
446            .await
447            .unwrap();
448
449        // The returned count is offset + freshly-copied bytes, and the final file is the prefix and
450        // the resumed bytes concatenated (the seek positioned the write correctly).
451        assert_eq!(total, (prefix.len() + rest.len()) as u64);
452        let body = std::fs::read(root.join("resume.txt")).unwrap();
453        let mut expected = prefix.to_vec();
454        expected.extend_from_slice(rest);
455        assert_eq!(body, expected);
456        assert!(!partial.exists());
457
458        std::fs::remove_dir_all(&root).ok();
459    }
460
461    #[tokio::test]
462    async fn put_file_short_body_leaves_partial_not_truncated_final() {
463        // F2: a body that ends before the declared length must NOT be finalized as a complete (but
464        // truncated) file under the real name. Go errors when copyLength != length; we leave the
465        // `.partial` in place for resume.
466        let root = tmp_root();
467        let store = TaildropStore::new(&root).unwrap();
468
469        // Reader yields 5 bytes but we declare 10 expected (a short/interrupted stream).
470        let err = store
471            .put_file("short.txt", &b"world"[..], 0, 10)
472            .await
473            .unwrap_err();
474        assert!(
475            matches!(err, TaildropError::Io(ref e) if e.kind() == io::ErrorKind::UnexpectedEof),
476            "a short body must error, got {err:?}"
477        );
478        // The final name was NEVER created; the partial remains with the bytes received so far.
479        assert!(!root.join("short.txt").exists(), "no truncated final file");
480        let partial = std::fs::read(root.join("short.txt.partial")).unwrap();
481        assert_eq!(
482            partial, b"world",
483            "partial holds the received prefix for resume"
484        );
485        assert!(store.waiting_files().unwrap().is_empty());
486
487        std::fs::remove_dir_all(&root).ok();
488    }
489
490    #[tokio::test]
491    async fn put_file_resume_offset_past_end_is_rejected() {
492        // F3: a resume offset beyond the current partial length must be rejected (Go errors
493        // "offset out of range"), not produce a zero-filled sparse hole.
494        let root = tmp_root();
495        let store = TaildropStore::new(&root).unwrap();
496        std::fs::write(root.join("sparse.txt.partial"), b"abc").unwrap(); // 3 bytes on disk
497
498        let err = store
499            .put_file("sparse.txt", &b"xyz"[..], 99, 102)
500            .await
501            .unwrap_err();
502        assert!(
503            matches!(err, TaildropError::Io(ref e) if e.kind() == io::ErrorKind::InvalidInput),
504            "offset past end must be rejected, got {err:?}"
505        );
506        // The partial is untouched (still 3 bytes), no final file.
507        assert_eq!(
508            std::fs::read(root.join("sparse.txt.partial")).unwrap(),
509            b"abc"
510        );
511        assert!(!root.join("sparse.txt").exists());
512
513        std::fs::remove_dir_all(&root).ok();
514    }
515
516    #[tokio::test]
517    async fn put_file_resume_truncates_stale_tail() {
518        // F3: resuming at an offset LESS than the current partial length must truncate the bytes
519        // past the offset (Go `Truncate(offset)`), so a stale tail from a prior attempt cannot
520        // survive past the newly-written end.
521        let root = tmp_root();
522        let store = TaildropStore::new(&root).unwrap();
523        // A prior attempt left 20 bytes; we resume at offset 5 with a 3-byte tail ⇒ final is 8 bytes.
524        std::fs::write(root.join("retry.txt.partial"), b"KEEPme-STALE-TAILxxx").unwrap();
525
526        let total = store
527            .put_file("retry.txt", &b"NEW"[..], 5, 8)
528            .await
529            .unwrap();
530        assert_eq!(total, 8);
531        let body = std::fs::read(root.join("retry.txt")).unwrap();
532        assert_eq!(
533            body, b"KEEPmNEW",
534            "bytes past offset 5 truncated, then NEW appended"
535        );
536
537        std::fs::remove_dir_all(&root).ok();
538    }
539
540    #[tokio::test]
541    async fn put_file_conflict_picks_non_clobbering_name() {
542        let root = tmp_root();
543        let store = TaildropStore::new(&root).unwrap();
544
545        store
546            .put_file("dup.txt", &b"first"[..], 0, 5)
547            .await
548            .unwrap();
549        store
550            .put_file("dup.txt", &b"second"[..], 0, 6)
551            .await
552            .unwrap();
553        store
554            .put_file("dup.txt", &b"third"[..], 0, 5)
555            .await
556            .unwrap();
557
558        // Original plus two non-clobbering renames.
559        assert!(root.join("dup.txt").exists());
560        assert!(root.join("dup (1).txt").exists());
561        assert!(root.join("dup (2).txt").exists());
562
563        let wf = store.waiting_files().unwrap();
564        assert_eq!(wf.len(), 3);
565
566        std::fs::remove_dir_all(&root).ok();
567    }
568
569    #[tokio::test]
570    async fn put_file_in_progress_partial_is_conflict() {
571        let root = tmp_root();
572        let store = TaildropStore::new(&root).unwrap();
573
574        // Simulate an in-flight transfer by pre-creating the .partial file.
575        std::fs::write(root.join("busy.txt.partial"), b"partial").unwrap();
576
577        let err = store
578            .put_file("busy.txt", &b"x"[..], 0, 1)
579            .await
580            .unwrap_err();
581        assert!(matches!(err, TaildropError::FileExists));
582
583        std::fs::remove_dir_all(&root).ok();
584    }
585
586    #[tokio::test]
587    async fn put_file_rejects_bad_name_before_any_io() {
588        let root = tmp_root();
589        let store = TaildropStore::new(&root).unwrap();
590
591        let err = store
592            .put_file("../escape", &b"x"[..], 0, 1)
593            .await
594            .unwrap_err();
595        assert!(matches!(err, TaildropError::InvalidFileName));
596        // Nothing was written anywhere.
597        assert!(store.waiting_files().unwrap().is_empty());
598
599        std::fs::remove_dir_all(&root).ok();
600    }
601
602    #[tokio::test]
603    async fn delete_and_open_roundtrip() {
604        let root = tmp_root();
605        let store = TaildropStore::new(&root).unwrap();
606
607        store.put_file("doc.bin", &b"abc"[..], 0, 3).await.unwrap();
608        let (_f, size) = store.open_file("doc.bin").unwrap();
609        assert_eq!(size, 3);
610
611        store.delete_file("doc.bin").unwrap();
612        assert!(store.waiting_files().unwrap().is_empty());
613
614        // Traversal can't reach outside the root.
615        assert!(matches!(
616            store.delete_file("../../etc/passwd"),
617            Err(TaildropError::InvalidFileName)
618        ));
619
620        std::fs::remove_dir_all(&root).ok();
621    }
622
623    #[test]
624    fn next_available_name_inserts_before_extension() {
625        let root = tmp_root();
626        std::fs::create_dir_all(&root).unwrap();
627        assert_eq!(next_available_name(&root, "a.txt"), "a.txt");
628        std::fs::write(root.join("a.txt"), b"x").unwrap();
629        assert_eq!(next_available_name(&root, "a.txt"), "a (1).txt");
630        std::fs::write(root.join("a (1).txt"), b"x").unwrap();
631        assert_eq!(next_available_name(&root, "a.txt"), "a (2).txt");
632        // Dotfile (no real extension) appends at end.
633        std::fs::write(root.join(".env"), b"x").unwrap();
634        assert_eq!(next_available_name(&root, ".env"), ".env (1)");
635        std::fs::remove_dir_all(&root).ok();
636    }
637}