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 and
171    /// the write starts at `offset`). Returns the total number of bytes in the completed file.
172    ///
173    /// Fail-closed: an invalid name is rejected before any path is built; an in-progress partial for
174    /// the same name yields [`TaildropError::FileExists`]; an I/O error mid-transfer leaves the
175    /// `.partial` in place (for resume) and the final name is never created.
176    pub async fn put_file<R>(
177        &self,
178        name: &str,
179        mut reader: R,
180        offset: u64,
181    ) -> Result<u64, TaildropError>
182    where
183        R: AsyncRead + Unpin,
184    {
185        let base = validate_base_name(name).ok_or(TaildropError::InvalidFileName)?;
186        let partial = self.partial_path(base);
187
188        // A fresh transfer (offset 0) must not collide with another in-flight transfer of the same
189        // name; a resume (offset > 0) reopens the existing partial. File handles are std (the tokio
190        // `fs` feature is intentionally not enabled in this crate); the body is read async off the
191        // overlay stream and written to the blocking handle in a bounded loop.
192        let mut file = if offset == 0 {
193            match std::fs::OpenOptions::new()
194                .write(true)
195                .create_new(true)
196                .open(&partial)
197            {
198                Ok(f) => f,
199                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
200                    return Err(TaildropError::FileExists);
201                }
202                Err(e) => return Err(e.into()),
203            }
204        } else {
205            let mut f = std::fs::OpenOptions::new().write(true).open(&partial)?;
206            f.seek(io::SeekFrom::Start(offset))?;
207            f
208        };
209
210        let mut copied: u64 = 0;
211        let mut buf = [0u8; 64 * 1024];
212        loop {
213            let n = reader.read(&mut buf).await?;
214            if n == 0 {
215                break;
216            }
217            // Each `write_all` only pushes the chunk into the page cache (microseconds); the
218            // genuinely blocking cost is the terminal `flush`/`sync_all`/`rename` below, which we
219            // hand to a blocking thread so a flood of concurrent transfers can't starve the tokio
220            // worker pool on fsync (see `peerapi::MAX_INFLIGHT`).
221            file.write_all(&buf[..n])?;
222            copied += n as u64;
223        }
224
225        // Finalize off the async runtime: `sync_all` (fsync) and `rename` are the dominant blocking
226        // operations, so run them on a blocking thread. The `File` and both paths are owned by the
227        // closure (`Send + 'static`), and `next_available_name` (which `stat`s candidates) goes with
228        // them. Fail-closed: any I/O error — or a join failure — propagates without ever publishing
229        // the final name, leaving the `.partial` in place for a later resume.
230        let root = self.root.clone();
231        let base = base.to_string();
232        tokio::task::spawn_blocking(move || -> io::Result<()> {
233            file.flush()?;
234            file.sync_all()?;
235            drop(file);
236
237            // Atomically publish under a non-clobbering final name.
238            let final_name = next_available_name(&root, &base);
239            let final_path = root.join(&final_name);
240            std::fs::rename(&partial, &final_path)?;
241            Ok(())
242        })
243        .await
244        .map_err(|join_err| {
245            // A panicked/cancelled finalize task: surface as I/O so the caller maps it to a 500 and
246            // the partial is left untouched (never publishes the final name).
247            TaildropError::Io(io::Error::other(format!(
248                "taildrop finalize task failed: {join_err}"
249            )))
250        })??;
251
252        Ok(offset + copied)
253    }
254
255    /// List fully-received (non-partial) files, sorted by name (Go `WaitingFiles`).
256    pub fn waiting_files(&self) -> Result<Vec<WaitingFile>, TaildropError> {
257        let mut out = Vec::new();
258        let entries = match std::fs::read_dir(&self.root) {
259            Ok(e) => e,
260            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
261            Err(e) => return Err(e.into()),
262        };
263        for entry in entries {
264            let entry = entry?;
265            let meta = entry.metadata()?;
266            if !meta.is_file() {
267                continue;
268            }
269            let Ok(name) = entry.file_name().into_string() else {
270                continue;
271            };
272            // Skip in-progress / tombstoned files.
273            if name.ends_with(PARTIAL_SUFFIX) || name.ends_with(DELETED_SUFFIX) {
274                continue;
275            }
276            out.push(WaitingFile {
277                name,
278                size: meta.len(),
279            });
280        }
281        out.sort_by(|a, b| a.name.cmp(&b.name));
282        Ok(out)
283    }
284
285    /// Delete a fully-received file by base name (Go `DeleteFile`). The name is validated first, so a
286    /// traversal attempt can never escape the store root.
287    pub fn delete_file(&self, name: &str) -> Result<(), TaildropError> {
288        let base = validate_base_name(name).ok_or(TaildropError::InvalidFileName)?;
289        std::fs::remove_file(self.root.join(base))?;
290        Ok(())
291    }
292
293    /// Open a fully-received file by base name for reading, returning the handle and its size (Go
294    /// `OpenFile`). The name is validated first.
295    pub fn open_file(&self, name: &str) -> Result<(std::fs::File, u64), TaildropError> {
296        let base = validate_base_name(name).ok_or(TaildropError::InvalidFileName)?;
297        let f = std::fs::File::open(self.root.join(base))?;
298        let size = f.metadata()?.len();
299        Ok((f, size))
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn tmp_root() -> PathBuf {
308        // A per-call atomic counter guarantees uniqueness across tests that run concurrently in the
309        // same binary. A timestamp alone is NOT enough: `SystemTime` resolution is coarse on some
310        // platforms, so two tests starting in the same tick would collide on one dir and stomp each
311        // other's files (the cause of intermittent taildrop-test flakiness under parallel runs).
312        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
313        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
314        let mut p = std::env::temp_dir();
315        p.push(format!("taildrop-test-{}-{n}", std::process::id()));
316        p
317    }
318
319    #[test]
320    fn validate_rejects_traversal_and_reserved() {
321        // Valid leaf names.
322        assert_eq!(validate_base_name("photo.jpg"), Some("photo.jpg"));
323        assert_eq!(
324            validate_base_name("a file with spaces.txt"),
325            Some("a file with spaces.txt")
326        );
327        assert_eq!(validate_base_name(".bashrc"), Some(".bashrc"));
328
329        // Traversal / separators.
330        assert_eq!(validate_base_name("../etc/passwd"), None);
331        assert_eq!(validate_base_name("a/b"), None);
332        assert_eq!(validate_base_name("a\\b"), None);
333        assert_eq!(validate_base_name("/abs"), None);
334        assert_eq!(validate_base_name(".."), None);
335        assert_eq!(validate_base_name("."), None);
336
337        // NUL / control.
338        assert_eq!(validate_base_name("a\0b"), None);
339        assert_eq!(validate_base_name("a\nb"), None);
340
341        // Reserved suffixes.
342        assert_eq!(validate_base_name("x.partial"), None);
343        assert_eq!(validate_base_name("x.deleted"), None);
344
345        // Edges.
346        assert_eq!(validate_base_name(""), None);
347        assert_eq!(validate_base_name(" leading"), None);
348        assert_eq!(validate_base_name("trailing "), None);
349        assert_eq!(validate_base_name(&"a".repeat(256)), None);
350        assert_eq!(
351            validate_base_name(&"a".repeat(255)).map(|s| s.len()),
352            Some(255)
353        );
354    }
355
356    #[tokio::test]
357    async fn put_file_writes_then_atomically_renames() {
358        let root = tmp_root();
359        let store = TaildropStore::new(&root).unwrap();
360
361        let data = b"hello taildrop";
362        let n = store.put_file("greeting.txt", &data[..], 0).await.unwrap();
363        assert_eq!(n, data.len() as u64);
364
365        // The final file exists; no .partial remains.
366        let body = std::fs::read(root.join("greeting.txt")).unwrap();
367        assert_eq!(body, data);
368        assert!(!root.join("greeting.txt.partial").exists());
369
370        let wf = store.waiting_files().unwrap();
371        assert_eq!(wf.len(), 1);
372        assert_eq!(wf[0].name, "greeting.txt");
373        assert_eq!(wf[0].size, data.len() as u64);
374
375        std::fs::remove_dir_all(&root).ok();
376    }
377
378    #[tokio::test]
379    async fn put_file_resumes_from_offset() {
380        let root = tmp_root();
381        let store = TaildropStore::new(&root).unwrap();
382
383        // Pre-write a prefix into the `.partial` directly, simulating bytes already received by an
384        // earlier (interrupted) transfer.
385        let prefix = b"the first half ";
386        let partial = root.join("resume.txt.partial");
387        std::fs::write(&partial, prefix).unwrap();
388
389        // Resume at offset == the prefix length: `put_file` opens the existing partial, seeks past
390        // the prefix, and appends the rest.
391        let rest = b"and the second half";
392        let total = store
393            .put_file("resume.txt", &rest[..], prefix.len() as u64)
394            .await
395            .unwrap();
396
397        // The returned count is offset + freshly-copied bytes, and the final file is the prefix and
398        // the resumed bytes concatenated (the seek positioned the write correctly).
399        assert_eq!(total, (prefix.len() + rest.len()) as u64);
400        let body = std::fs::read(root.join("resume.txt")).unwrap();
401        let mut expected = prefix.to_vec();
402        expected.extend_from_slice(rest);
403        assert_eq!(body, expected);
404        assert!(!partial.exists());
405
406        std::fs::remove_dir_all(&root).ok();
407    }
408
409    #[tokio::test]
410    async fn put_file_conflict_picks_non_clobbering_name() {
411        let root = tmp_root();
412        let store = TaildropStore::new(&root).unwrap();
413
414        store.put_file("dup.txt", &b"first"[..], 0).await.unwrap();
415        store.put_file("dup.txt", &b"second"[..], 0).await.unwrap();
416        store.put_file("dup.txt", &b"third"[..], 0).await.unwrap();
417
418        // Original plus two non-clobbering renames.
419        assert!(root.join("dup.txt").exists());
420        assert!(root.join("dup (1).txt").exists());
421        assert!(root.join("dup (2).txt").exists());
422
423        let wf = store.waiting_files().unwrap();
424        assert_eq!(wf.len(), 3);
425
426        std::fs::remove_dir_all(&root).ok();
427    }
428
429    #[tokio::test]
430    async fn put_file_in_progress_partial_is_conflict() {
431        let root = tmp_root();
432        let store = TaildropStore::new(&root).unwrap();
433
434        // Simulate an in-flight transfer by pre-creating the .partial file.
435        std::fs::write(root.join("busy.txt.partial"), b"partial").unwrap();
436
437        let err = store.put_file("busy.txt", &b"x"[..], 0).await.unwrap_err();
438        assert!(matches!(err, TaildropError::FileExists));
439
440        std::fs::remove_dir_all(&root).ok();
441    }
442
443    #[tokio::test]
444    async fn put_file_rejects_bad_name_before_any_io() {
445        let root = tmp_root();
446        let store = TaildropStore::new(&root).unwrap();
447
448        let err = store.put_file("../escape", &b"x"[..], 0).await.unwrap_err();
449        assert!(matches!(err, TaildropError::InvalidFileName));
450        // Nothing was written anywhere.
451        assert!(store.waiting_files().unwrap().is_empty());
452
453        std::fs::remove_dir_all(&root).ok();
454    }
455
456    #[tokio::test]
457    async fn delete_and_open_roundtrip() {
458        let root = tmp_root();
459        let store = TaildropStore::new(&root).unwrap();
460
461        store.put_file("doc.bin", &b"abc"[..], 0).await.unwrap();
462        let (_f, size) = store.open_file("doc.bin").unwrap();
463        assert_eq!(size, 3);
464
465        store.delete_file("doc.bin").unwrap();
466        assert!(store.waiting_files().unwrap().is_empty());
467
468        // Traversal can't reach outside the root.
469        assert!(matches!(
470            store.delete_file("../../etc/passwd"),
471            Err(TaildropError::InvalidFileName)
472        ));
473
474        std::fs::remove_dir_all(&root).ok();
475    }
476
477    #[test]
478    fn next_available_name_inserts_before_extension() {
479        let root = tmp_root();
480        std::fs::create_dir_all(&root).unwrap();
481        assert_eq!(next_available_name(&root, "a.txt"), "a.txt");
482        std::fs::write(root.join("a.txt"), b"x").unwrap();
483        assert_eq!(next_available_name(&root, "a.txt"), "a (1).txt");
484        std::fs::write(root.join("a (1).txt"), b"x").unwrap();
485        assert_eq!(next_available_name(&root, "a.txt"), "a (2).txt");
486        // Dotfile (no real extension) appends at end.
487        std::fs::write(root.join(".env"), b"x").unwrap();
488        assert_eq!(next_available_name(&root, ".env"), ".env (1)");
489        std::fs::remove_dir_all(&root).ok();
490    }
491}