1use std::{
23 io::{self, Seek, Write},
24 path::{Path, PathBuf},
25};
26
27use tokio::io::{AsyncRead, AsyncReadExt};
28
29const PARTIAL_SUFFIX: &str = ".partial";
32const DELETED_SUFFIX: &str = ".deleted";
35const MAX_BASE_NAME_LEN: usize = 255;
37
38#[derive(Debug)]
40pub enum TaildropError {
41 InvalidFileName,
44 FileExists,
46 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#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct WaitingFile {
81 pub name: String,
83 pub size: u64,
85}
86
87pub 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 for ch in name.chars() {
110 if ch == '/' || ch == '\\' || ch == '\0' || ch.is_control() {
111 return None;
112 }
113 }
114 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
124fn 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 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 format!("{stem} (overflow){ext}")
145}
146
147#[derive(Debug, Clone)]
150pub struct TaildropStore {
151 root: PathBuf,
152}
153
154impl TaildropStore {
155 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 fn partial_path(&self, base: &str) -> PathBuf {
164 self.root.join(format!("{base}{PARTIAL_SUFFIX}"))
165 }
166
167 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 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 file.write_all(&buf[..n])?;
222 copied += n as u64;
223 }
224
225 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 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 TaildropError::Io(io::Error::other(format!(
248 "taildrop finalize task failed: {join_err}"
249 )))
250 })??;
251
252 Ok(offset + copied)
253 }
254
255 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 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 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 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 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 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 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 assert_eq!(validate_base_name("a\0b"), None);
339 assert_eq!(validate_base_name("a\nb"), None);
340
341 assert_eq!(validate_base_name("x.partial"), None);
343 assert_eq!(validate_base_name("x.deleted"), None);
344
345 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 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 let prefix = b"the first half ";
386 let partial = root.join("resume.txt.partial");
387 std::fs::write(&partial, prefix).unwrap();
388
389 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 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 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 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 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 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 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}