1use percent_encoding::percent_decode_str;
8use std::collections::{HashMap, HashSet};
9use std::fs::{self, File};
10use std::io::{self, Write};
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15const UPLOAD_SESSION_TIMEOUT: Duration = Duration::from_secs(3600);
18
19#[non_exhaustive]
21#[derive(Debug, thiserror::Error)]
22pub enum ChunkedUploadError {
23 #[error("IO error: {0}")]
25 Io(#[from] io::Error),
26 #[error("Upload session not found: {0}")]
28 SessionNotFound(String),
29 #[error("Invalid chunk: expected {expected}, got {actual}")]
31 InvalidChunk {
32 expected: usize,
34 actual: usize,
36 },
37 #[error("Chunk out of order: expected {expected}, got {actual}")]
39 ChunkOutOfOrder {
40 expected: usize,
42 actual: usize,
44 },
45 #[error("Upload already completed")]
47 AlreadyCompleted,
48 #[error("Checksum mismatch")]
50 ChecksumMismatch,
51 #[error("Duplicate chunk: chunk {chunk_number} has already been uploaded")]
53 DuplicateChunk {
54 chunk_number: usize,
56 },
57}
58
59#[derive(Debug, Clone)]
61pub struct UploadProgress {
62 pub bytes_uploaded: usize,
64 pub total_bytes: usize,
66 pub percentage: f64,
68 pub chunks_uploaded: usize,
70 pub total_chunks: usize,
72 pub started_at: Instant,
74 pub estimated_time_remaining: Option<f64>,
76 pub upload_speed: f64,
78}
79
80impl UploadProgress {
81 fn new(total_bytes: usize, total_chunks: usize) -> Self {
83 Self {
84 bytes_uploaded: 0,
85 total_bytes,
86 percentage: 0.0,
87 chunks_uploaded: 0,
88 total_chunks,
89 started_at: Instant::now(),
90 estimated_time_remaining: None,
91 upload_speed: 0.0,
92 }
93 }
94
95 fn update(&mut self, chunk_size: usize) {
97 self.chunks_uploaded += 1;
98 self.bytes_uploaded += chunk_size;
99
100 if self.chunks_uploaded >= self.total_chunks {
104 self.total_bytes = self.bytes_uploaded;
105 }
106
107 self.percentage = if self.total_bytes > 0 {
108 (self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0
109 } else {
110 0.0
111 };
112
113 let elapsed = self.started_at.elapsed().as_secs_f64();
115 if elapsed > 0.0 {
116 self.upload_speed = self.bytes_uploaded as f64 / elapsed;
117
118 let bytes_remaining = self.total_bytes.saturating_sub(self.bytes_uploaded);
120 if self.upload_speed > 0.0 {
121 self.estimated_time_remaining = Some(bytes_remaining as f64 / self.upload_speed);
122 }
123 }
124 }
125
126 pub fn is_complete(&self) -> bool {
128 self.chunks_uploaded >= self.total_chunks
129 }
130
131 pub fn formatted_speed(&self) -> String {
133 if self.upload_speed < 1024.0 {
134 format!("{:.2} B/s", self.upload_speed)
135 } else if self.upload_speed < 1024.0 * 1024.0 {
136 format!("{:.2} KB/s", self.upload_speed / 1024.0)
137 } else {
138 format!("{:.2} MB/s", self.upload_speed / (1024.0 * 1024.0))
139 }
140 }
141
142 pub fn formatted_eta(&self) -> String {
144 match self.estimated_time_remaining {
145 Some(seconds) => {
146 let mins = (seconds / 60.0) as u64;
147 let secs = (seconds % 60.0) as u64;
148 if mins > 0 {
149 format!("{}m {}s", mins, secs)
150 } else {
151 format!("{}s", secs)
152 }
153 }
154 None => "Unknown".to_string(),
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct ChunkedUploadSession {
162 pub session_id: String,
164 pub filename: String,
166 pub total_size: usize,
168 pub chunk_size: usize,
170 pub total_chunks: usize,
172 pub received_chunks: usize,
174 received_chunk_numbers: HashSet<usize>,
176 pub temp_dir: PathBuf,
178 pub completed: bool,
180 progress: UploadProgress,
182 created_at: Instant,
184}
185
186impl ChunkedUploadSession {
187 pub fn new(
191 session_id: String,
192 filename: String,
193 total_size: usize,
194 chunk_size: usize,
195 temp_dir: PathBuf,
196 ) -> Result<Self, ChunkedUploadError> {
197 if chunk_size == 0 {
198 return Err(ChunkedUploadError::InvalidChunk {
199 expected: 1,
200 actual: 0,
201 });
202 }
203 let total_chunks = total_size.div_ceil(chunk_size);
204 Ok(Self {
205 session_id,
206 filename,
207 total_size,
208 chunk_size,
209 total_chunks,
210 received_chunks: 0,
211 received_chunk_numbers: HashSet::new(),
212 temp_dir,
213 completed: false,
214 progress: UploadProgress::new(total_size, total_chunks),
215 created_at: Instant::now(),
216 })
217 }
218
219 pub fn progress(&self) -> f64 {
221 self.progress.percentage
222 }
223
224 pub fn get_progress(&self) -> &UploadProgress {
243 &self.progress
244 }
245
246 #[doc(hidden)]
250 pub fn update_progress(&mut self, chunk_size: usize) {
251 self.progress.update(chunk_size);
252 }
253
254 pub fn is_complete(&self) -> bool {
256 self.completed || self.received_chunks >= self.total_chunks
257 }
258
259 fn chunk_path(&self, chunk_number: usize) -> PathBuf {
264 self.temp_dir
265 .join(format!("{}_{}.chunk", self.session_id, chunk_number))
266 }
267}
268
269pub struct ChunkedUploadManager {
271 sessions: Arc<Mutex<HashMap<String, ChunkedUploadSession>>>,
272 temp_base_dir: PathBuf,
273}
274
275impl ChunkedUploadManager {
276 pub fn new(temp_base_dir: PathBuf) -> Self {
287 Self {
288 sessions: Arc::new(Mutex::new(HashMap::new())),
289 temp_base_dir,
290 }
291 }
292
293 pub fn start_session(
311 &self,
312 session_id: String,
313 filename: String,
314 total_size: usize,
315 chunk_size: usize,
316 ) -> Result<ChunkedUploadSession, ChunkedUploadError> {
317 self.cleanup_expired_sessions();
319
320 let decoded = percent_decode_str(&session_id).decode_utf8_lossy();
325 for candidate in [session_id.as_str(), decoded.as_ref()] {
326 if candidate.is_empty()
327 || candidate.contains('/')
328 || candidate.contains('\\')
329 || candidate.contains('\0')
330 || candidate.contains("..")
331 {
332 return Err(ChunkedUploadError::SessionNotFound(
333 "Invalid session ID".to_string(),
334 ));
335 }
336 }
337 let temp_dir = self.temp_base_dir.join(&session_id);
338 fs::create_dir_all(&temp_dir)?;
339
340 let session = ChunkedUploadSession::new(
341 session_id.clone(),
342 filename,
343 total_size,
344 chunk_size,
345 temp_dir,
346 )?;
347
348 let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
349 sessions.insert(session_id, session.clone());
350
351 Ok(session)
352 }
353
354 pub fn upload_chunk(
369 &self,
370 session_id: &str,
371 chunk_number: usize,
372 data: &[u8],
373 ) -> Result<ChunkedUploadSession, ChunkedUploadError> {
374 self.cleanup_expired_sessions();
376
377 let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
378 let session = sessions
379 .get_mut(session_id)
380 .ok_or_else(|| ChunkedUploadError::SessionNotFound(session_id.to_string()))?;
381
382 if session.completed {
383 return Err(ChunkedUploadError::AlreadyCompleted);
384 }
385
386 if chunk_number >= session.total_chunks {
388 return Err(ChunkedUploadError::InvalidChunk {
389 expected: session.total_chunks - 1,
390 actual: chunk_number,
391 });
392 }
393
394 if session.received_chunk_numbers.contains(&chunk_number) {
396 return Err(ChunkedUploadError::DuplicateChunk { chunk_number });
397 }
398
399 let chunk_path = session.chunk_path(chunk_number);
401 let mut file = File::create(chunk_path)?;
402 file.write_all(data)?;
403
404 session.received_chunk_numbers.insert(chunk_number);
405 session.received_chunks = session.received_chunk_numbers.len();
406 session.update_progress(data.len());
407
408 if session.is_complete() {
409 session.completed = true;
410 }
411
412 Ok(session.clone())
413 }
414
415 pub fn assemble_chunks(
427 &self,
428 session_id: &str,
429 output_path: PathBuf,
430 ) -> Result<PathBuf, ChunkedUploadError> {
431 let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
432 let session = sessions
433 .get(session_id)
434 .ok_or_else(|| ChunkedUploadError::SessionNotFound(session_id.to_string()))?;
435
436 if !session.is_complete() {
437 return Err(ChunkedUploadError::InvalidChunk {
438 expected: session.total_chunks,
439 actual: session.received_chunks,
440 });
441 }
442
443 let mut output_file = File::create(&output_path)?;
445
446 for i in 0..session.total_chunks {
448 let chunk_path = session.chunk_path(i);
449 let chunk_data = fs::read(&chunk_path)?;
450 output_file.write_all(&chunk_data)?;
451 }
452
453 Ok(output_path)
454 }
455
456 pub fn cleanup_session(&self, session_id: &str) -> Result<(), ChunkedUploadError> {
468 let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
469 if let Some(session) = sessions.remove(session_id)
470 && session.temp_dir.exists()
471 {
472 fs::remove_dir_all(session.temp_dir)?;
473 }
474 Ok(())
475 }
476
477 pub fn get_session(&self, session_id: &str) -> Option<ChunkedUploadSession> {
479 let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
480 sessions.get(session_id).cloned()
481 }
482
483 pub fn list_sessions(&self) -> Vec<ChunkedUploadSession> {
485 let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
486 sessions.values().cloned().collect()
487 }
488
489 pub fn cleanup_expired_sessions(&self) {
495 let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
496 sessions.retain(|_, session| {
497 let expired = session.created_at.elapsed() >= UPLOAD_SESSION_TIMEOUT;
498 if expired && session.temp_dir.exists() {
499 let _ = fs::remove_dir_all(&session.temp_dir);
500 }
501 !expired
502 });
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_session_creation() {
512 let session = ChunkedUploadSession::new(
513 "test123".to_string(),
514 "file.bin".to_string(),
515 1000,
516 100,
517 PathBuf::from("/tmp"),
518 )
519 .unwrap();
520
521 assert_eq!(session.session_id, "test123");
522 assert_eq!(session.filename, "file.bin");
523 assert_eq!(session.total_size, 1000);
524 assert_eq!(session.chunk_size, 100);
525 assert_eq!(session.total_chunks, 10);
526 assert_eq!(session.received_chunks, 0);
527 assert!(!session.completed);
528 }
529
530 #[test]
531 fn test_session_progress() {
532 let mut session = ChunkedUploadSession::new(
533 "test123".to_string(),
534 "file.bin".to_string(),
535 1000,
536 100,
537 PathBuf::from("/tmp"),
538 )
539 .unwrap();
540
541 assert_eq!(session.progress(), 0.0);
542
543 for _ in 0..5 {
545 session.update_progress(100);
546 }
547 assert_eq!(session.progress(), 50.0);
548
549 for _ in 0..5 {
550 session.update_progress(100);
551 }
552 assert_eq!(session.progress(), 100.0);
553 }
554
555 #[test]
556 fn test_manager_creation() {
557 let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks"));
558 assert_eq!(manager.list_sessions().len(), 0);
559 }
560
561 #[test]
562 fn test_start_session() {
563 let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks"));
564 let session = manager
565 .start_session("session1".to_string(), "file.bin".to_string(), 1000, 100)
566 .unwrap();
567
568 assert_eq!(session.session_id, "session1");
569 assert_eq!(session.total_chunks, 10);
570 assert_eq!(manager.list_sessions().len(), 1);
571 }
572
573 #[test]
574 fn test_upload_chunk() {
575 let temp_dir = PathBuf::from("/tmp/test_chunks_upload");
576 let manager = ChunkedUploadManager::new(temp_dir.clone());
577
578 manager
579 .start_session("session2".to_string(), "file.bin".to_string(), 300, 100)
580 .unwrap();
581
582 let chunk_data = vec![0u8; 100];
583 let result = manager.upload_chunk("session2", 0, &chunk_data);
584 assert!(result.is_ok());
585
586 let session = manager.get_session("session2").unwrap();
587 assert_eq!(session.received_chunks, 1);
588
589 manager.cleanup_session("session2").unwrap();
590 }
591
592 #[test]
593 fn test_invalid_session() {
594 let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks"));
595 let chunk_data = vec![0u8; 100];
596 let result = manager.upload_chunk("nonexistent", 0, &chunk_data);
597
598 assert!(result.is_err());
599 if let Err(ChunkedUploadError::SessionNotFound(id)) = result {
600 assert_eq!(id, "nonexistent");
601 } else {
602 panic!("Expected SessionNotFound error");
603 }
604 }
605
606 #[test]
607 fn test_chunk_assembly() {
608 let temp_dir = PathBuf::from("/tmp/test_chunks_assembly");
609 let manager = ChunkedUploadManager::new(temp_dir.clone());
610
611 manager
612 .start_session("session3".to_string(), "file.bin".to_string(), 300, 100)
613 .unwrap();
614
615 for i in 0..3 {
617 let chunk_data = vec![i as u8; 100];
618 manager.upload_chunk("session3", i, &chunk_data).unwrap();
619 }
620
621 let output_path = temp_dir.join("assembled.bin");
622 let result = manager.assemble_chunks("session3", output_path.clone());
623 assert!(result.is_ok());
624
625 assert!(output_path.exists());
626 let content = fs::read(&output_path).unwrap();
627 assert_eq!(content.len(), 300);
628
629 fs::remove_file(output_path).unwrap();
631 manager.cleanup_session("session3").unwrap();
632 }
633
634 #[test]
635 fn test_session_completion() {
636 let temp_dir = PathBuf::from("/tmp/test_chunks_completion");
637 let manager = ChunkedUploadManager::new(temp_dir.clone());
638
639 manager
640 .start_session("session4".to_string(), "file.bin".to_string(), 200, 100)
641 .unwrap();
642
643 let chunk_data = vec![0u8; 100];
644
645 manager.upload_chunk("session4", 0, &chunk_data).unwrap();
646 let session = manager.get_session("session4").unwrap();
647 assert!(!session.is_complete());
648
649 manager.upload_chunk("session4", 1, &chunk_data).unwrap();
650 let session = manager.get_session("session4").unwrap();
651 assert!(session.is_complete());
652
653 manager.cleanup_session("session4").unwrap();
654 }
655
656 #[rstest::rstest]
661 #[case("../../../etc")]
662 #[case("foo/bar")]
663 #[case("foo\\bar")]
664 #[case("null\0byte")]
665 #[case("..")]
666 #[case("..%2f..%2fetc")]
667 #[case("%2e%2e%2f%2e%2e%2f")]
668 #[case("..%2fmalicious")]
669 fn test_start_session_rejects_traversal_in_session_id(#[case] session_id: &str) {
670 let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks_security"));
672
673 let result =
675 manager.start_session(session_id.to_string(), "file.bin".to_string(), 1000, 100);
676
677 assert!(
679 result.is_err(),
680 "Expected error for session_id: {}",
681 session_id
682 );
683 }
684
685 #[rstest::rstest]
686 fn test_start_session_allows_safe_session_id() {
687 let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks_safe"));
689
690 let result = manager.start_session(
692 "safe-session_123".to_string(),
693 "file.bin".to_string(),
694 1000,
695 100,
696 );
697
698 assert!(result.is_ok());
700 manager.cleanup_session("safe-session_123").unwrap();
701 }
702
703 #[rstest::rstest]
708 fn test_chunked_upload_session_rejects_zero_chunk_size() {
709 let session_id = "test-zero".to_string();
711 let filename = "file.bin".to_string();
712 let total_size = 1000;
713 let chunk_size = 0;
714
715 let result = ChunkedUploadSession::new(
717 session_id,
718 filename,
719 total_size,
720 chunk_size,
721 PathBuf::from("/tmp"),
722 );
723
724 assert!(result.is_err());
726 if let Err(ChunkedUploadError::InvalidChunk { expected, actual }) = result {
727 assert_eq!(expected, 1);
728 assert_eq!(actual, 0);
729 } else {
730 panic!("Expected InvalidChunk error for zero chunk_size");
731 }
732 }
733
734 #[rstest::rstest]
735 fn test_start_session_rejects_zero_chunk_size() {
736 let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks_zero"));
738
739 let result =
741 manager.start_session("session-zero".to_string(), "file.bin".to_string(), 1000, 0);
742
743 assert!(result.is_err());
745 }
746
747 #[rstest::rstest]
752 fn test_duplicate_chunk_upload_is_rejected() {
753 let temp_dir = PathBuf::from("/tmp/test_chunks_dedup");
755 let manager = ChunkedUploadManager::new(temp_dir.clone());
756 manager
757 .start_session("dedup1".to_string(), "file.bin".to_string(), 300, 100)
758 .unwrap();
759 let chunk_data = vec![0u8; 100];
760
761 manager.upload_chunk("dedup1", 0, &chunk_data).unwrap();
763 let result = manager.upload_chunk("dedup1", 0, &chunk_data);
764
765 assert!(result.is_err());
767 if let Err(ChunkedUploadError::DuplicateChunk { chunk_number }) = result {
768 assert_eq!(chunk_number, 0);
769 } else {
770 panic!("Expected DuplicateChunk error");
771 }
772
773 let session = manager.get_session("dedup1").unwrap();
775 assert_eq!(session.received_chunks, 1);
776 assert!(!session.is_complete());
777
778 manager.cleanup_session("dedup1").unwrap();
780 }
781
782 #[rstest::rstest]
783 fn test_sequential_chunk_upload_still_works() {
784 let temp_dir = PathBuf::from("/tmp/test_chunks_dedup_seq");
786 let manager = ChunkedUploadManager::new(temp_dir.clone());
787 manager
788 .start_session("dedup2".to_string(), "file.bin".to_string(), 300, 100)
789 .unwrap();
790
791 for i in 0..3 {
793 let chunk_data = vec![i as u8; 100];
794 manager.upload_chunk("dedup2", i, &chunk_data).unwrap();
795 }
796
797 let session = manager.get_session("dedup2").unwrap();
799 assert_eq!(session.received_chunks, 3);
800 assert!(session.is_complete());
801
802 let output_path = temp_dir.join("dedup2_assembled.bin");
804 let result = manager.assemble_chunks("dedup2", output_path.clone());
805 assert!(result.is_ok());
806
807 let content = fs::read(&output_path).unwrap();
808 assert_eq!(content.len(), 300);
809 assert!(content[0..100].iter().all(|&b| b == 0));
811 assert!(content[100..200].iter().all(|&b| b == 1));
812 assert!(content[200..300].iter().all(|&b| b == 2));
813
814 fs::remove_file(output_path).unwrap();
816 manager.cleanup_session("dedup2").unwrap();
817 }
818}