1use crate::constants::DEFAULT_CHUNK_SIZE;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::hash_map::RandomState;
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::hash::BuildHasher;
9use std::time::{Duration, Instant};
10
11mod base64_bytes {
12 use base64::{engine::general_purpose::STANDARD, Engine};
13 use serde::{Deserialize, Deserializer, Serializer};
14
15 pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
16 where
17 S: Serializer,
18 {
19 serializer.serialize_str(&STANDARD.encode(bytes))
20 }
21
22 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
23 where
24 D: Deserializer<'de>,
25 {
26 let s = String::deserialize(deserializer)?;
27 STANDARD.decode(&s).map_err(serde::de::Error::custom)
28 }
29}
30
31#[derive(Debug, Clone)]
33pub struct FileTransferConfig {
34 pub chunk_size: usize,
41
42 pub max_file_size: u64,
44
45 pub max_concurrent_assemblies: usize,
49
50 pub max_assemblies_per_sender: usize,
57
58 pub max_total_buffered_bytes: u64,
67}
68
69impl Default for FileTransferConfig {
70 fn default() -> Self {
71 Self {
72 chunk_size: DEFAULT_CHUNK_SIZE,
73 max_file_size: 100 * 1024 * 1024, max_concurrent_assemblies: 32,
75 max_assemblies_per_sender: 16,
76 max_total_buffered_bytes: 128 * 1024 * 1024, }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ChunkRejection {
84 Invalid,
88 MetadataMismatch,
90 SenderMismatch,
92 TooManyTransfers,
94 SenderQuotaExceeded,
96 BufferBudgetExhausted,
101 PreviouslyFailed,
108}
109
110impl ChunkRejection {
111 pub fn is_resource_exhaustion(self) -> bool {
115 matches!(
116 self,
117 Self::TooManyTransfers | Self::SenderQuotaExceeded | Self::BufferBudgetExhausted
118 )
119 }
120
121 pub fn as_str(self) -> &'static str {
124 match self {
125 Self::Invalid => "invalid_chunk",
126 Self::MetadataMismatch => "metadata_mismatch",
127 Self::SenderMismatch => "sender_mismatch",
128 Self::TooManyTransfers => "too_many_transfers",
129 Self::SenderQuotaExceeded => "sender_quota_exceeded",
130 Self::BufferBudgetExhausted => "buffer_budget_exhausted",
131 Self::PreviouslyFailed => "previously_failed",
132 }
133 }
134}
135
136#[non_exhaustive]
140#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
141pub enum ChunkDecodeError {
142 #[error("unexpected end of data reading {what}")]
144 UnexpectedEof {
145 what: &'static str,
147 },
148 #[error("{field} {len} exceeds maximum {max}")]
150 FieldTooLong {
151 field: &'static str,
153 len: usize,
155 max: usize,
157 },
158 #[error("invalid {field} UTF-8: {source}")]
160 InvalidUtf8 {
161 field: &'static str,
163 source: std::string::FromUtf8Error,
165 },
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct FileChunk {
171 pub file_id: String,
173
174 pub file_name: String,
176
177 pub file_size: u64,
179
180 pub total_chunks: u32,
182
183 pub chunk_index: u32,
185
186 #[serde(with = "base64_bytes")]
188 pub chunk_data: Vec<u8>,
189
190 pub file_checksum: String,
192}
193
194impl FileChunk {
195 pub fn to_json(&self) -> Result<String, serde_json::Error> {
197 serde_json::to_string(self)
198 }
199
200 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
202 serde_json::from_str(json)
203 }
204
205 pub fn to_bytes(&self) -> Vec<u8> {
215 let file_id = self.file_id.as_bytes();
216 let file_name = self.file_name.as_bytes();
217 let checksum = self.file_checksum.as_bytes();
218
219 let capacity = 4
220 + file_id.len()
221 + 4
222 + file_name.len()
223 + 8
224 + 4
225 + 4
226 + 4
227 + self.chunk_data.len()
228 + 4
229 + checksum.len();
230 let mut buf = Vec::with_capacity(capacity);
231
232 buf.extend_from_slice(&(file_id.len() as u32).to_le_bytes());
233 buf.extend_from_slice(file_id);
234 buf.extend_from_slice(&(file_name.len() as u32).to_le_bytes());
235 buf.extend_from_slice(file_name);
236 buf.extend_from_slice(&self.file_size.to_le_bytes());
237 buf.extend_from_slice(&self.total_chunks.to_le_bytes());
238 buf.extend_from_slice(&self.chunk_index.to_le_bytes());
239 buf.extend_from_slice(&(self.chunk_data.len() as u32).to_le_bytes());
240 buf.extend_from_slice(&self.chunk_data);
241 buf.extend_from_slice(&(checksum.len() as u32).to_le_bytes());
242 buf.extend_from_slice(checksum);
243
244 buf
245 }
246
247 pub(crate) const MAX_STRING_FIELD_LEN: usize = 4096;
252 const MAX_CHUNK_DATA_LEN: usize = 128 * 1024 * 1024; pub fn from_bytes(data: &[u8]) -> Result<Self, ChunkDecodeError> {
257 let mut pos = 0;
258
259 let read_u32 = |pos: &mut usize| -> Result<u32, ChunkDecodeError> {
260 if *pos + 4 > data.len() {
261 return Err(ChunkDecodeError::UnexpectedEof { what: "u32" });
262 }
263 let mut bytes = [0u8; 4];
264 bytes.copy_from_slice(&data[*pos..*pos + 4]);
265 let val = u32::from_le_bytes(bytes);
266 *pos += 4;
267 Ok(val)
268 };
269
270 let read_u64 = |pos: &mut usize| -> Result<u64, ChunkDecodeError> {
271 if *pos + 8 > data.len() {
272 return Err(ChunkDecodeError::UnexpectedEof { what: "u64" });
273 }
274 let mut bytes = [0u8; 8];
275 bytes.copy_from_slice(&data[*pos..*pos + 8]);
276 let val = u64::from_le_bytes(bytes);
277 *pos += 8;
278 Ok(val)
279 };
280
281 let read_bytes = |pos: &mut usize, len: usize| -> Result<Vec<u8>, ChunkDecodeError> {
282 if *pos + len > data.len() {
283 return Err(ChunkDecodeError::UnexpectedEof { what: "bytes" });
284 }
285 let val = data[*pos..*pos + len].to_vec();
286 *pos += len;
287 Ok(val)
288 };
289
290 let file_id_len = read_u32(&mut pos)? as usize;
291 if file_id_len > Self::MAX_STRING_FIELD_LEN {
292 return Err(ChunkDecodeError::FieldTooLong {
293 field: "file_id_len",
294 len: file_id_len,
295 max: Self::MAX_STRING_FIELD_LEN,
296 });
297 }
298 let file_id = String::from_utf8(read_bytes(&mut pos, file_id_len)?).map_err(|e| {
299 ChunkDecodeError::InvalidUtf8 {
300 field: "file_id",
301 source: e,
302 }
303 })?;
304
305 let file_name_len = read_u32(&mut pos)? as usize;
306 if file_name_len > Self::MAX_STRING_FIELD_LEN {
307 return Err(ChunkDecodeError::FieldTooLong {
308 field: "file_name_len",
309 len: file_name_len,
310 max: Self::MAX_STRING_FIELD_LEN,
311 });
312 }
313 let file_name = String::from_utf8(read_bytes(&mut pos, file_name_len)?).map_err(|e| {
314 ChunkDecodeError::InvalidUtf8 {
315 field: "file_name",
316 source: e,
317 }
318 })?;
319
320 let file_size = read_u64(&mut pos)?;
321 let total_chunks = read_u32(&mut pos)?;
322 let chunk_index = read_u32(&mut pos)?;
323
324 let chunk_data_len = read_u32(&mut pos)? as usize;
325 if chunk_data_len > Self::MAX_CHUNK_DATA_LEN {
326 return Err(ChunkDecodeError::FieldTooLong {
327 field: "chunk_data_len",
328 len: chunk_data_len,
329 max: Self::MAX_CHUNK_DATA_LEN,
330 });
331 }
332 let chunk_data = read_bytes(&mut pos, chunk_data_len)?;
333
334 let checksum_len = read_u32(&mut pos)? as usize;
335 if checksum_len > Self::MAX_STRING_FIELD_LEN {
336 return Err(ChunkDecodeError::FieldTooLong {
337 field: "checksum_len",
338 len: checksum_len,
339 max: Self::MAX_STRING_FIELD_LEN,
340 });
341 }
342 let file_checksum =
343 String::from_utf8(read_bytes(&mut pos, checksum_len)?).map_err(|e| {
344 ChunkDecodeError::InvalidUtf8 {
345 field: "checksum",
346 source: e,
347 }
348 })?;
349
350 Ok(Self {
351 file_id,
352 file_name,
353 file_size,
354 total_chunks,
355 chunk_index,
356 chunk_data,
357 file_checksum,
358 })
359 }
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct FileProgress {
365 pub file_id: String,
367
368 pub file_name: String,
370
371 pub file_size: u64,
373
374 pub chunks_completed: u32,
376
377 pub total_chunks: u32,
379
380 pub percentage: u8,
382}
383
384impl FileProgress {
385 pub fn calculate_percentage(chunks_completed: u32, total_chunks: u32) -> u8 {
387 if total_chunks == 0 {
388 return 0;
389 }
390 ((chunks_completed as f32 / total_chunks as f32) * 100.0) as u8
391 }
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct StaleTransfer {
398 pub file_id: String,
400 pub file_name: String,
402 pub sender: String,
404}
405
406struct FileAssembly {
408 sender: String,
411 file_name: String,
412 file_size: u64,
413 total_chunks: u32,
414 file_checksum: String,
415 received_chunks: HashMap<u32, Vec<u8>>,
416 received_bytes: u64,
418 last_updated_at: Instant,
419}
420
421impl FileAssembly {
422 fn new(sender: &str, chunk: &FileChunk) -> Self {
423 Self {
424 sender: sender.to_string(),
425 file_name: chunk.file_name.clone(),
426 file_size: chunk.file_size,
427 total_chunks: chunk.total_chunks,
428 file_checksum: chunk.file_checksum.clone(),
429 received_chunks: HashMap::new(),
430 received_bytes: 0,
431 last_updated_at: Instant::now(),
432 }
433 }
434
435 fn add_chunk(&mut self, chunk_index: u32, data: Vec<u8>) {
436 if let Some(old) = self.received_chunks.get(&chunk_index) {
437 self.received_bytes -= old.len() as u64;
438 }
439 self.received_bytes += data.len() as u64;
440 self.received_chunks.insert(chunk_index, data);
441 self.last_updated_at = Instant::now();
442 }
443
444 fn charged_bytes(&self) -> u64 {
448 self.received_bytes
449 + self.received_chunks.len() as u64 * FileTransferManager::PER_CHUNK_OVERHEAD
450 }
451
452 fn is_complete(&self) -> bool {
453 if self.total_chunks == 0 || self.received_chunks.len() != self.total_chunks as usize {
454 return false;
455 }
456
457 (0..self.total_chunks).all(|chunk_index| self.received_chunks.contains_key(&chunk_index))
458 }
459
460 fn progress(&self) -> FileProgress {
461 FileProgress {
462 file_id: String::new(), file_name: self.file_name.clone(),
464 file_size: self.file_size,
465 chunks_completed: self.received_chunks.len() as u32,
466 total_chunks: self.total_chunks,
467 percentage: FileProgress::calculate_percentage(
468 self.received_chunks.len() as u32,
469 self.total_chunks,
470 ),
471 }
472 }
473
474 fn reassemble(&self) -> Option<Vec<u8>> {
475 if !self.is_complete() {
476 return None;
477 }
478
479 let mut file_data = Vec::with_capacity(self.received_bytes as usize);
482
483 for i in 0..self.total_chunks {
485 let chunk_data = self.received_chunks.get(&i)?; file_data.extend_from_slice(chunk_data);
487 }
488
489 Some(file_data)
490 }
491}
492
493pub struct FileTransferManager {
495 config: FileTransferConfig,
496 active_assemblies: HashMap<String, FileAssembly>,
497 failed_transfers: HashMap<u64, Instant>,
504 failed_transfer_order: VecDeque<u64>,
507 tombstone_hasher: RandomState,
511}
512
513impl FileTransferManager {
514 const MIN_ACCEPTED_CHUNK_PAYLOAD: u64 = 1024;
519
520 const MIN_TOTAL_CHUNKS_CAP: u64 = 1024;
523
524 const PER_CHUNK_OVERHEAD: u64 = 64;
529
530 pub const MANUAL_SENDER: &'static str = "manual:ffi";
536
537 const TOMBSTONE_CAPACITY: usize = 1024;
543
544 pub fn new() -> Self {
546 Self::with_config(FileTransferConfig::default())
547 }
548
549 pub fn with_config(mut config: FileTransferConfig) -> Self {
555 let min_budget = Self::min_viable_budget(&config);
556 if config.max_total_buffered_bytes < min_budget {
557 tracing::warn!(
558 configured = config.max_total_buffered_bytes,
559 clamped_to = min_budget,
560 "max_total_buffered_bytes below what one max_file_size transfer charges; clamping"
561 );
562 config.max_total_buffered_bytes = min_budget;
563 }
564 Self {
565 config,
566 active_assemblies: HashMap::new(),
567 failed_transfers: HashMap::new(),
568 failed_transfer_order: VecDeque::new(),
569 tombstone_hasher: RandomState::new(),
570 }
571 }
572
573 fn tombstone_key(&self, file_id: &str) -> u64 {
576 self.tombstone_hasher.hash_one(file_id)
577 }
578
579 fn tombstone(&mut self, file_id: &str) {
583 let key = self.tombstone_key(file_id);
584 if self.failed_transfers.insert(key, Instant::now()).is_none() {
585 self.failed_transfer_order.push_back(key);
586 while self.failed_transfers.len() > Self::TOMBSTONE_CAPACITY {
587 match self.failed_transfer_order.pop_front() {
588 Some(oldest) => {
589 self.failed_transfers.remove(&oldest);
590 }
591 None => break,
592 }
593 }
594 }
595 }
596
597 fn is_tombstoned(&mut self, file_id: &str) -> bool {
601 let key = self.tombstone_key(file_id);
602 match self.failed_transfers.get_mut(&key) {
603 Some(last_seen) => {
604 *last_seen = Instant::now();
605 true
606 }
607 None => false,
608 }
609 }
610
611 fn derived_total_chunks_cap(max_file_size: u64) -> u64 {
615 (max_file_size / Self::MIN_ACCEPTED_CHUNK_PAYLOAD)
616 .saturating_add(1)
617 .max(Self::MIN_TOTAL_CHUNKS_CAP)
618 }
619
620 fn min_viable_budget(config: &FileTransferConfig) -> u64 {
625 let max_chunks =
626 Self::derived_total_chunks_cap(config.max_file_size).min(config.max_file_size);
627 config
628 .max_file_size
629 .saturating_add(max_chunks.saturating_mul(Self::PER_CHUNK_OVERHEAD))
630 }
631
632 pub fn chunk_file(
651 &self,
652 file_id: String,
653 file_name: String,
654 file_data: Vec<u8>,
655 chunk_size_override: Option<usize>,
656 ) -> crate::Result<Vec<FileChunk>> {
657 let file_size = file_data.len() as u64;
658
659 if file_size > self.config.max_file_size {
660 return Err(crate::Error::InvalidArgument(format!(
661 "File size {} exceeds maximum {}",
662 file_size, self.config.max_file_size
663 )));
664 }
665
666 if file_data.is_empty() {
667 return Err(crate::Error::InvalidArgument("File is empty".to_string()));
668 }
669
670 let chunk_size = chunk_size_override.unwrap_or(self.config.chunk_size);
671 if chunk_size == 0 {
672 return Err(crate::Error::InvalidArgument(
673 "Chunk size must be greater than zero".to_string(),
674 ));
675 }
676
677 let total_chunks = file_size.div_ceil(chunk_size as u64);
681 let max_total_chunks = Self::derived_total_chunks_cap(self.config.max_file_size);
682 if total_chunks > max_total_chunks {
683 return Err(crate::Error::InvalidArgument(format!(
684 "Chunk size {} splits {} bytes into {} chunks, exceeding the receive-side cap of {}; use a larger chunk size",
685 chunk_size, file_size, total_chunks, max_total_chunks
686 )));
687 }
688 let total_chunks = total_chunks as u32;
689
690 let file_checksum = format!("{:x}", Sha256::digest(&file_data));
691
692 let mut chunks = Vec::new();
693
694 for chunk_index in 0..total_chunks {
695 let start = (chunk_index as usize) * chunk_size;
696 let end = ((chunk_index as usize + 1) * chunk_size).min(file_data.len());
697
698 let chunk = FileChunk {
699 file_id: file_id.clone(),
700 file_name: file_name.clone(),
701 file_size,
702 total_chunks,
703 chunk_index,
704 chunk_data: file_data[start..end].to_vec(),
705 file_checksum: file_checksum.clone(),
706 };
707
708 chunks.push(chunk);
709 }
710
711 Ok(chunks)
712 }
713
714 pub fn process_chunk(
733 &mut self,
734 sender: &str,
735 chunk: FileChunk,
736 ) -> Result<FileProgress, ChunkRejection> {
737 if chunk.total_chunks == 0 || chunk.chunk_index >= chunk.total_chunks {
738 return Err(ChunkRejection::Invalid);
739 }
740
741 if chunk.file_size == 0 || chunk.file_size > self.config.max_file_size {
742 tracing::warn!(
743 file_id = %chunk.file_id,
744 file_size = chunk.file_size,
745 max_file_size = self.config.max_file_size,
746 "Rejecting file chunk: claimed file_size out of bounds"
747 );
748 return Err(ChunkRejection::Invalid);
749 }
750
751 let max_total_chunks = Self::derived_total_chunks_cap(self.config.max_file_size);
752 if chunk.total_chunks as u64 > max_total_chunks
753 || chunk.total_chunks as u64 > chunk.file_size
754 {
755 tracing::warn!(
756 file_id = %chunk.file_id,
757 total_chunks = chunk.total_chunks,
758 file_size = chunk.file_size,
759 max_total_chunks,
760 "Rejecting file chunk: claimed total_chunks out of bounds"
761 );
762 return Err(ChunkRejection::Invalid);
763 }
764
765 if chunk.chunk_data.is_empty() || chunk.chunk_data.len() as u64 > chunk.file_size {
766 tracing::warn!(
767 file_id = %chunk.file_id,
768 chunk_len = chunk.chunk_data.len(),
769 file_size = chunk.file_size,
770 "Rejecting file chunk: chunk data length inconsistent with claimed file_size"
771 );
772 return Err(ChunkRejection::Invalid);
773 }
774
775 if self.is_tombstoned(&chunk.file_id) {
779 tracing::debug!(
780 file_id = %chunk.file_id,
781 "Dropping chunk of a previously failed transfer"
782 );
783 return Err(ChunkRejection::PreviouslyFailed);
784 }
785
786 let file_id = chunk.file_id.clone();
787
788 let replaced_charge = match self.active_assemblies.get(&file_id) {
792 Some(assembly) => {
793 if assembly.sender != sender {
794 tracing::warn!(
795 file_id = %file_id,
796 sender = %sender,
797 "Rejecting file chunk: sender does not match the assembly's sender"
798 );
799 return Err(ChunkRejection::SenderMismatch);
800 }
801 if assembly.total_chunks != chunk.total_chunks
802 || assembly.file_size != chunk.file_size
803 || assembly.file_checksum != chunk.file_checksum
804 {
805 tracing::warn!(
806 file_id = %file_id,
807 "Rejecting file chunk: metadata does not match the existing assembly"
808 );
809 return Err(ChunkRejection::MetadataMismatch);
810 }
811 let replaced = assembly
815 .received_chunks
816 .get(&chunk.chunk_index)
817 .map(|c| c.len() as u64);
818 if assembly.received_bytes - replaced.unwrap_or(0) + chunk.chunk_data.len() as u64
819 > assembly.file_size
820 {
821 tracing::warn!(
822 file_id = %file_id,
823 received_bytes = assembly.received_bytes,
824 chunk_len = chunk.chunk_data.len(),
825 file_size = assembly.file_size,
826 "Rejecting file chunk: received bytes exceed claimed file_size"
827 );
828 return Err(ChunkRejection::Invalid);
829 }
830 replaced.map_or(0, |len| len + Self::PER_CHUNK_OVERHEAD)
833 }
834 None => {
835 if self.active_assemblies.len() >= self.config.max_concurrent_assemblies {
836 tracing::warn!(
837 file_id = %file_id,
838 active = self.active_assemblies.len(),
839 max = self.config.max_concurrent_assemblies,
840 "Rejecting file chunk: too many concurrent inbound transfers"
841 );
842 self.tombstone(&file_id);
846 return Err(ChunkRejection::TooManyTransfers);
847 }
848 let sender_active = self
849 .active_assemblies
850 .values()
851 .filter(|a| a.sender == sender)
852 .count();
853 if sender_active >= self.config.max_assemblies_per_sender {
854 tracing::warn!(
855 file_id = %file_id,
856 sender = %sender,
857 sender_active,
858 max = self.config.max_assemblies_per_sender,
859 "Rejecting file chunk: sender's concurrent-transfer quota reached"
860 );
861 self.tombstone(&file_id);
862 return Err(ChunkRejection::SenderQuotaExceeded);
863 }
864 0
865 }
866 };
867
868 let buffered: u64 = self
872 .active_assemblies
873 .values()
874 .map(FileAssembly::charged_bytes)
875 .sum();
876 if buffered - replaced_charge + chunk.chunk_data.len() as u64 + Self::PER_CHUNK_OVERHEAD
877 > self.config.max_total_buffered_bytes
878 {
879 tracing::warn!(
880 file_id = %file_id,
881 buffered,
882 chunk_len = chunk.chunk_data.len(),
883 budget = self.config.max_total_buffered_bytes,
884 "Rejecting file chunk: buffered-bytes budget exhausted, dropping transfer"
885 );
886 self.active_assemblies.remove(&file_id);
892 self.tombstone(&file_id);
893 return Err(ChunkRejection::BufferBudgetExhausted);
894 }
895
896 let assembly = self
898 .active_assemblies
899 .entry(file_id.clone())
900 .or_insert_with(|| FileAssembly::new(sender, &chunk));
901
902 assembly.add_chunk(chunk.chunk_index, chunk.chunk_data);
904
905 let mut progress = assembly.progress();
907 progress.file_id = file_id;
908
909 Ok(progress)
910 }
911
912 pub fn is_complete(&self, file_id: &str) -> bool {
914 self.active_assemblies
915 .get(file_id)
916 .map(|assembly| assembly.is_complete())
917 .unwrap_or(false)
918 }
919
920 pub fn finalize_file(&mut self, file_id: &str) -> Option<Vec<u8>> {
933 let assembly = self.active_assemblies.get(file_id)?;
934 let file_data = assembly.reassemble()?;
935
936 if file_data.len() as u64 != assembly.file_size {
938 tracing::warn!(
939 file_id = %file_id,
940 claimed = assembly.file_size,
941 actual = file_data.len(),
942 "File size mismatch — corrupted or tampered transfer"
943 );
944 self.active_assemblies.remove(file_id);
945 self.tombstone(file_id);
946 return None;
947 }
948
949 let actual_checksum = format!("{:x}", Sha256::digest(&file_data));
951 if actual_checksum != assembly.file_checksum {
952 tracing::warn!(
953 file_id = %file_id,
954 expected = %assembly.file_checksum,
955 actual = %actual_checksum,
956 "File checksum mismatch — corrupted or tampered transfer"
957 );
958 self.active_assemblies.remove(file_id);
959 self.tombstone(file_id);
960 return None;
961 }
962
963 self.active_assemblies.remove(file_id);
964 Some(file_data)
965 }
966
967 pub fn cleanup_stale_transfers(&mut self, max_age: Duration) -> Vec<StaleTransfer> {
973 let now = Instant::now();
974
975 self.failed_transfers
976 .retain(|_, last_seen| now.duration_since(*last_seen) <= max_age);
977 let failed_transfers = &self.failed_transfers;
978 self.failed_transfer_order
979 .retain(|key| failed_transfers.contains_key(key));
980
981 let stale_file_ids: Vec<String> = self
982 .active_assemblies
983 .iter()
984 .filter_map(|(file_id, assembly)| {
985 if now.duration_since(assembly.last_updated_at) > max_age {
986 return Some(file_id.clone());
987 }
988 None
989 })
990 .collect();
991
992 stale_file_ids
993 .into_iter()
994 .filter_map(|file_id| {
995 let assembly = self.active_assemblies.remove(&file_id)?;
996 self.tombstone(&file_id);
997 Some(StaleTransfer {
998 file_id,
999 file_name: assembly.file_name,
1000 sender: assembly.sender,
1001 })
1002 })
1003 .collect()
1004 }
1005
1006 pub fn get_progress(&self, file_id: &str) -> Option<FileProgress> {
1008 self.active_assemblies.get(file_id).map(|assembly| {
1009 let mut progress = assembly.progress();
1010 progress.file_id = file_id.to_string();
1011 progress
1012 })
1013 }
1014
1015 pub fn cancel_transfer(&mut self, file_id: &str) -> bool {
1017 self.active_assemblies.remove(file_id).is_some()
1018 }
1019
1020 pub fn active_transfer_count(&self) -> usize {
1022 self.active_assemblies.len()
1023 }
1024
1025 #[cfg(test)]
1028 pub(crate) fn backdate_transfers(&mut self, by: Duration) {
1029 let backdated = Instant::now()
1033 .checked_sub(by)
1034 .expect("backdate_transfers needs system uptime longer than the backdate duration");
1035 for assembly in self.active_assemblies.values_mut() {
1036 assembly.last_updated_at = backdated;
1037 }
1038 for last_seen in self.failed_transfers.values_mut() {
1039 *last_seen = backdated;
1040 }
1041 }
1042}
1043
1044impl Default for FileTransferManager {
1045 fn default() -> Self {
1046 Self::new()
1047 }
1048}
1049
1050pub struct OutboundTransferState {
1056 chunks: Vec<FileChunk>,
1057 next_unsent: usize,
1058 in_flight: HashSet<u32>,
1059 acked: HashSet<u32>,
1060 max_in_flight: usize,
1061}
1062
1063impl OutboundTransferState {
1064 pub fn new(chunks: Vec<FileChunk>, max_in_flight: usize) -> Self {
1066 Self {
1067 chunks,
1068 next_unsent: 0,
1069 in_flight: HashSet::new(),
1070 acked: HashSet::new(),
1071 max_in_flight,
1072 }
1073 }
1074
1075 pub fn next_chunks_to_send(&mut self) -> Vec<FileChunk> {
1078 let mut batch = Vec::new();
1079 while self.in_flight.len() < self.max_in_flight && self.next_unsent < self.chunks.len() {
1080 let chunk = self.chunks[self.next_unsent].clone();
1081 self.in_flight.insert(chunk.chunk_index);
1082 self.next_unsent += 1;
1083 batch.push(chunk);
1084 }
1085 batch
1086 }
1087
1088 pub fn on_chunk_ack(&mut self, chunk_index: u32) -> bool {
1091 self.acked.insert(chunk_index);
1092 self.in_flight.remove(&chunk_index)
1093 }
1094
1095 pub fn on_chunk_failed(&mut self, chunk_index: u32) {
1098 self.in_flight.remove(&chunk_index);
1099 }
1100
1101 pub fn total_chunks(&self) -> u32 {
1103 self.chunks.len() as u32
1104 }
1105
1106 pub fn acked_count(&self) -> u32 {
1108 self.acked.len() as u32
1109 }
1110
1111 pub fn is_fully_acked(&self) -> bool {
1113 self.acked.len() == self.chunks.len()
1114 }
1115
1116 pub fn all_sent(&self) -> bool {
1119 self.next_unsent >= self.chunks.len()
1120 }
1121
1122 pub fn has_capacity(&self) -> bool {
1124 self.in_flight.len() < self.max_in_flight && self.next_unsent < self.chunks.len()
1125 }
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131
1132 fn sample_chunk() -> FileChunk {
1133 FileChunk {
1134 file_id: "file1".to_string(),
1135 file_name: "test.txt".to_string(),
1136 file_size: 13,
1137 total_chunks: 1,
1138 chunk_index: 0,
1139 chunk_data: b"Hello, World!".to_vec(),
1140 file_checksum: "abc123".to_string(),
1141 }
1142 }
1143
1144 #[test]
1145 fn test_from_bytes_truncated_payload_is_unexpected_eof() {
1146 let bytes = sample_chunk().to_bytes();
1147 let err = FileChunk::from_bytes(&bytes[..2]).unwrap_err();
1149 assert!(matches!(
1150 err,
1151 ChunkDecodeError::UnexpectedEof { what: "u32" }
1152 ));
1153 let after_strings = 4 + "file1".len() + 4 + "test.txt".len();
1156 let err = FileChunk::from_bytes(&bytes[..after_strings + 4]).unwrap_err();
1157 assert!(matches!(
1158 err,
1159 ChunkDecodeError::UnexpectedEof { what: "u64" }
1160 ));
1161 let err = FileChunk::from_bytes(&bytes[..bytes.len() - 1]).unwrap_err();
1163 assert!(matches!(
1164 err,
1165 ChunkDecodeError::UnexpectedEof { what: "bytes" }
1166 ));
1167 }
1168
1169 #[test]
1170 fn test_from_bytes_oversized_length_prefix_is_field_too_long() {
1171 let mut bytes = sample_chunk().to_bytes();
1172 let huge = (FileChunk::MAX_STRING_FIELD_LEN as u32 + 1).to_le_bytes();
1174 bytes[..4].copy_from_slice(&huge);
1175 let err = FileChunk::from_bytes(&bytes).unwrap_err();
1176 assert!(matches!(
1177 err,
1178 ChunkDecodeError::FieldTooLong {
1179 field: "file_id_len",
1180 ..
1181 }
1182 ));
1183 }
1184
1185 #[test]
1186 fn test_from_bytes_invalid_utf8_names_the_field() {
1187 let mut bytes = sample_chunk().to_bytes();
1188 bytes[4] = 0xFF;
1190 let err = FileChunk::from_bytes(&bytes).unwrap_err();
1191 assert!(matches!(
1192 err,
1193 ChunkDecodeError::InvalidUtf8 {
1194 field: "file_id",
1195 ..
1196 }
1197 ));
1198 }
1199
1200 #[test]
1201 fn test_chunk_small_file() {
1202 let manager = FileTransferManager::new();
1203 let file_data = b"Hello, World!".to_vec();
1204
1205 let chunks = manager
1206 .chunk_file(
1207 "file1".to_string(),
1208 "test.txt".to_string(),
1209 file_data.clone(),
1210 None,
1211 )
1212 .unwrap();
1213
1214 assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].chunk_index, 0);
1216 assert_eq!(chunks[0].total_chunks, 1);
1217 assert_eq!(chunks[0].file_size, file_data.len() as u64);
1218 assert_eq!(chunks[0].chunk_data, file_data);
1219 }
1220
1221 #[test]
1222 fn test_chunk_large_file() {
1223 let config = FileTransferConfig {
1224 chunk_size: 100, max_file_size: 1024 * 1024,
1226 ..FileTransferConfig::default()
1227 };
1228 let manager = FileTransferManager::with_config(config);
1229
1230 let file_data = vec![0u8; 350]; let chunks = manager
1233 .chunk_file("file1".to_string(), "test.bin".to_string(), file_data, None)
1234 .unwrap();
1235
1236 assert_eq!(chunks.len(), 4); assert_eq!(chunks[0].chunk_data.len(), 100);
1238 assert_eq!(chunks[1].chunk_data.len(), 100);
1239 assert_eq!(chunks[2].chunk_data.len(), 100);
1240 assert_eq!(chunks[3].chunk_data.len(), 50);
1241 }
1242
1243 #[test]
1244 fn test_file_too_large() {
1245 let config = FileTransferConfig {
1246 chunk_size: 1024,
1247 max_file_size: 1000,
1248 ..FileTransferConfig::default()
1249 };
1250 let manager = FileTransferManager::with_config(config);
1251
1252 let file_data = vec![0u8; 2000]; let result = manager.chunk_file(
1255 "file1".to_string(),
1256 "large.bin".to_string(),
1257 file_data,
1258 None,
1259 );
1260 assert!(result.is_err());
1261 }
1262
1263 #[test]
1264 fn test_empty_file() {
1265 let manager = FileTransferManager::new();
1266 let result = manager.chunk_file("file1".to_string(), "empty.txt".to_string(), vec![], None);
1267 assert!(result.is_err());
1268 }
1269
1270 #[test]
1271 fn test_zero_chunk_size_override_rejected() {
1272 let manager = FileTransferManager::new();
1273 let result = manager.chunk_file(
1274 "file1".to_string(),
1275 "test.txt".to_string(),
1276 b"hello".to_vec(),
1277 Some(0),
1278 );
1279 assert!(result.is_err());
1280 }
1281
1282 #[test]
1283 fn test_reassemble_file() {
1284 let config = FileTransferConfig {
1285 chunk_size: 10,
1286 max_file_size: 1024,
1287 ..FileTransferConfig::default()
1288 };
1289 let mut manager = FileTransferManager::with_config(config);
1290
1291 let original_data = b"Hello, World!".to_vec();
1292 let chunks = manager
1293 .chunk_file(
1294 "file1".to_string(),
1295 "test.txt".to_string(),
1296 original_data.clone(),
1297 None,
1298 )
1299 .unwrap();
1300
1301 for chunk in chunks {
1303 manager.process_chunk("peer", chunk).unwrap();
1304 }
1305
1306 assert!(manager.is_complete("file1"));
1307
1308 let reassembled = manager.finalize_file("file1").unwrap();
1309 assert_eq!(reassembled, original_data);
1310 }
1311
1312 #[test]
1313 fn test_reassemble_out_of_order() {
1314 let config = FileTransferConfig {
1315 chunk_size: 10,
1316 max_file_size: 1024,
1317 ..FileTransferConfig::default()
1318 };
1319 let mut manager = FileTransferManager::with_config(config);
1320
1321 let original_data = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_vec();
1322 let mut chunks = manager
1323 .chunk_file(
1324 "file1".to_string(),
1325 "alphabet.txt".to_string(),
1326 original_data.clone(),
1327 None,
1328 )
1329 .unwrap();
1330
1331 chunks.reverse();
1333
1334 for chunk in chunks {
1336 manager.process_chunk("peer", chunk).unwrap();
1337 }
1338
1339 assert!(manager.is_complete("file1"));
1340
1341 let reassembled = manager.finalize_file("file1").unwrap();
1342 assert_eq!(reassembled, original_data);
1343 }
1344
1345 #[test]
1346 fn test_progress_tracking() {
1347 let config = FileTransferConfig {
1348 chunk_size: 100,
1349 max_file_size: 1024,
1350 ..FileTransferConfig::default()
1351 };
1352 let mut manager = FileTransferManager::with_config(config);
1353
1354 let file_data = vec![0u8; 250]; let chunks = manager
1356 .chunk_file("file1".to_string(), "test.bin".to_string(), file_data, None)
1357 .unwrap();
1358
1359 assert_eq!(chunks.len(), 3);
1360
1361 let progress = manager.process_chunk("peer", chunks[0].clone()).unwrap();
1363 assert_eq!(progress.chunks_completed, 1);
1364 assert_eq!(progress.total_chunks, 3);
1365 assert_eq!(progress.percentage, 33); let progress = manager.process_chunk("peer", chunks[1].clone()).unwrap();
1369 assert_eq!(progress.chunks_completed, 2);
1370 assert_eq!(progress.percentage, 66); let progress = manager.process_chunk("peer", chunks[2].clone()).unwrap();
1374 assert_eq!(progress.chunks_completed, 3);
1375 assert_eq!(progress.percentage, 100); assert!(manager.is_complete("file1"));
1378 }
1379
1380 #[test]
1381 fn test_duplicate_chunk() {
1382 let mut manager = FileTransferManager::new();
1383
1384 let file_data = b"Test data".to_vec();
1385 let chunks = manager
1386 .chunk_file("file1".to_string(), "test.txt".to_string(), file_data, None)
1387 .unwrap();
1388
1389 manager.process_chunk("peer", chunks[0].clone()).unwrap();
1391 manager.process_chunk("peer", chunks[0].clone()).unwrap();
1392
1393 let progress = manager.get_progress("file1").unwrap();
1395 assert_eq!(progress.chunks_completed, 1);
1396 }
1397
1398 #[test]
1399 fn test_multiple_files() {
1400 let config = FileTransferConfig {
1401 chunk_size: 10,
1402 max_file_size: 1024,
1403 ..FileTransferConfig::default()
1404 };
1405 let mut manager = FileTransferManager::with_config(config);
1406
1407 let data1 = b"File 1 data".to_vec();
1408 let data2 = b"File 2 data".to_vec();
1409
1410 let chunks1 = manager
1411 .chunk_file(
1412 "file1".to_string(),
1413 "file1.txt".to_string(),
1414 data1.clone(),
1415 None,
1416 )
1417 .unwrap();
1418 let chunks2 = manager
1419 .chunk_file(
1420 "file2".to_string(),
1421 "file2.txt".to_string(),
1422 data2.clone(),
1423 None,
1424 )
1425 .unwrap();
1426
1427 for chunk in chunks1 {
1429 manager.process_chunk("peer", chunk).unwrap();
1430 }
1431 for chunk in chunks2 {
1432 manager.process_chunk("peer", chunk).unwrap();
1433 }
1434
1435 assert_eq!(manager.active_transfer_count(), 2);
1436 assert!(manager.is_complete("file1"));
1437 assert!(manager.is_complete("file2"));
1438 }
1439
1440 #[test]
1441 fn test_cancel_transfer() {
1442 let mut manager = FileTransferManager::new();
1443
1444 let file_data = b"Test data".to_vec();
1445 let chunks = manager
1446 .chunk_file("file1".to_string(), "test.txt".to_string(), file_data, None)
1447 .unwrap();
1448
1449 manager.process_chunk("peer", chunks[0].clone()).unwrap();
1450 assert_eq!(manager.active_transfer_count(), 1);
1451
1452 assert!(manager.cancel_transfer("file1"));
1453 assert_eq!(manager.active_transfer_count(), 0);
1454 assert!(!manager.is_complete("file1"));
1455 }
1456
1457 #[test]
1458 fn test_get_progress() {
1459 let mut manager = FileTransferManager::new();
1460
1461 let file_data = b"Test data".to_vec();
1462 let chunks = manager
1463 .chunk_file("file1".to_string(), "test.txt".to_string(), file_data, None)
1464 .unwrap();
1465
1466 manager.process_chunk("peer", chunks[0].clone()).unwrap();
1467
1468 let progress = manager.get_progress("file1").unwrap();
1469 assert_eq!(progress.file_id, "file1");
1470 assert_eq!(progress.file_name, "test.txt");
1471 assert!(progress.percentage > 0);
1472 }
1473
1474 #[test]
1475 fn test_progress_nonexistent_file() {
1476 let manager = FileTransferManager::new();
1477 assert!(manager.get_progress("nonexistent").is_none());
1478 }
1479
1480 #[test]
1481 fn test_calculate_percentage() {
1482 assert_eq!(FileProgress::calculate_percentage(0, 10), 0);
1483 assert_eq!(FileProgress::calculate_percentage(5, 10), 50);
1484 assert_eq!(FileProgress::calculate_percentage(10, 10), 100);
1485 assert_eq!(FileProgress::calculate_percentage(1, 3), 33);
1486 }
1487
1488 #[test]
1489 fn test_invalid_chunk_index_rejected() {
1490 let mut manager = FileTransferManager::new();
1491 let chunks = manager
1492 .chunk_file(
1493 "file1".to_string(),
1494 "test.txt".to_string(),
1495 b"hello world".to_vec(),
1496 None,
1497 )
1498 .unwrap();
1499
1500 let mut invalid_chunk = chunks[0].clone();
1501 invalid_chunk.chunk_index = invalid_chunk.total_chunks;
1502
1503 assert!(manager.process_chunk("peer", invalid_chunk).is_err());
1504 assert!(manager.get_progress("file1").is_none());
1505 }
1506
1507 fn attack_chunk(
1508 file_id: &str,
1509 file_size: u64,
1510 total_chunks: u32,
1511 chunk_index: u32,
1512 data: Vec<u8>,
1513 ) -> FileChunk {
1514 FileChunk {
1515 file_id: file_id.to_string(),
1516 file_name: "attack.bin".to_string(),
1517 file_size,
1518 total_chunks,
1519 chunk_index,
1520 chunk_data: data,
1521 file_checksum: "0000".to_string(),
1522 }
1523 }
1524
1525 #[test]
1526 fn test_huge_file_size_claim_rejected() {
1527 let mut manager = FileTransferManager::new();
1530 let chunk = attack_chunk("evil", u64::MAX, 1, 0, vec![0u8; 16]);
1531 assert!(manager.process_chunk("peer", chunk).is_err());
1532 assert_eq!(manager.active_transfer_count(), 0);
1533 assert!(!manager.is_complete("evil"));
1534 assert!(manager.finalize_file("evil").is_none());
1535 }
1536
1537 #[test]
1538 fn test_file_size_over_configured_max_rejected() {
1539 let config = FileTransferConfig {
1540 chunk_size: 1024,
1541 max_file_size: 1000,
1542 ..FileTransferConfig::default()
1543 };
1544 let mut manager = FileTransferManager::with_config(config);
1545 let chunk = attack_chunk("f", 2000, 2, 0, vec![0u8; 100]);
1546 assert!(manager.process_chunk("peer", chunk).is_err());
1547 assert_eq!(manager.active_transfer_count(), 0);
1548 }
1549
1550 #[test]
1551 fn test_zero_file_size_rejected() {
1552 let mut manager = FileTransferManager::new();
1553 let chunk = attack_chunk("f", 0, 1, 0, vec![0u8; 8]);
1554 assert!(manager.process_chunk("peer", chunk).is_err());
1555 }
1556
1557 #[test]
1558 fn test_total_chunks_exceeding_file_size_rejected() {
1559 let mut manager = FileTransferManager::new();
1561 let chunk = attack_chunk("f", 10_000, 20_000, 0, vec![0u8; 8]);
1562 assert!(manager.process_chunk("peer", chunk).is_err());
1563 }
1564
1565 #[test]
1566 fn test_total_chunks_exceeding_derived_cap_rejected() {
1567 let mut manager = FileTransferManager::new();
1569 let chunk = attack_chunk("f", 100 * 1024 * 1024, 200_000, 0, vec![0u8; 8]);
1570 assert!(manager.process_chunk("peer", chunk).is_err());
1571 }
1572
1573 #[test]
1574 fn test_empty_chunk_data_rejected() {
1575 let mut manager = FileTransferManager::new();
1576 let chunk = attack_chunk("f", 100, 2, 0, vec![]);
1577 assert!(manager.process_chunk("peer", chunk).is_err());
1578 }
1579
1580 #[test]
1581 fn test_chunk_data_larger_than_file_size_rejected() {
1582 let mut manager = FileTransferManager::new();
1583 let chunk = attack_chunk("f", 10, 1, 0, vec![0u8; 64]);
1584 assert!(manager.process_chunk("peer", chunk).is_err());
1585 }
1586
1587 #[test]
1588 fn test_cumulative_bytes_exceeding_claim_rejected() {
1589 let mut manager = FileTransferManager::new();
1590 let first = attack_chunk("f", 2048, 2, 0, vec![0u8; 1500]);
1592 assert!(manager.process_chunk("peer", first).is_ok());
1593 let second = attack_chunk("f", 2048, 2, 1, vec![0u8; 1500]);
1594 assert!(manager.process_chunk("peer", second).is_err());
1595 let progress = manager.get_progress("f").unwrap();
1597 assert_eq!(progress.chunks_completed, 1);
1598 }
1599
1600 #[test]
1601 fn test_duplicate_chunk_does_not_double_count_bytes() {
1602 let mut manager = FileTransferManager::new();
1603 let data0 = vec![7u8; 1500];
1606 let data1 = vec![9u8; 548];
1607 let mut whole = data0.clone();
1608 whole.extend_from_slice(&data1);
1609 let checksum = format!("{:x}", Sha256::digest(&whole));
1610
1611 let make = |index: u32, data: Vec<u8>| FileChunk {
1612 file_id: "f".to_string(),
1613 file_name: "file.bin".to_string(),
1614 file_size: 2048,
1615 total_chunks: 2,
1616 chunk_index: index,
1617 chunk_data: data,
1618 file_checksum: checksum.clone(),
1619 };
1620
1621 assert!(manager
1622 .process_chunk("peer", make(0, data0.clone()))
1623 .is_ok());
1624 assert!(manager.process_chunk("peer", make(0, data0)).is_ok());
1625 assert!(manager.process_chunk("peer", make(1, data1)).is_ok());
1626 assert!(manager.is_complete("f"));
1627 assert_eq!(manager.finalize_file("f").unwrap(), whole);
1628 }
1629
1630 #[test]
1631 fn test_concurrent_assembly_cap_enforced() {
1632 let config = FileTransferConfig {
1633 max_concurrent_assemblies: 2,
1634 ..FileTransferConfig::default()
1635 };
1636 let mut manager = FileTransferManager::with_config(config);
1637
1638 assert!(manager
1639 .process_chunk("peer", attack_chunk("f1", 100, 2, 0, vec![0u8; 10]))
1640 .is_ok());
1641 assert!(manager
1642 .process_chunk("peer", attack_chunk("f2", 100, 2, 0, vec![0u8; 10]))
1643 .is_ok());
1644 assert_eq!(
1646 manager
1647 .process_chunk("peer", attack_chunk("f3", 100, 2, 0, vec![0u8; 10]))
1648 .unwrap_err(),
1649 ChunkRejection::TooManyTransfers
1650 );
1651 assert!(manager
1653 .process_chunk("peer", attack_chunk("f1", 100, 2, 1, vec![0u8; 10]))
1654 .is_ok());
1655 assert!(manager.cancel_transfer("f2"));
1659 assert_eq!(
1660 manager
1661 .process_chunk("peer", attack_chunk("f3", 100, 2, 0, vec![0u8; 10]))
1662 .unwrap_err(),
1663 ChunkRejection::PreviouslyFailed
1664 );
1665 assert!(manager
1666 .process_chunk("peer", attack_chunk("f4", 100, 2, 0, vec![0u8; 10]))
1667 .is_ok());
1668 }
1669
1670 #[test]
1671 fn test_sender_quota_enforced() {
1672 let config = FileTransferConfig {
1673 max_concurrent_assemblies: 4,
1674 max_assemblies_per_sender: 1,
1675 ..FileTransferConfig::default()
1676 };
1677 let mut manager = FileTransferManager::with_config(config);
1678
1679 assert!(manager
1680 .process_chunk("alice", attack_chunk("f1", 100, 2, 0, vec![0u8; 10]))
1681 .is_ok());
1682 assert_eq!(
1684 manager
1685 .process_chunk("alice", attack_chunk("f2", 100, 2, 0, vec![0u8; 10]))
1686 .unwrap_err(),
1687 ChunkRejection::SenderQuotaExceeded
1688 );
1689 assert!(manager
1691 .process_chunk("bob", attack_chunk("f3", 100, 2, 0, vec![0u8; 10]))
1692 .is_ok());
1693 }
1694
1695 #[test]
1696 fn test_sender_mismatch_rejected() {
1697 let mut manager = FileTransferManager::new();
1698 assert!(manager
1699 .process_chunk("alice", attack_chunk("f", 100, 2, 0, vec![0u8; 10]))
1700 .is_ok());
1701 assert_eq!(
1704 manager
1705 .process_chunk("mallory", attack_chunk("f", 100, 2, 1, vec![0u8; 10]))
1706 .unwrap_err(),
1707 ChunkRejection::SenderMismatch
1708 );
1709 assert_eq!(manager.get_progress("f").unwrap().chunks_completed, 1);
1711 }
1712
1713 #[test]
1714 fn test_buffer_budget_rejects_new_transfer() {
1715 let config = FileTransferConfig {
1718 max_file_size: 1_000_000,
1719 max_total_buffered_bytes: 1_065_536,
1720 ..FileTransferConfig::default()
1721 };
1722 let mut manager = FileTransferManager::with_config(config);
1723
1724 assert!(manager
1725 .process_chunk(
1726 "alice",
1727 attack_chunk("f1", 1_000_000, 2, 0, vec![0u8; 600_000])
1728 )
1729 .is_ok());
1730 assert_eq!(
1733 manager
1734 .process_chunk(
1735 "bob",
1736 attack_chunk("f2", 1_000_000, 2, 0, vec![0u8; 500_000])
1737 )
1738 .unwrap_err(),
1739 ChunkRejection::BufferBudgetExhausted
1740 );
1741 assert_eq!(manager.active_transfer_count(), 1);
1742 assert!(manager.get_progress("f2").is_none());
1743 assert_eq!(manager.get_progress("f1").unwrap().chunks_completed, 1);
1745 }
1746
1747 #[test]
1748 fn test_buffer_budget_drops_existing_assembly() {
1749 let config = FileTransferConfig {
1750 max_file_size: 1_000_000,
1751 max_total_buffered_bytes: 1_065_536,
1752 ..FileTransferConfig::default()
1753 };
1754 let mut manager = FileTransferManager::with_config(config);
1755
1756 assert!(manager
1757 .process_chunk(
1758 "alice",
1759 attack_chunk("f1", 1_000_000, 3, 0, vec![0u8; 600_000])
1760 )
1761 .is_ok());
1762 assert!(manager
1763 .process_chunk("bob", attack_chunk("f2", 400_000, 2, 0, vec![0u8; 300_000]))
1764 .is_ok());
1765 assert_eq!(
1770 manager
1771 .process_chunk(
1772 "alice",
1773 attack_chunk("f1", 1_000_000, 3, 1, vec![0u8; 300_000])
1774 )
1775 .unwrap_err(),
1776 ChunkRejection::BufferBudgetExhausted
1777 );
1778 assert!(manager.get_progress("f1").is_none());
1779 assert_eq!(manager.active_transfer_count(), 1);
1780 assert!(manager
1782 .process_chunk("bob", attack_chunk("f2", 400_000, 2, 1, vec![0u8; 100_000]))
1783 .is_ok());
1784 }
1785
1786 #[test]
1787 fn test_budget_charges_per_chunk_overhead() {
1788 let config = FileTransferConfig {
1792 max_file_size: 10_000,
1793 max_total_buffered_bytes: 0,
1794 ..FileTransferConfig::default()
1795 };
1796 let mut manager = FileTransferManager::with_config(config);
1797
1798 for i in 0..1024u32 {
1800 manager
1801 .process_chunk("alice", attack_chunk("f1", 10_000, 1024, i, vec![0u8; 1]))
1802 .unwrap();
1803 }
1804
1805 let mut rejected = None;
1808 for i in 0..1024u32 {
1809 if let Err(e) =
1810 manager.process_chunk("bob", attack_chunk("f2", 10_000, 1024, i, vec![0u8; 1]))
1811 {
1812 rejected = Some((i, e));
1813 break;
1814 }
1815 }
1816 let (index, err) = rejected.expect("tiny-chunk flood must exhaust the budget");
1817 assert_eq!(err, ChunkRejection::BufferBudgetExhausted);
1818 let total_payload = 1024 + u64::from(index);
1819 assert!(
1820 total_payload < 2_000,
1821 "rejection must be overhead-driven, but {} payload bytes were buffered",
1822 total_payload
1823 );
1824 assert!(manager.get_progress("f2").is_none());
1826 }
1827
1828 #[test]
1829 fn test_manual_sender_cannot_be_a_wire_user_id() {
1830 assert!(offline_protocol_core::UserId::new(FileTransferManager::MANUAL_SENDER).is_err());
1834 }
1835
1836 #[test]
1837 fn test_resource_rejected_transfer_is_tombstoned() {
1838 let config = FileTransferConfig {
1843 max_concurrent_assemblies: 1,
1844 ..FileTransferConfig::default()
1845 };
1846 let mut manager = FileTransferManager::with_config(config);
1847
1848 assert!(manager
1849 .process_chunk("alice", attack_chunk("f1", 100, 2, 0, vec![0u8; 10]))
1850 .is_ok());
1851 assert_eq!(
1852 manager
1853 .process_chunk("bob", attack_chunk("f2", 1000, 10, 0, vec![0u8; 100]))
1854 .unwrap_err(),
1855 ChunkRejection::TooManyTransfers
1856 );
1857 for index in 1..10u32 {
1858 assert_eq!(
1859 manager
1860 .process_chunk("bob", attack_chunk("f2", 1000, 10, index, vec![0u8; 100]))
1861 .unwrap_err(),
1862 ChunkRejection::PreviouslyFailed
1863 );
1864 }
1865 assert!(manager.cancel_transfer("f1"));
1868 assert_eq!(
1869 manager
1870 .process_chunk("bob", attack_chunk("f2", 1000, 10, 5, vec![0u8; 100]))
1871 .unwrap_err(),
1872 ChunkRejection::PreviouslyFailed
1873 );
1874 assert_eq!(manager.active_transfer_count(), 0);
1875 }
1876
1877 #[test]
1878 fn test_budget_dropped_transfer_cannot_resurrect() {
1879 let config = FileTransferConfig {
1880 max_file_size: 1_000_000,
1881 max_total_buffered_bytes: 1_065_536,
1882 ..FileTransferConfig::default()
1883 };
1884 let mut manager = FileTransferManager::with_config(config);
1885
1886 assert!(manager
1887 .process_chunk(
1888 "alice",
1889 attack_chunk("f1", 1_000_000, 3, 0, vec![0u8; 600_000])
1890 )
1891 .is_ok());
1892 assert!(manager
1893 .process_chunk("bob", attack_chunk("f2", 400_000, 2, 0, vec![0u8; 300_000]))
1894 .is_ok());
1895 assert_eq!(
1896 manager
1897 .process_chunk(
1898 "alice",
1899 attack_chunk("f1", 1_000_000, 3, 1, vec![0u8; 300_000])
1900 )
1901 .unwrap_err(),
1902 ChunkRejection::BufferBudgetExhausted
1903 );
1904 assert_eq!(
1908 manager
1909 .process_chunk(
1910 "alice",
1911 attack_chunk("f1", 1_000_000, 3, 2, vec![0u8; 100_000])
1912 )
1913 .unwrap_err(),
1914 ChunkRejection::PreviouslyFailed
1915 );
1916 assert!(manager.get_progress("f1").is_none());
1917 assert_eq!(manager.active_transfer_count(), 1);
1918 }
1919
1920 #[test]
1921 fn test_finalize_integrity_failure_tombstones_id() {
1922 let mut manager = FileTransferManager::new();
1923
1924 let data = vec![3u8; 100];
1927 let checksum = format!("{:x}", Sha256::digest(&data));
1928 let chunk = FileChunk {
1929 file_id: "f".to_string(),
1930 file_name: "bad.bin".to_string(),
1931 file_size: 2048,
1932 total_chunks: 1,
1933 chunk_index: 0,
1934 chunk_data: data,
1935 file_checksum: checksum,
1936 };
1937 assert!(manager.process_chunk("peer", chunk.clone()).is_ok());
1938 assert!(manager.is_complete("f"));
1939 assert!(manager.finalize_file("f").is_none());
1940
1941 assert_eq!(
1943 manager.process_chunk("peer", chunk).unwrap_err(),
1944 ChunkRejection::PreviouslyFailed
1945 );
1946 assert_eq!(manager.active_transfer_count(), 0);
1947 }
1948
1949 #[test]
1950 fn test_stale_swept_transfer_is_tombstoned() {
1951 let mut manager = FileTransferManager::new();
1952
1953 assert!(manager
1954 .process_chunk("peer", attack_chunk("f", 200, 2, 0, vec![0u8; 100]))
1955 .is_ok());
1956 manager.backdate_transfers(Duration::from_secs(2));
1957 let swept = manager.cleanup_stale_transfers(Duration::from_secs(1));
1958 assert_eq!(swept.len(), 1);
1959
1960 assert_eq!(
1963 manager
1964 .process_chunk("peer", attack_chunk("f", 200, 2, 1, vec![0u8; 100]))
1965 .unwrap_err(),
1966 ChunkRejection::PreviouslyFailed
1967 );
1968 assert_eq!(manager.active_transfer_count(), 0);
1969 }
1970
1971 #[test]
1972 fn test_tombstone_expires_after_max_age() {
1973 let config = FileTransferConfig {
1974 max_concurrent_assemblies: 1,
1975 ..FileTransferConfig::default()
1976 };
1977 let mut manager = FileTransferManager::with_config(config);
1978
1979 assert!(manager
1980 .process_chunk("alice", attack_chunk("f1", 100, 2, 0, vec![0u8; 10]))
1981 .is_ok());
1982 assert_eq!(
1983 manager
1984 .process_chunk("bob", attack_chunk("f2", 100, 2, 0, vec![0u8; 10]))
1985 .unwrap_err(),
1986 ChunkRejection::TooManyTransfers
1987 );
1988 assert!(manager.cancel_transfer("f1"));
1989
1990 manager.backdate_transfers(Duration::from_secs(2));
1994 manager.cleanup_stale_transfers(Duration::from_secs(1));
1995 assert!(manager
1996 .process_chunk("bob", attack_chunk("f2", 100, 2, 0, vec![0u8; 10]))
1997 .is_ok());
1998 }
1999
2000 #[test]
2001 fn test_cancel_does_not_tombstone() {
2002 let mut manager = FileTransferManager::new();
2006 assert!(manager
2007 .process_chunk("peer", attack_chunk("f", 100, 2, 0, vec![0u8; 10]))
2008 .is_ok());
2009 assert!(manager.cancel_transfer("f"));
2010 assert!(manager
2011 .process_chunk("peer", attack_chunk("f", 100, 2, 0, vec![0u8; 10]))
2012 .is_ok());
2013 assert_eq!(manager.active_transfer_count(), 1);
2014 }
2015
2016 #[test]
2017 fn test_duplicate_chunk_resent_with_different_size_replaces() {
2018 let mut manager = FileTransferManager::new();
2022 let data0 = vec![7u8; 1500];
2023 let data1 = vec![9u8; 548];
2024 let mut whole = data0.clone();
2025 whole.extend_from_slice(&data1);
2026 let checksum = format!("{:x}", Sha256::digest(&whole));
2027
2028 let make = |index: u32, data: Vec<u8>| FileChunk {
2029 file_id: "f".to_string(),
2030 file_name: "file.bin".to_string(),
2031 file_size: 2048,
2032 total_chunks: 2,
2033 chunk_index: index,
2034 chunk_data: data,
2035 file_checksum: checksum.clone(),
2036 };
2037
2038 assert!(manager
2040 .process_chunk("peer", make(0, vec![0u8; 1400]))
2041 .is_ok());
2042 assert!(manager.process_chunk("peer", make(0, data0)).is_ok());
2043 assert!(manager.process_chunk("peer", make(1, data1)).is_ok());
2044 assert!(manager.is_complete("f"));
2045 assert_eq!(manager.finalize_file("f").unwrap(), whole);
2048 }
2049
2050 #[test]
2051 fn test_single_chunk_at_exactly_file_size_completes() {
2052 let mut manager = FileTransferManager::new();
2053 let data = vec![4u8; 512];
2054 let checksum = format!("{:x}", Sha256::digest(&data));
2055 let chunk = FileChunk {
2056 file_id: "f".to_string(),
2057 file_name: "exact.bin".to_string(),
2058 file_size: 512,
2059 total_chunks: 1,
2060 chunk_index: 0,
2061 chunk_data: data.clone(),
2062 file_checksum: checksum,
2063 };
2064 assert!(manager.process_chunk("peer", chunk).is_ok());
2065 assert!(manager.is_complete("f"));
2066 assert_eq!(manager.finalize_file("f").unwrap(), data);
2067 }
2068
2069 #[test]
2070 fn test_file_at_exactly_max_file_size_completes() {
2071 let config = FileTransferConfig {
2075 chunk_size: 512,
2076 max_file_size: 1000,
2077 max_total_buffered_bytes: 1000,
2078 ..FileTransferConfig::default()
2079 };
2080 let mut manager = FileTransferManager::with_config(config);
2081
2082 let file_data = vec![5u8; 1000];
2083 let chunks = manager
2084 .chunk_file(
2085 "f".to_string(),
2086 "max.bin".to_string(),
2087 file_data.clone(),
2088 None,
2089 )
2090 .unwrap();
2091 for chunk in chunks {
2092 manager.process_chunk("peer", chunk).unwrap();
2093 }
2094 assert_eq!(manager.finalize_file("f").unwrap(), file_data);
2095 }
2096
2097 #[test]
2098 fn test_chunk_file_rejects_chunking_finer_than_receive_cap() {
2099 let config = FileTransferConfig {
2104 chunk_size: 512,
2105 max_file_size: 1024 * 1024,
2106 ..FileTransferConfig::default()
2107 };
2108 let manager = FileTransferManager::with_config(config);
2109 let result = manager.chunk_file(
2110 "f".to_string(),
2111 "big.bin".to_string(),
2112 vec![0u8; 1024 * 1024],
2113 None,
2114 );
2115 assert!(result.is_err());
2116 }
2117
2118 #[test]
2119 fn test_chunk_file_small_chunk_size_round_trips_within_cap() {
2120 let config = FileTransferConfig {
2124 chunk_size: 512,
2125 max_file_size: 1024 * 1024,
2126 ..FileTransferConfig::default()
2127 };
2128 let mut manager = FileTransferManager::with_config(config);
2129 let file_data: Vec<u8> = (0..400 * 1024).map(|i| (i % 251) as u8).collect();
2130 let chunks = manager
2131 .chunk_file(
2132 "f".to_string(),
2133 "ok.bin".to_string(),
2134 file_data.clone(),
2135 None,
2136 )
2137 .unwrap();
2138 assert_eq!(chunks.len(), 800);
2139 for chunk in chunks {
2140 manager.process_chunk("peer", chunk).unwrap();
2141 }
2142 assert_eq!(manager.finalize_file("f").unwrap(), file_data);
2143 }
2144
2145 #[test]
2146 fn test_total_chunks_at_derived_cap_accepted() {
2147 let config = FileTransferConfig {
2149 max_file_size: 1024 * 1024,
2150 ..FileTransferConfig::default()
2151 };
2152 let mut manager = FileTransferManager::with_config(config);
2153 assert!(manager
2154 .process_chunk(
2155 "peer",
2156 attack_chunk("f1", 1024 * 1024, 1025, 0, vec![0u8; 8])
2157 )
2158 .is_ok());
2159 assert_eq!(
2160 manager
2161 .process_chunk(
2162 "peer",
2163 attack_chunk("f2", 1024 * 1024, 1026, 0, vec![0u8; 8])
2164 )
2165 .unwrap_err(),
2166 ChunkRejection::Invalid
2167 );
2168 }
2169
2170 #[test]
2171 fn test_duplicate_chunk_replaces_data() {
2172 let mut manager = FileTransferManager::new();
2173 let stale = vec![1u8; 100];
2176 let fresh = vec![2u8; 100];
2177 let tail = vec![3u8; 50];
2178 let mut whole = fresh.clone();
2179 whole.extend_from_slice(&tail);
2180 let checksum = format!("{:x}", Sha256::digest(&whole));
2181
2182 let make = |index: u32, data: Vec<u8>| FileChunk {
2183 file_id: "f".to_string(),
2184 file_name: "file.bin".to_string(),
2185 file_size: 150,
2186 total_chunks: 2,
2187 chunk_index: index,
2188 chunk_data: data,
2189 file_checksum: checksum.clone(),
2190 };
2191
2192 manager.process_chunk("peer", make(0, stale)).unwrap();
2193 manager.process_chunk("peer", make(0, fresh)).unwrap();
2194 manager.process_chunk("peer", make(1, tail)).unwrap();
2195 assert_eq!(manager.finalize_file("f").unwrap(), whole);
2196 }
2197
2198 #[test]
2199 fn test_finalize_rejects_size_mismatch() {
2200 let mut manager = FileTransferManager::new();
2201 let data = vec![3u8; 100];
2204 let checksum = format!("{:x}", Sha256::digest(&data));
2205 let chunk = FileChunk {
2206 file_id: "f".to_string(),
2207 file_name: "file.bin".to_string(),
2208 file_size: 2048,
2209 total_chunks: 1,
2210 chunk_index: 0,
2211 chunk_data: data,
2212 file_checksum: checksum,
2213 };
2214 assert!(manager.process_chunk("peer", chunk).is_ok());
2215 assert!(manager.is_complete("f"));
2216 assert!(manager.finalize_file("f").is_none());
2217 assert_eq!(manager.active_transfer_count(), 0);
2219 }
2220
2221 #[test]
2222 fn test_cleanup_stale_transfers() {
2223 let mut manager = FileTransferManager::new();
2224 let chunks = manager
2225 .chunk_file(
2226 "file1".to_string(),
2227 "test.txt".to_string(),
2228 vec![1u8; 64 * 1024],
2229 None,
2230 )
2231 .unwrap();
2232
2233 manager.process_chunk("peer", chunks[0].clone()).unwrap();
2234 std::thread::sleep(Duration::from_millis(3));
2235
2236 let removed = manager.cleanup_stale_transfers(Duration::from_millis(1));
2237 assert_eq!(
2238 removed,
2239 vec![StaleTransfer {
2240 file_id: "file1".to_string(),
2241 file_name: "test.txt".to_string(),
2242 sender: "peer".to_string(),
2243 }]
2244 );
2245 assert!(manager.get_progress("file1").is_none());
2246 }
2247}