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}