Skip to main content

reinhardt_http/
chunked_upload.rs

1//! Chunked upload handling for large files
2//!
3//! This module provides functionality for handling large file uploads
4//! by splitting them into manageable chunks, supporting resumable uploads,
5//! and assembling chunks back into complete files.
6
7use 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
15/// Default session timeout for chunked uploads (1 hour).
16/// Sessions older than this are considered stale and eligible for cleanup.
17const UPLOAD_SESSION_TIMEOUT: Duration = Duration::from_secs(3600);
18
19/// Errors that can occur during chunked upload
20#[non_exhaustive]
21#[derive(Debug, thiserror::Error)]
22pub enum ChunkedUploadError {
23	/// An I/O error occurred during chunk processing.
24	#[error("IO error: {0}")]
25	Io(#[from] io::Error),
26	/// The upload session with the given ID was not found.
27	#[error("Upload session not found: {0}")]
28	SessionNotFound(String),
29	/// The received chunk size does not match the expected size.
30	#[error("Invalid chunk: expected {expected}, got {actual}")]
31	InvalidChunk {
32		/// The expected chunk size in bytes.
33		expected: usize,
34		/// The actual chunk size received.
35		actual: usize,
36	},
37	/// Chunks were received out of sequential order.
38	#[error("Chunk out of order: expected {expected}, got {actual}")]
39	ChunkOutOfOrder {
40		/// The expected chunk index.
41		expected: usize,
42		/// The actual chunk index received.
43		actual: usize,
44	},
45	/// The upload session has already been completed.
46	#[error("Upload already completed")]
47	AlreadyCompleted,
48	/// The assembled file checksum does not match the expected value.
49	#[error("Checksum mismatch")]
50	ChecksumMismatch,
51	/// A chunk with the same number has already been uploaded.
52	#[error("Duplicate chunk: chunk {chunk_number} has already been uploaded")]
53	DuplicateChunk {
54		/// The chunk number that was uploaded more than once.
55		chunk_number: usize,
56	},
57}
58
59/// Upload progress information
60#[derive(Debug, Clone)]
61pub struct UploadProgress {
62	/// Current number of bytes uploaded
63	pub bytes_uploaded: usize,
64	/// Total file size in bytes
65	pub total_bytes: usize,
66	/// Progress percentage (0.0 - 100.0)
67	pub percentage: f64,
68	/// Number of chunks uploaded
69	pub chunks_uploaded: usize,
70	/// Total number of chunks
71	pub total_chunks: usize,
72	/// Upload start time
73	pub started_at: Instant,
74	/// Estimated time remaining in seconds
75	pub estimated_time_remaining: Option<f64>,
76	/// Upload speed in bytes per second
77	pub upload_speed: f64,
78}
79
80impl UploadProgress {
81	/// Create a new upload progress tracker
82	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	/// Update progress with new chunk
96	fn update(&mut self, chunk_size: usize) {
97		self.chunks_uploaded += 1;
98		self.bytes_uploaded += chunk_size;
99
100		// On the final chunk, update total_bytes to reflect actual cumulative
101		// byte count, which may differ from the declared total_size if the
102		// last chunk is smaller than chunk_size.
103		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		// Calculate upload speed
114		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			// Estimate time remaining
119			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	/// Check if upload is complete
127	pub fn is_complete(&self) -> bool {
128		self.chunks_uploaded >= self.total_chunks
129	}
130
131	/// Get formatted upload speed (e.g., "1.5 MB/s")
132	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	/// Get formatted estimated time remaining (e.g., "2m 30s")
143	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/// Metadata for a chunked upload session
160#[derive(Debug, Clone)]
161pub struct ChunkedUploadSession {
162	/// Unique session ID
163	pub session_id: String,
164	/// Original filename
165	pub filename: String,
166	/// Total file size in bytes
167	pub total_size: usize,
168	/// Chunk size in bytes
169	pub chunk_size: usize,
170	/// Total number of chunks
171	pub total_chunks: usize,
172	/// Number of chunks received so far
173	pub received_chunks: usize,
174	/// Set of chunk numbers that have been received, used to detect duplicates
175	received_chunk_numbers: HashSet<usize>,
176	/// Temporary directory for chunks
177	pub temp_dir: PathBuf,
178	/// Whether the upload is complete
179	pub completed: bool,
180	/// Upload progress tracker
181	progress: UploadProgress,
182	/// When the session was created, used for timeout-based cleanup
183	created_at: Instant,
184}
185
186impl ChunkedUploadSession {
187	/// Create a new upload session
188	///
189	/// Returns `ChunkedUploadError::InvalidChunk` if `chunk_size` is zero.
190	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	/// Get progress percentage
220	pub fn progress(&self) -> f64 {
221		self.progress.percentage
222	}
223
224	/// Get detailed upload progress
225	///
226	/// # Examples
227	///
228	/// ```
229	/// use reinhardt_http::chunked_upload::ChunkedUploadSession;
230	/// use std::path::PathBuf;
231	///
232	/// let session = ChunkedUploadSession::new(
233	///     "session1".to_string(),
234	///     "file.bin".to_string(),
235	///     1000,
236	///     100,
237	///     PathBuf::from("/tmp")
238	/// ).unwrap();
239	/// let progress = session.get_progress();
240	/// assert_eq!(progress.percentage, 0.0);
241	/// ```
242	pub fn get_progress(&self) -> &UploadProgress {
243		&self.progress
244	}
245
246	/// Update progress with a new chunk
247	///
248	/// This is primarily used internally but exposed for testing purposes.
249	#[doc(hidden)]
250	pub fn update_progress(&mut self, chunk_size: usize) {
251		self.progress.update(chunk_size);
252	}
253
254	/// Check if upload is complete
255	pub fn is_complete(&self) -> bool {
256		self.completed || self.received_chunks >= self.total_chunks
257	}
258
259	/// Get the path for a specific chunk
260	///
261	/// Uses the pre-validated session_id (validated during session creation)
262	/// combined with the numeric chunk_number, both safe for path construction.
263	fn chunk_path(&self, chunk_number: usize) -> PathBuf {
264		self.temp_dir
265			.join(format!("{}_{}.chunk", self.session_id, chunk_number))
266	}
267}
268
269/// Manager for chunked uploads
270pub struct ChunkedUploadManager {
271	sessions: Arc<Mutex<HashMap<String, ChunkedUploadSession>>>,
272	temp_base_dir: PathBuf,
273}
274
275impl ChunkedUploadManager {
276	/// Create a new chunked upload manager
277	///
278	/// # Examples
279	///
280	/// ```
281	/// use reinhardt_http::chunked_upload::ChunkedUploadManager;
282	/// use std::path::PathBuf;
283	///
284	/// let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/chunked_uploads"));
285	/// ```
286	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	/// Start a new upload session
294	///
295	/// # Examples
296	///
297	/// ```
298	/// use reinhardt_http::chunked_upload::ChunkedUploadManager;
299	/// use std::path::PathBuf;
300	///
301	/// let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/chunked_uploads"));
302	/// let session = manager.start_session(
303	///     "session123".to_string(),
304	///     "large_file.bin".to_string(),
305	///     10_000_000, // 10MB
306	///     1_000_000,  // 1MB chunks
307	/// ).unwrap();
308	/// assert_eq!(session.total_chunks, 10);
309	/// ```
310	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		// Lazily clean up expired sessions to prevent memory exhaustion
318		self.cleanup_expired_sessions();
319
320		// Validate session_id to prevent path traversal attacks.
321		// Session IDs are used to construct directory and file paths.
322		// Check both raw and URL-decoded forms to prevent bypass via
323		// percent-encoded traversal sequences like %2e%2e%2f.
324		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	/// Upload a chunk
355	///
356	/// # Examples
357	///
358	/// ```no_run
359	/// use reinhardt_http::chunked_upload::ChunkedUploadManager;
360	/// use std::path::PathBuf;
361	///
362	/// let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/chunked_uploads"));
363	/// manager.start_session("session123".to_string(), "file.bin".to_string(), 1000, 100).unwrap();
364	///
365	/// let chunk_data = vec![0u8; 100];
366	/// manager.upload_chunk("session123", 0, &chunk_data).unwrap();
367	/// ```
368	pub fn upload_chunk(
369		&self,
370		session_id: &str,
371		chunk_number: usize,
372		data: &[u8],
373	) -> Result<ChunkedUploadSession, ChunkedUploadError> {
374		// Lazily clean up expired sessions to prevent memory exhaustion
375		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		// Validate chunk number
387		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		// Reject duplicate chunk uploads to prevent counter overcounting
395		if session.received_chunk_numbers.contains(&chunk_number) {
396			return Err(ChunkedUploadError::DuplicateChunk { chunk_number });
397		}
398
399		// Write chunk to disk
400		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	/// Assemble all chunks into final file
416	///
417	/// # Examples
418	///
419	/// ```no_run
420	/// use reinhardt_http::chunked_upload::ChunkedUploadManager;
421	/// use std::path::PathBuf;
422	///
423	/// let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/chunked_uploads"));
424	/// let output_path = manager.assemble_chunks("session123", PathBuf::from("/tmp/final_file.bin")).unwrap();
425	/// ```
426	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		// Create output file
444		let mut output_file = File::create(&output_path)?;
445
446		// Assemble chunks in order
447		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	/// Clean up a session (delete temporary files)
457	///
458	/// # Examples
459	///
460	/// ```no_run
461	/// use reinhardt_http::chunked_upload::ChunkedUploadManager;
462	/// use std::path::PathBuf;
463	///
464	/// let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/chunked_uploads"));
465	/// manager.cleanup_session("session123").unwrap();
466	/// ```
467	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	/// Get session information
478	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	/// List all active sessions
484	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	/// Remove sessions that have exceeded the timeout.
490	///
491	/// Expired sessions have their temporary files cleaned up automatically.
492	/// This is called lazily on `start_session` and `upload_chunk` to prevent
493	/// unbounded memory growth from abandoned uploads.
494	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		// Update progress by simulating chunk uploads
544		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		// Upload 3 chunks
616		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		// Cleanup
630		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	// =================================================================
657	// Path traversal prevention tests (Issue #355)
658	// =================================================================
659
660	#[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		// Arrange
671		let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks_security"));
672
673		// Act
674		let result =
675			manager.start_session(session_id.to_string(), "file.bin".to_string(), 1000, 100);
676
677		// Assert
678		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		// Arrange
688		let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks_safe"));
689
690		// Act
691		let result = manager.start_session(
692			"safe-session_123".to_string(),
693			"file.bin".to_string(),
694			1000,
695			100,
696		);
697
698		// Assert
699		assert!(result.is_ok());
700		manager.cleanup_session("safe-session_123").unwrap();
701	}
702
703	// =================================================================
704	// Division by zero prevention tests (Issue #359)
705	// =================================================================
706
707	#[rstest::rstest]
708	fn test_chunked_upload_session_rejects_zero_chunk_size() {
709		// Arrange
710		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		// Act
716		let result = ChunkedUploadSession::new(
717			session_id,
718			filename,
719			total_size,
720			chunk_size,
721			PathBuf::from("/tmp"),
722		);
723
724		// Assert
725		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		// Arrange
737		let manager = ChunkedUploadManager::new(PathBuf::from("/tmp/test_chunks_zero"));
738
739		// Act
740		let result =
741			manager.start_session("session-zero".to_string(), "file.bin".to_string(), 1000, 0);
742
743		// Assert
744		assert!(result.is_err());
745	}
746
747	// =================================================================
748	// Duplicate chunk upload prevention tests (Issue #2260)
749	// =================================================================
750
751	#[rstest::rstest]
752	fn test_duplicate_chunk_upload_is_rejected() {
753		// Arrange
754		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		// Act
762		manager.upload_chunk("dedup1", 0, &chunk_data).unwrap();
763		let result = manager.upload_chunk("dedup1", 0, &chunk_data);
764
765		// Assert
766		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		// Verify received_chunks was not overcounted
774		let session = manager.get_session("dedup1").unwrap();
775		assert_eq!(session.received_chunks, 1);
776		assert!(!session.is_complete());
777
778		// Cleanup
779		manager.cleanup_session("dedup1").unwrap();
780	}
781
782	#[rstest::rstest]
783	fn test_sequential_chunk_upload_still_works() {
784		// Arrange
785		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		// Act
792		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		// Assert
798		let session = manager.get_session("dedup2").unwrap();
799		assert_eq!(session.received_chunks, 3);
800		assert!(session.is_complete());
801
802		// Verify assembly works correctly
803		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		// Verify each chunk has correct content
810		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		// Cleanup
815		fs::remove_file(output_path).unwrap();
816		manager.cleanup_session("dedup2").unwrap();
817	}
818}