Skip to main content

offline_protocol/
file_transfer.rs

1//! File transfer with chunking and reassembly.
2
3use 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/// File transfer configuration.
32#[derive(Debug, Clone)]
33pub struct FileTransferConfig {
34    /// Size of each chunk in bytes. Chunk sizes that would split a file
35    /// into more chunks than the receive-side cap derived from
36    /// `max_file_size` (one chunk per KiB of `max_file_size`, minimum 1024)
37    /// are rejected by [`FileTransferManager::chunk_file`] — a receiver
38    /// with the same configuration would drop every chunk of such a
39    /// transfer.
40    pub chunk_size: usize,
41
42    /// Maximum file size allowed (bytes).
43    pub max_file_size: u64,
44
45    /// Maximum number of inbound files that may be reassembling at once.
46    /// Chunks that would start an additional assembly are rejected; slots
47    /// free when a transfer completes, is cancelled, or goes stale.
48    pub max_concurrent_assemblies: usize,
49
50    /// Maximum number of inbound files a single sender may have reassembling
51    /// at once, so one peer cannot occupy every assembly slot. Only as
52    /// strong as sender authentication: MLS-path senders are
53    /// cryptographically bound, but legacy plaintext senders are wire
54    /// claims a peer can rotate — there, this quota bounds only honest
55    /// peers, and the global count and byte caps are the real limits.
56    pub max_assemblies_per_sender: usize,
57
58    /// Maximum total bytes buffered across all inbound assemblies. This —
59    /// not the assembly counts — is the hard bound on receive-path memory.
60    /// Each stored chunk is charged its payload plus a small fixed
61    /// bookkeeping overhead, so floods of tiny chunks cannot pin more
62    /// memory than the budget admits. Values below what a single
63    /// `max_file_size` transfer charges are clamped up to it by
64    /// [`FileTransferManager::with_config`], or a maximum-size file could
65    /// never complete.
66    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, // 100 MB
74            max_concurrent_assemblies: 32,
75            max_assemblies_per_sender: 16,
76            max_total_buffered_bytes: 128 * 1024 * 1024, // 128 MB
77        }
78    }
79}
80
81/// Why [`FileTransferManager::process_chunk`] refused a chunk.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ChunkRejection {
84    /// Wire fields are malformed or out of bounds: zero or oversized size
85    /// claims, empty or oversized data, or bytes exceeding the claimed
86    /// file size.
87    Invalid,
88    /// Chunk metadata does not match the existing assembly for this file id.
89    MetadataMismatch,
90    /// Chunk sender does not match the sender that started the assembly.
91    SenderMismatch,
92    /// The global concurrent-assembly cap is reached.
93    TooManyTransfers,
94    /// The sender's concurrent-assembly quota is reached.
95    SenderQuotaExceeded,
96    /// Accepting the chunk would exceed the global buffered-bytes budget.
97    /// The affected assembly is dropped along with the chunk: a dropped
98    /// chunk is never retransmitted, so the transfer could no longer
99    /// complete and its buffer would only prolong the exhaustion.
100    BufferBudgetExhausted,
101    /// The chunk belongs to a transfer that already failed (resource drop,
102    /// integrity failure, or stale sweep). Its remaining in-flight chunks
103    /// are dropped without re-creating assembly state or re-notifying the
104    /// application — `FileReceiveFailed` fires at most once per transfer.
105    /// Retries must use a fresh `file_id`; the failed id is admitted again
106    /// only after its tombstone expires (no chunks for the stale timeout).
107    PreviouslyFailed,
108}
109
110impl ChunkRejection {
111    /// `true` for rejections caused by receiver resource limits rather than
112    /// malformed input — the cases where a well-formed transfer was lost and
113    /// the application should be told.
114    pub fn is_resource_exhaustion(self) -> bool {
115        matches!(
116            self,
117            Self::TooManyTransfers | Self::SenderQuotaExceeded | Self::BufferBudgetExhausted
118        )
119    }
120
121    /// Stable machine-readable reason string, carried by the
122    /// `FileReceiveFailed` event.
123    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/// Why [`FileChunk::from_bytes`] rejected a binary payload.
137// Adding a variant to a public error enum is a breaking change without
138// this attribute; downstream crates must carry a wildcard arm.
139#[non_exhaustive]
140#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
141pub enum ChunkDecodeError {
142    /// The payload ended before the named value could be read.
143    #[error("unexpected end of data reading {what}")]
144    UnexpectedEof {
145        /// What was being read (`"u32"`, `"u64"`, or `"bytes"`).
146        what: &'static str,
147    },
148    /// A length prefix exceeds the wire-format cap for its field.
149    #[error("{field} {len} exceeds maximum {max}")]
150    FieldTooLong {
151        /// Name of the offending length field.
152        field: &'static str,
153        /// Claimed length.
154        len: usize,
155        /// Maximum allowed length.
156        max: usize,
157    },
158    /// A string field holds invalid UTF-8.
159    #[error("invalid {field} UTF-8: {source}")]
160    InvalidUtf8 {
161        /// Name of the offending string field.
162        field: &'static str,
163        /// The underlying UTF-8 decode failure.
164        source: std::string::FromUtf8Error,
165    },
166}
167
168/// A file chunk message.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct FileChunk {
171    /// Unique file identifier.
172    pub file_id: String,
173
174    /// Original file name.
175    pub file_name: String,
176
177    /// Total file size in bytes.
178    pub file_size: u64,
179
180    /// Total number of chunks.
181    pub total_chunks: u32,
182
183    /// Index of this chunk (0-based).
184    pub chunk_index: u32,
185
186    /// Chunk data (base64-encoded in JSON).
187    #[serde(with = "base64_bytes")]
188    pub chunk_data: Vec<u8>,
189
190    /// Checksum of the complete file (SHA256 hex string).
191    pub file_checksum: String,
192}
193
194impl FileChunk {
195    /// Serializes to JSON (kept for backward compatibility).
196    pub fn to_json(&self) -> Result<String, serde_json::Error> {
197        serde_json::to_string(self)
198    }
199
200    /// Deserializes from JSON (kept for backward compatibility).
201    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
202        serde_json::from_str(json)
203    }
204
205    /// Serializes to a compact binary format, avoiding base64/JSON overhead.
206    ///
207    /// Wire format (all multi-byte integers are little-endian):
208    /// ```text
209    /// [file_id_len:4][file_id][file_name_len:4][file_name]
210    /// [file_size:8][total_chunks:4][chunk_index:4]
211    /// [chunk_data_len:4][chunk_data]
212    /// [checksum_len:4][checksum]
213    /// ```
214    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    /// Maximum allowed length for a string field (file_id, file_name, checksum)
248    /// in the binary wire format. Prevents allocation bombs from crafted
249    /// payloads. `pub(crate)`: also bounds caller-supplied file ids at the
250    /// `send_media_with` boundary.
251    pub(crate) const MAX_STRING_FIELD_LEN: usize = 4096;
252    /// Maximum allowed chunk data length in the binary wire format.
253    const MAX_CHUNK_DATA_LEN: usize = 128 * 1024 * 1024; // 128 MB
254
255    /// Deserializes from the compact binary format produced by `to_bytes`.
256    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/// File transfer progress information.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct FileProgress {
365    /// File identifier.
366    pub file_id: String,
367
368    /// File name.
369    pub file_name: String,
370
371    /// Total file size.
372    pub file_size: u64,
373
374    /// Number of chunks sent/received.
375    pub chunks_completed: u32,
376
377    /// Total number of chunks.
378    pub total_chunks: u32,
379
380    /// Progress percentage (0-100).
381    pub percentage: u8,
382}
383
384impl FileProgress {
385    /// Calculates the progress percentage.
386    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/// An incomplete transfer removed by
395/// [`FileTransferManager::cleanup_stale_transfers`].
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct StaleTransfer {
398    /// File identifier of the dropped transfer.
399    pub file_id: String,
400    /// File name from the chunk metadata.
401    pub file_name: String,
402    /// Sender the assembly was bound to.
403    pub sender: String,
404}
405
406/// Tracks file reassembly state.
407struct FileAssembly {
408    /// Sender the first accepted chunk arrived from; all later chunks must
409    /// match it.
410    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    /// Total bytes currently held in `received_chunks`.
417    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    /// Bytes this assembly charges against the global buffer budget:
445    /// payload plus a fixed per-chunk bookkeeping charge, so many tiny
446    /// chunks cannot evade the budget.
447    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(), // Will be set by caller
463            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        // Size the buffer from bytes actually received — file_size is a
480        // sender-controlled claim and must never drive an allocation.
481        let mut file_data = Vec::with_capacity(self.received_bytes as usize);
482
483        // Reassemble chunks in order
484        for i in 0..self.total_chunks {
485            let chunk_data = self.received_chunks.get(&i)?; // Missing chunk
486            file_data.extend_from_slice(chunk_data);
487        }
488
489        Some(file_data)
490    }
491}
492
493/// File transfer manager for chunking and reassembly.
494pub struct FileTransferManager {
495    config: FileTransferConfig,
496    active_assemblies: HashMap<String, FileAssembly>,
497    /// Tombstones for failed transfers, keyed by a keyed hash of the
498    /// `file_id` (never the id itself, so attacker-length ids cannot grow
499    /// this map) mapping to the last time a chunk for that id was seen.
500    /// While tombstoned, an id's chunks are dropped as `PreviouslyFailed` —
501    /// they were already ACKed and will keep streaming in, but must neither
502    /// resurrect a partial assembly nor re-emit the failure event.
503    failed_transfers: HashMap<u64, Instant>,
504    /// Insertion order of `failed_transfers` keys, for FIFO eviction at
505    /// [`Self::TOMBSTONE_CAPACITY`].
506    failed_transfer_order: VecDeque<u64>,
507    /// Randomly keyed hasher for tombstone keys: collisions (a fresh
508    /// transfer misread as previously failed) cannot be crafted by a peer
509    /// and are a ~2⁻⁵⁴ accident at capacity.
510    tombstone_hasher: RandomState,
511}
512
513impl FileTransferManager {
514    /// Floor on the average chunk payload assumed when bounding a sender's
515    /// `total_chunks` claim, so per-assembly bookkeeping stays proportional
516    /// to `max_file_size` even when every chunk is tiny. The SDK's own
517    /// senders never chunk below `CHUNK_SIZE_BLE` (4 KiB).
518    const MIN_ACCEPTED_CHUNK_PAYLOAD: u64 = 1024;
519
520    /// Lower bound on the derived `total_chunks` cap so configurations with
521    /// a small `max_file_size` still admit fine-grained chunking.
522    const MIN_TOTAL_CHUNKS_CAP: u64 = 1024;
523
524    /// Flat per-stored-chunk charge against `max_total_buffered_bytes`,
525    /// approximating the bookkeeping memory the payload sum does not see
526    /// (hashmap entry, allocator rounding). Without it, a flood of 1-byte
527    /// chunks pins far more memory than the budget records.
528    const PER_CHUNK_OVERHEAD: u64 = 64;
529
530    /// Pseudo-sender bound to assemblies fed through the manual
531    /// `process_file_chunk` FFI path, which carries no wire sender. All
532    /// manual chunks share this identity for sender binding and quota. The
533    /// `:` is rejected by wire `UserId` validation, so no remote peer can
534    /// ever claim this identity.
535    pub const MANUAL_SENDER: &'static str = "manual:ffi";
536
537    /// Most failed-transfer tombstones retained at once. Each costs ~50
538    /// bytes (hash key, timestamp, deque slot), so the set is capped around
539    /// 50 KiB. Evicting a tombstone early only means one extra
540    /// `FileReceiveFailed` if that transfer's chunks are still arriving —
541    /// filling the set costs an attacker one rejected transfer per slot.
542    const TOMBSTONE_CAPACITY: usize = 1024;
543
544    /// Creates a new file transfer manager.
545    pub fn new() -> Self {
546        Self::with_config(FileTransferConfig::default())
547    }
548
549    /// Creates a new file transfer manager with custom configuration.
550    ///
551    /// `max_total_buffered_bytes` is clamped up to what a single
552    /// `max_file_size` transfer charges (payload plus per-chunk overhead) —
553    /// any lower value would make `max_file_size` unachievable.
554    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    /// Keyed hash under which a `file_id` is tombstoned. Hashing bounds the
574    /// tombstone set's memory regardless of how long a peer makes its ids.
575    fn tombstone_key(&self, file_id: &str) -> u64 {
576        self.tombstone_hasher.hash_one(file_id)
577    }
578
579    /// Marks a transfer as failed so its remaining in-flight chunks are
580    /// dropped as [`ChunkRejection::PreviouslyFailed`] instead of
581    /// re-creating assembly state or re-notifying the application.
582    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    /// Whether `file_id` belongs to a failed transfer. A hit refreshes the
598    /// tombstone's timestamp, so it outlives the transfer's chunk stream
599    /// and expires only once chunks stop arriving for the stale timeout.
600    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    /// Upper bound on a sender's `total_chunks` claim, derived from
612    /// `max_file_size`. Floor division plus one — always at least the exact
613    /// ceiling — without u64::div_ceil (MSRV) or overflow.
614    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    /// The smallest budget that still admits one maximum-size transfer:
621    /// its payload plus the per-chunk overhead of the most chunks such a
622    /// transfer may legally claim (bounded by both the derived cap and the
623    /// one-byte-per-chunk minimum).
624    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    /// Chunks a file for sending.
633    ///
634    /// # Arguments
635    ///
636    /// * `file_id` - Unique identifier for this file transfer
637    /// * `file_name` - Name of the file
638    /// * `file_data` - Complete file data
639    /// * `chunk_size_override` - If `Some`, uses this chunk size instead of the configured default.
640    ///   Allows callers to select a transport-appropriate chunk size.
641    ///
642    /// # Returns
643    ///
644    /// Returns a vector of FileChunk ready to send. Fails if the file is
645    /// empty or exceeds `max_file_size`, if the chunk size is zero, or if
646    /// the chunking is so fine that the chunk count would exceed the
647    /// receive-side `total_chunks` cap — [`Self::process_chunk`] on a
648    /// receiver with the same `max_file_size` would silently drop every
649    /// chunk of such a transfer.
650    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        // Mirror the receive-side cap: process_chunk rejects total_chunks
678        // above this, so a finer chunking would have every chunk silently
679        // dropped by any receiver with the same max_file_size.
680        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    /// Processes a received file chunk from `sender`.
715    ///
716    /// All wire-supplied fields (`file_size`, `total_chunks`, `chunk_data`,
717    /// `file_id`) are bounded here before any assembly state is created, so
718    /// a remote peer cannot force oversized allocations or unbounded
719    /// bookkeeping. Assemblies are bound to the sender that started them,
720    /// counted against global and per-sender caps, and the bytes buffered
721    /// across all assemblies never exceed `max_total_buffered_bytes`.
722    ///
723    /// # Arguments
724    ///
725    /// * `sender` - The authenticated (or wire-claimed) sender of the chunk
726    /// * `chunk` - The received file chunk
727    ///
728    /// # Returns
729    ///
730    /// Returns `Ok(FileProgress)` with current progress, or the
731    /// [`ChunkRejection`] explaining why the chunk was dropped.
732    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        // A failed transfer's chunks were already ACKed and keep streaming
776        // in; drop them without state or (further) events. Logged at debug
777        // — a large drained transfer produces thousands of these.
778        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        // A chunk for an existing assembly must be consistent with it; a
789        // chunk starting a new assembly must fit within the count caps.
790        // Everything is checked before any state is created or mutated.
791        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                // Duplicates replace their earlier copy, so discount it
812                // before checking the cumulative received bytes against the
813                // claimed size.
814                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                // A replaced duplicate refunds its full charge — payload and
831                // overhead — since its map entry is reused, not added.
832                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                    // The rejected chunk was already ACKed and is lost for
843                    // good, so the whole transfer is unrecoverable —
844                    // tombstone it so its remaining chunks drop silently.
845                    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        // Hard memory bound: total bytes charged across every assembly,
869        // where each stored chunk costs its payload plus PER_CHUNK_OVERHEAD.
870        // Iterating is fine — the count caps above keep this map small.
871        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            // The dropped chunk is never retransmitted, so this transfer can
887            // no longer complete — free its partial buffer now instead of
888            // letting it squat the budget until the stale sweep, and
889            // tombstone the id so later chunks cannot resurrect it as a
890            // never-completable assembly.
891            self.active_assemblies.remove(&file_id);
892            self.tombstone(&file_id);
893            return Err(ChunkRejection::BufferBudgetExhausted);
894        }
895
896        // Get or create assembly for this file
897        let assembly = self
898            .active_assemblies
899            .entry(file_id.clone())
900            .or_insert_with(|| FileAssembly::new(sender, &chunk));
901
902        // Add chunk
903        assembly.add_chunk(chunk.chunk_index, chunk.chunk_data);
904
905        // Get progress
906        let mut progress = assembly.progress();
907        progress.file_id = file_id;
908
909        Ok(progress)
910    }
911
912    /// Checks if a file transfer is complete.
913    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    /// Finalizes a file transfer and returns the complete file data.
921    ///
922    /// Verifies the SHA256 checksum of the reassembled file before returning.
923    ///
924    /// # Arguments
925    ///
926    /// * `file_id` - File identifier
927    ///
928    /// # Returns
929    ///
930    /// Returns `Some(Vec<u8>)` if file is complete, its size matches the
931    /// claimed `file_size`, and the checksum matches, `None` otherwise.
932    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        // A completed transfer must deliver exactly the bytes it claimed.
937        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        // Verify checksum before returning
950        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    /// Removes stale/incomplete transfers that have not received any chunk
968    /// updates within `max_age`, returning what was dropped so callers can
969    /// surface the loss. Swept ids are tombstoned so straggler chunks
970    /// cannot resurrect them; tombstones themselves expire by the same
971    /// rule — no chunks for `max_age` — freeing the id for reuse.
972    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    /// Gets the progress of an active file transfer.
1007    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    /// Cancels an active file transfer.
1016    pub fn cancel_transfer(&mut self, file_id: &str) -> bool {
1017        self.active_assemblies.remove(file_id).is_some()
1018    }
1019
1020    /// Gets the number of active file transfers.
1021    pub fn active_transfer_count(&self) -> usize {
1022        self.active_assemblies.len()
1023    }
1024
1025    /// Rewinds every active assembly's last-update time and every tombstone
1026    /// (test-only), so staleness paths can be exercised without sleeping.
1027    #[cfg(test)]
1028    pub(crate) fn backdate_transfers(&mut self, by: Duration) {
1029        // checked_sub instead of `-=`: Instant underflows (and panics) when
1030        // the monotonic clock's epoch is closer than `by` — a fresh CI VM.
1031        // Failing loudly beats an intermittent subtraction panic.
1032        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
1050/// Tracks the sliding-window state for a single outbound file transfer.
1051///
1052/// Instead of sending all chunks at once, this limits the number of in-flight
1053/// (unACKed) chunks to `max_in_flight`, reducing outbox pressure and providing
1054/// backpressure that adapts to the transport's throughput.
1055pub 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    /// Creates a new outbound transfer state with the given chunks and window size.
1065    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    /// Returns the next batch of chunks that can be sent without exceeding the
1076    /// in-flight window. Returned chunks are marked as in-flight.
1077    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    /// Records that a chunk was ACKed. Returns `true` if this was an in-flight
1089    /// chunk (i.e. the window has room for more).
1090    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    /// Records that a chunk failed terminally and should not be retried
1096    /// at the window level (the transfer will be aborted by the caller).
1097    pub fn on_chunk_failed(&mut self, chunk_index: u32) {
1098        self.in_flight.remove(&chunk_index);
1099    }
1100
1101    /// Returns the total number of chunks in this transfer.
1102    pub fn total_chunks(&self) -> u32 {
1103        self.chunks.len() as u32
1104    }
1105
1106    /// Returns the number of chunks that have been acknowledged.
1107    pub fn acked_count(&self) -> u32 {
1108        self.acked.len() as u32
1109    }
1110
1111    /// Returns `true` when all chunks have been acknowledged.
1112    pub fn is_fully_acked(&self) -> bool {
1113        self.acked.len() == self.chunks.len()
1114    }
1115
1116    /// Returns `true` when all chunks have been sent at least once
1117    /// (they may still be in flight awaiting ACK).
1118    pub fn all_sent(&self) -> bool {
1119        self.next_unsent >= self.chunks.len()
1120    }
1121
1122    /// Returns `true` when the window has capacity for more chunks.
1123    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        // Truncate mid-header: the first u32 (file_id_len) can't be read.
1148        let err = FileChunk::from_bytes(&bytes[..2]).unwrap_err();
1149        assert!(matches!(
1150            err,
1151            ChunkDecodeError::UnexpectedEof { what: "u32" }
1152        ));
1153        // Truncate mid-header: both string fields parse but the file_size
1154        // u64 read hits EOF.
1155        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        // Truncate mid-body: length prefixes parse but the data falls short.
1162        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        // Claim a file_id longer than the wire-format cap.
1173        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        // file_id content starts right after its 4-byte length prefix.
1189        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); // Small file = 1 chunk
1215        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, // Small chunk for testing
1225            max_file_size: 1024 * 1024,
1226            ..FileTransferConfig::default()
1227        };
1228        let manager = FileTransferManager::with_config(config);
1229
1230        let file_data = vec![0u8; 350]; // 350 bytes
1231
1232        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); // 350 bytes / 100 = 4 chunks
1237        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]; // Exceeds max
1253
1254        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        // Process all chunks
1302        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        // Reverse order
1332        chunks.reverse();
1333
1334        // Process all chunks
1335        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]; // 3 chunks
1355        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        // Process first chunk
1362        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); // 1/3 ≈ 33%
1366
1367        // Process second chunk
1368        let progress = manager.process_chunk("peer", chunks[1].clone()).unwrap();
1369        assert_eq!(progress.chunks_completed, 2);
1370        assert_eq!(progress.percentage, 66); // 2/3 ≈ 66%
1371
1372        // Process third chunk
1373        let progress = manager.process_chunk("peer", chunks[2].clone()).unwrap();
1374        assert_eq!(progress.chunks_completed, 3);
1375        assert_eq!(progress.percentage, 100); // 3/3 = 100%
1376
1377        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        // Process same chunk twice
1390        manager.process_chunk("peer", chunks[0].clone()).unwrap();
1391        manager.process_chunk("peer", chunks[0].clone()).unwrap();
1392
1393        // Should still show only 1 chunk
1394        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        // Process both files
1428        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        // SEC-H2 regression: a single chunk claiming u64::MAX bytes used to
1528        // reach reassemble() and panic on Vec::with_capacity.
1529        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        // More chunks than bytes is inherently bogus (chunks are non-empty).
1560        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        // Default config: 100 MB / 1 KiB payload floor ≈ 102_400 max chunks.
1568        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        // Claim 2048 bytes in 2 chunks, then send two 1500-byte chunks.
1591        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        // The first chunk's state survives; only the offending chunk drops.
1596        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        // 1500 + 548 = 2048 claimed bytes; resending chunk 0 must replace
1604        // (not add), so the transfer still completes.
1605        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        // Third simultaneous transfer is rejected...
1645        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        // ...but chunks for existing assemblies still flow.
1652        assert!(manager
1653            .process_chunk("peer", attack_chunk("f1", 100, 2, 1, vec![0u8; 10]))
1654            .is_ok());
1655        // Freeing a slot re-admits new transfers — but not the failed id:
1656        // f3's rejected chunk was ACKed and lost, so the transfer could
1657        // never complete. A retry must use a fresh file id.
1658        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        // A second transfer from the same sender exceeds the quota...
1683        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        // ...but other senders are unaffected.
1690        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        // Same file_id and metadata from a different sender must not be able
1702        // to poison the assembly.
1703        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        // The original assembly is untouched.
1710        assert_eq!(manager.get_progress("f").unwrap().chunks_completed, 1);
1711    }
1712
1713    #[test]
1714    fn test_buffer_budget_rejects_new_transfer() {
1715        // 1_065_536 is exactly the minimum viable budget for a 1 MB
1716        // max_file_size, so with_config leaves it unclamped.
1717        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        // 600_064 + 500_064 charged would exceed the budget; the new
1731        // transfer is rejected before any of its state is created.
1732        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        // The pre-existing transfer is untouched.
1744        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        // f1's next chunk busts the budget. The dropped chunk is never
1766        // retransmitted, so f1 can no longer complete — its partial buffer
1767        // is freed immediately rather than squatting the budget until the
1768        // stale sweep.
1769        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        // The freed budget re-admits other traffic.
1781        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        // A flood of 1-byte chunks must be stopped by its bookkeeping
1789        // charge, not its (negligible) payload sum. Budget 0 is clamped up
1790        // to the minimum viable value: 10_000 + 1024 * 64 = 75_536.
1791        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        // 1024 chunks is the derived cap for this config; f1 fills it.
1799        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        // f2's identical flood must hit the budget long before its payload
1806        // (a few hundred bytes) comes anywhere near 75_536.
1807        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        // The busting transfer is dropped with its chunk.
1825        assert!(manager.get_progress("f2").is_none());
1826    }
1827
1828    #[test]
1829    fn test_manual_sender_cannot_be_a_wire_user_id() {
1830        // Sender binding for the manual FFI path relies on this sentinel
1831        // being unrepresentable as a wire UserId (':' is rejected), so no
1832        // remote peer can alias it.
1833        assert!(offline_protocol_core::UserId::new(FileTransferManager::MANUAL_SENDER).is_err());
1834    }
1835
1836    #[test]
1837    fn test_resource_rejected_transfer_is_tombstoned() {
1838        // Once a transfer is refused for a resource reason, its remaining
1839        // in-flight chunks are dropped as previously_failed — no repeated
1840        // resource rejections (which would re-emit FileReceiveFailed
1841        // upstream) and no ghost assembly, even after capacity frees up.
1842        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        // Even with the slot freed, the failed id stays dead: its first
1866        // chunk was ACKed and lost, so the transfer could never complete.
1867        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        // f1's remaining in-flight chunk must not start a fresh assembly —
1905        // it would miss the dropped chunk forever, squatting a slot and the
1906        // budget until the stale sweep and then failing a second time.
1907        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        // Checksum matches the bytes but the claimed file_size does not, so
1925        // finalize fails its size check and drops the transfer.
1926        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        // The failed id cannot be restarted by resending its chunks.
1942        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        // A straggler chunk must not resurrect the swept transfer — the
1961        // caller already emitted its stale_timeout failure.
1962        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        // Once no chunk for the failed id has been seen for max_age, the
1991        // stale sweep releases it and the id may be reused (the manual FFI
1992        // path retries with app-chosen ids).
1993        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        // Cancellation is an app decision, not a failure — the id may be
2003        // restarted immediately (nothing was lost that a full resend
2004        // cannot supply).
2005        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        // A duplicate whose payload differs in size must fully replace the
2019        // earlier copy in both bytes and budget accounting, so the transfer
2020        // still completes and delivers exactly the claimed size.
2021        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        // Undersized garbage first, then the real 1500-byte chunk 0.
2039        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        // finalize checks reassembled length against the claimed file_size,
2046        // so this also proves the replacement byte accounting is exact.
2047        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        // The configured budget equals max_file_size, which cannot cover
2072        // the per-chunk overhead — with_config clamps it up so a
2073        // maximum-size file still completes.
2074        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        // A 1 MiB max_file_size derives a receive-side cap of 1025 chunks;
2100        // chunking a full-size file at 512 bytes would need 2048. Every
2101        // chunk of such a transfer would be ACKed then dropped by the
2102        // receiver, so the send side must refuse to produce it.
2103        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        // Small chunk sizes stay valid while the chunk count fits the
2121        // receive-side cap: 400 KiB at 512 bytes is 800 chunks (cap 1025),
2122        // and a same-config receiver accepts the whole transfer.
2123        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        // max_file_size 1 MiB → cap = 1 MiB / 1 KiB + 1 = 1025.
2148        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        // A resent chunk 0 with different bytes replaces the first copy; the
2174        // final file must contain the replacement.
2175        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        // Single 100-byte chunk with a valid checksum of those bytes, but a
2202        // claimed file_size of 2048.
2203        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        // The failed transfer is discarded.
2218        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}