phantom_protocol/transport/
fragmentation.rs1use borsh::{BorshDeserialize, BorshSerialize};
2use std::collections::HashMap;
3use std::time::{Duration, Instant};
4
5const MAX_UDP_PAYLOAD: usize = 1200; pub const MAX_REASSEMBLED_LEN: usize = 256 * 1024;
11
12pub const MAX_TOTAL_CHUNKS: u16 = (MAX_REASSEMBLED_LEN / MAX_UDP_PAYLOAD + 1) as u16;
16
17pub const MAX_CONCURRENT_ASSEMBLIES: usize = 256;
23
24#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
26pub struct CryptoFrame {
27 pub session_id: [u8; 16], pub packet_id: u32,
29 pub chunk_index: u16,
30 pub total_chunks: u16,
31 pub payload: Vec<u8>,
32}
33
34pub struct FragmentAssembler {
35 assemblies: HashMap<([u8; 16], u32), AssemblyState>,
37}
38
39struct AssemblyState {
40 chunks: HashMap<u16, Vec<u8>>,
41 total_chunks: u16,
42 last_update: Instant,
43}
44
45impl Default for FragmentAssembler {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl FragmentAssembler {
52 pub fn new() -> Self {
53 Self {
54 assemblies: HashMap::new(),
55 }
56 }
57
58 pub fn process_chunk(&mut self, frame: CryptoFrame) -> Option<Vec<u8>> {
61 if frame.total_chunks == 0
69 || frame.total_chunks > MAX_TOTAL_CHUNKS
70 || frame.chunk_index >= frame.total_chunks
71 || frame.payload.len() > MAX_UDP_PAYLOAD
72 {
73 return None;
74 }
75
76 let key = (frame.session_id, frame.packet_id);
77
78 if !self.assemblies.contains_key(&key) && self.assemblies.len() >= MAX_CONCURRENT_ASSEMBLIES
84 {
85 self.evict_stalest();
86 }
87
88 let is_complete = {
89 let state = self.assemblies.entry(key).or_insert_with(|| AssemblyState {
90 chunks: HashMap::new(),
91 total_chunks: frame.total_chunks,
92 last_update: Instant::now(),
93 });
94
95 state.last_update = Instant::now();
96 state
101 .chunks
102 .entry(frame.chunk_index)
103 .or_insert(frame.payload);
104
105 state.chunks.len() == state.total_chunks as usize
106 };
107
108 if is_complete {
109 #[allow(clippy::unwrap_used, clippy::disallowed_methods)]
113 let state = self.assemblies.remove(&key).unwrap();
114 let mut total_size = 0;
115 for i in 0..state.total_chunks {
116 if let Some(chunk) = state.chunks.get(&i) {
117 total_size += chunk.len();
118 } else {
119 return None;
120 }
121 }
122
123 let mut packet = Vec::with_capacity(total_size);
124 for i in 0..state.total_chunks {
125 #[allow(clippy::unwrap_used, clippy::disallowed_methods)]
129 packet.extend_from_slice(state.chunks.get(&i).unwrap());
130 }
131
132 return Some(packet);
133 }
134
135 None
136 }
137
138 fn evict_stalest(&mut self) {
143 if let Some((&stalest_key, _)) = self
144 .assemblies
145 .iter()
146 .min_by_key(|(_, state)| state.last_update)
147 {
148 self.assemblies.remove(&stalest_key);
149 }
150 }
151
152 pub fn len(&self) -> usize {
154 self.assemblies.len()
155 }
156
157 pub fn is_empty(&self) -> bool {
159 self.assemblies.is_empty()
160 }
161
162 pub fn get_nacks_and_evict(&mut self) -> Vec<([u8; 16], u32, Vec<u16>)> {
165 let now = Instant::now();
166 let mut nacks = Vec::new();
167 let mut to_remove = Vec::new();
168
169 for (key, state) in self.assemblies.iter() {
170 let elapsed = now.duration_since(state.last_update);
171
172 if elapsed > Duration::from_millis(5000) {
173 to_remove.push(*key);
175 } else if elapsed > Duration::from_millis(50) {
176 let mut missing = Vec::new();
178 for i in 0..state.total_chunks {
179 if !state.chunks.contains_key(&i) {
180 missing.push(i);
181 }
182 }
183 if !missing.is_empty() {
184 nacks.push((key.0, key.1, missing));
185 }
186 }
187 }
188
189 for k in to_remove {
190 self.assemblies.remove(&k);
191 }
192
193 nacks
194 }
195}
196
197pub fn fragment_payload(session_id: [u8; 16], packet_id: u32, payload: &[u8]) -> Vec<CryptoFrame> {
199 let mut frames = Vec::new();
200 let chunks = payload.chunks(MAX_UDP_PAYLOAD);
201 let total_chunks = chunks.len() as u16;
202
203 for (i, chunk) in chunks.enumerate() {
204 frames.push(CryptoFrame {
205 session_id,
206 packet_id,
207 chunk_index: i as u16,
208 total_chunks,
209 payload: chunk.to_vec(),
210 });
211 }
212
213 frames
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 fn frame(packet_id: u32, idx: u16, total: u16, payload_len: usize) -> CryptoFrame {
221 CryptoFrame {
222 session_id: [0u8; 16],
223 packet_id,
224 chunk_index: idx,
225 total_chunks: total,
226 payload: vec![0xABu8; payload_len],
227 }
228 }
229
230 #[test]
231 fn fragment_reassemble_round_trip() {
232 let payload: Vec<u8> = (0..3000u32).map(|i| i as u8).collect();
233 let frames = fragment_payload([1u8; 16], 42, &payload);
234 assert!(frames.len() > 1, "3000 bytes must fragment");
235 let mut asm = FragmentAssembler::new();
236 let mut out = None;
237 for f in frames {
238 if let Some(p) = asm.process_chunk(f) {
239 out = Some(p);
240 }
241 }
242 assert_eq!(out.as_deref(), Some(payload.as_slice()));
243 assert!(asm.is_empty(), "completed assembly is removed");
244 }
245
246 #[test]
247 fn rejects_zero_total_chunks() {
248 let mut asm = FragmentAssembler::new();
249 assert!(asm.process_chunk(frame(1, 0, 0, 10)).is_none());
250 assert!(asm.is_empty(), "malformed frame must not open an assembly");
251 }
252
253 #[test]
254 fn rejects_out_of_range_chunk_index() {
255 let mut asm = FragmentAssembler::new();
256 assert!(asm.process_chunk(frame(1, 2, 2, 10)).is_none());
258 assert!(asm.is_empty());
259 }
260
261 #[test]
262 fn rejects_excessive_total_chunks() {
263 let mut asm = FragmentAssembler::new();
264 assert!(asm
265 .process_chunk(frame(1, 0, MAX_TOTAL_CHUNKS.saturating_add(1), 10))
266 .is_none());
267 assert!(asm.is_empty());
268 }
269
270 #[test]
271 fn rejects_oversized_fragment_payload() {
272 let mut asm = FragmentAssembler::new();
273 assert!(asm
274 .process_chunk(frame(1, 0, 4, MAX_UDP_PAYLOAD + 1))
275 .is_none());
276 assert!(asm.is_empty());
277 }
278
279 #[test]
280 fn caps_concurrent_assemblies() {
281 let mut asm = FragmentAssembler::new();
282 for packet_id in 0..(MAX_CONCURRENT_ASSEMBLIES as u32 * 4) {
285 assert!(asm.process_chunk(frame(packet_id, 0, 4, 10)).is_none());
286 assert!(
287 asm.len() <= MAX_CONCURRENT_ASSEMBLIES,
288 "assembly table exceeded its cap: {}",
289 asm.len()
290 );
291 }
292 assert_eq!(asm.len(), MAX_CONCURRENT_ASSEMBLIES);
293 }
294
295 #[test]
301 fn duplicate_chunk_does_not_overwrite_first_seen_payload() {
302 let mut asm = FragmentAssembler::new();
303 let mk = |idx: u16, byte: u8| CryptoFrame {
304 session_id: [1u8; 16],
305 packet_id: 7,
306 chunk_index: idx,
307 total_chunks: 2,
308 payload: vec![byte; 4],
309 };
310 assert!(asm.process_chunk(mk(0, 0xAA)).is_none());
312 assert!(
313 asm.process_chunk(mk(0, 0xFF)).is_none(),
314 "a duplicate index must not complete or overwrite"
315 );
316 let out = asm
318 .process_chunk(mk(1, 0xBB))
319 .expect("completes the 2-chunk packet");
320 assert_eq!(
321 out,
322 vec![0xAA, 0xAA, 0xAA, 0xAA, 0xBB, 0xBB, 0xBB, 0xBB],
323 "first-seen chunk 0 must win, not the poisoned duplicate"
324 );
325 }
326}