1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::collections::HashMap;
5use std::fmt;
6use std::time::{Duration, Instant};
7
8pub const L3_VERSION: u8 = 1;
11
12pub const L3_HEADER_LEN: usize = 12;
15
16pub const TUNNEL_PAYLOAD_CAP: usize = 128;
19
20pub const MAX_CHUNK_DATA: usize = TUNNEL_PAYLOAD_CAP - L3_HEADER_LEN;
22
23const _: () = assert!(L3_HEADER_LEN + MAX_CHUNK_DATA == TUNNEL_PAYLOAD_CAP);
26
27const FLAG_IS_RESPONSE: u8 = 0b0000_0001;
28const FLAG_IS_ERROR: u8 = 0b0000_0010;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TunnelError {
33 ChunkTooLarge {
35 got: usize,
37 },
38 BadChunkIndex {
40 seq: u16,
42 total: u16,
44 },
45 TooManyChunks {
47 needed: usize,
49 limit: usize,
51 },
52}
53
54impl fmt::Display for TunnelError {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 Self::ChunkTooLarge { got } => write!(
58 f,
59 "chunk data is {got} bytes, exceeds the {MAX_CHUNK_DATA}-byte chunk limit"
60 ),
61 Self::BadChunkIndex { seq, total } => {
62 write!(f, "chunk index seq={seq} is out of range for total={total}")
63 }
64 Self::TooManyChunks { needed, limit } => {
65 write!(
66 f,
67 "body needs {needed} chunks, exceeds the {limit}-chunk limit"
68 )
69 }
70 }
71 }
72}
73
74impl std::error::Error for TunnelError {}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct ChunkHeader {
79 pub request_id: u32,
82 pub seq: u16,
84 pub total: u16,
86 pub is_response: bool,
88 pub is_error: bool,
91}
92
93impl ChunkHeader {
94 fn flags(&self) -> u8 {
95 let mut f = 0u8;
96 if self.is_response {
97 f |= FLAG_IS_RESPONSE;
98 }
99 if self.is_error {
100 f |= FLAG_IS_ERROR;
101 }
102 f
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct Framing {
114 pub magic: [u8; 2],
116}
117
118impl Framing {
119 pub fn build_chunk(&self, header: &ChunkHeader, data: &[u8]) -> Result<Vec<u8>, TunnelError> {
125 if data.len() > MAX_CHUNK_DATA {
126 return Err(TunnelError::ChunkTooLarge { got: data.len() });
127 }
128 if header.total == 0 || header.seq >= header.total {
129 return Err(TunnelError::BadChunkIndex {
130 seq: header.seq,
131 total: header.total,
132 });
133 }
134 let mut out = Vec::with_capacity(L3_HEADER_LEN + data.len());
135 out.extend_from_slice(&self.magic);
136 out.push(L3_VERSION);
137 out.push(header.flags());
138 out.extend_from_slice(&header.request_id.to_le_bytes());
139 out.extend_from_slice(&header.seq.to_le_bytes());
140 out.extend_from_slice(&header.total.to_le_bytes());
141 out.extend_from_slice(data);
142 Ok(out)
143 }
144
145 #[must_use]
151 pub fn parse_chunk<'a>(&self, payload: &'a [u8]) -> Option<(ChunkHeader, &'a [u8])> {
152 if payload.len() < L3_HEADER_LEN {
153 return None;
154 }
155 if payload[0..2] != self.magic || payload[2] != L3_VERSION {
156 return None;
157 }
158 let flags = payload[3];
159 let request_id = u32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]);
160 let seq = u16::from_le_bytes([payload[8], payload[9]]);
161 let total = u16::from_le_bytes([payload[10], payload[11]]);
162 if total == 0 || seq >= total {
163 return None;
164 }
165 let header = ChunkHeader {
166 request_id,
167 seq,
168 total,
169 is_response: flags & FLAG_IS_RESPONSE != 0,
170 is_error: flags & FLAG_IS_ERROR != 0,
171 };
172 Some((header, &payload[L3_HEADER_LEN..]))
173 }
174
175 pub fn chunk_message(
180 &self,
181 request_id: u32,
182 is_response: bool,
183 is_error: bool,
184 body: &[u8],
185 max_chunks: usize,
186 ) -> Result<Vec<Vec<u8>>, TunnelError> {
187 let needed = if body.is_empty() {
190 1
191 } else {
192 body.len().div_ceil(MAX_CHUNK_DATA)
193 };
194 if needed > max_chunks || needed > u16::MAX as usize {
195 return Err(TunnelError::TooManyChunks {
196 needed,
197 limit: max_chunks.min(u16::MAX as usize),
198 });
199 }
200 let total = needed as u16;
201 let mut chunks = Vec::with_capacity(needed);
202 for seq in 0..needed {
203 let start = seq * MAX_CHUNK_DATA;
204 let end = (start + MAX_CHUNK_DATA).min(body.len());
205 let slice = if body.is_empty() {
206 &[][..]
207 } else {
208 &body[start..end]
209 };
210 let header = ChunkHeader {
211 request_id,
212 seq: seq as u16,
213 total,
214 is_response,
215 is_error,
216 };
217 chunks.push(self.build_chunk(&header, slice)?);
218 }
219 Ok(chunks)
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct CompletedMessage {
226 pub request_id: u32,
228 pub is_response: bool,
230 pub is_error: bool,
232 pub body: Vec<u8>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum PushOutcome {
239 Ignored,
242 Incomplete,
244 Complete(CompletedMessage),
247 Rejected(String),
251}
252
253#[derive(Debug)]
255struct Partial {
256 is_response: bool,
257 is_error: bool,
258 total: u16,
259 chunks: Vec<Option<Vec<u8>>>,
260 received: usize,
261 bytes: usize,
262 created: Instant,
263}
264
265#[derive(Debug)]
274pub struct Reassembler {
275 framing: Framing,
276 pending: HashMap<u32, Partial>,
277 max_chunks: usize,
278 max_body_bytes: usize,
279}
280
281impl Reassembler {
282 #[must_use]
284 pub fn new(framing: Framing, max_chunks: usize, max_body_bytes: usize) -> Self {
285 Self {
286 framing,
287 pending: HashMap::new(),
288 max_chunks,
289 max_body_bytes,
290 }
291 }
292
293 #[must_use]
295 pub fn in_flight(&self) -> usize {
296 self.pending.len()
297 }
298
299 pub fn push(&mut self, payload: &[u8], now: Instant) -> PushOutcome {
302 let Some((header, data)) = self.framing.parse_chunk(payload) else {
303 return PushOutcome::Ignored;
304 };
305 if header.total as usize > self.max_chunks {
306 self.pending.remove(&header.request_id);
309 return PushOutcome::Rejected(format!(
310 "total {} exceeds max_chunks {}",
311 header.total, self.max_chunks
312 ));
313 }
314
315 let fresh = match self.pending.get(&header.request_id) {
319 Some(p) => {
320 p.total != header.total
321 || p.is_response != header.is_response
322 || p.is_error != header.is_error
323 }
324 None => true,
325 };
326 if fresh {
327 self.pending.insert(
328 header.request_id,
329 Partial {
330 is_response: header.is_response,
331 is_error: header.is_error,
332 total: header.total,
333 chunks: vec![None; header.total as usize],
334 received: 0,
335 bytes: 0,
336 created: now,
337 },
338 );
339 }
340 let partial = self
341 .pending
342 .get_mut(&header.request_id)
343 .expect("just inserted or already present");
344
345 let slot = &mut partial.chunks[header.seq as usize];
348 if let Some(old) = slot.take() {
349 partial.bytes -= old.len();
350 partial.received -= 1;
351 }
352 partial.bytes += data.len();
353 partial.received += 1;
354 *slot = Some(data.to_vec());
355
356 if partial.bytes > self.max_body_bytes {
357 let bytes = partial.bytes;
358 let cap = self.max_body_bytes;
359 self.pending.remove(&header.request_id);
360 return PushOutcome::Rejected(format!("body {bytes} bytes exceeds cap {cap}"));
361 }
362
363 if partial.received < partial.total as usize {
364 return PushOutcome::Incomplete;
365 }
366
367 let partial = self
369 .pending
370 .remove(&header.request_id)
371 .expect("present above");
372 let mut body = Vec::with_capacity(partial.bytes);
373 for chunk in partial.chunks {
374 body.extend_from_slice(&chunk.expect("received == total ⇒ every slot filled"));
375 }
376 PushOutcome::Complete(CompletedMessage {
377 request_id: header.request_id,
378 is_response: partial.is_response,
379 is_error: partial.is_error,
380 body,
381 })
382 }
383
384 pub fn sweep(&mut self, now: Instant, timeout: Duration) -> usize {
388 let before = self.pending.len();
389 self.pending
390 .retain(|_, p| now.duration_since(p.created) <= timeout);
391 before - self.pending.len()
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 const F: Framing = Framing {
400 magic: [0x5A, 0x01],
401 };
402
403 fn hdr(request_id: u32, seq: u16, total: u16) -> ChunkHeader {
404 ChunkHeader {
405 request_id,
406 seq,
407 total,
408 is_response: false,
409 is_error: false,
410 }
411 }
412
413 #[test]
414 fn header_len_and_data_cap_fit_the_tunnel_budget() {
415 assert_eq!(L3_HEADER_LEN + MAX_CHUNK_DATA, TUNNEL_PAYLOAD_CAP);
418 assert_eq!(MAX_CHUNK_DATA, 116);
419 }
420
421 #[test]
422 fn build_then_parse_round_trips_header_and_data() {
423 let h = ChunkHeader {
424 request_id: 0xDEAD_BEEF,
425 seq: 3,
426 total: 9,
427 is_response: true,
428 is_error: true,
429 };
430 let data = b"the quick brown fox";
431 let payload = F.build_chunk(&h, data).unwrap();
432 assert_eq!(payload.len(), L3_HEADER_LEN + data.len());
433 let (got, got_data) = F.parse_chunk(&payload).unwrap();
434 assert_eq!(got, h);
435 assert_eq!(got_data, data);
436 }
437
438 #[test]
439 fn build_rejects_oversize_data_and_bad_index() {
440 let big = vec![0u8; MAX_CHUNK_DATA + 1];
441 assert_eq!(
442 F.build_chunk(&hdr(1, 0, 1), &big),
443 Err(TunnelError::ChunkTooLarge {
444 got: MAX_CHUNK_DATA + 1
445 })
446 );
447 assert!(matches!(
448 F.build_chunk(&hdr(1, 2, 2), b"x"),
449 Err(TunnelError::BadChunkIndex { seq: 2, total: 2 })
450 ));
451 assert!(matches!(
452 F.build_chunk(&hdr(1, 0, 0), b"x"),
453 Err(TunnelError::BadChunkIndex { seq: 0, total: 0 })
454 ));
455 }
456
457 #[test]
458 fn parse_ignores_non_conforming_payloads() {
459 assert!(F.parse_chunk(b"").is_none());
460 assert!(F.parse_chunk(&[0u8; L3_HEADER_LEN - 1]).is_none());
461 let mut bad = vec![0xFFu8; L3_HEADER_LEN];
463 bad[10] = 1; assert!(F.parse_chunk(&bad).is_none());
465 let mut wrong_ver = F.build_chunk(&hdr(7, 0, 1), b"hi").unwrap();
467 wrong_ver[2] = L3_VERSION + 1;
468 assert!(F.parse_chunk(&wrong_ver).is_none());
469 let mut bad_idx = F.build_chunk(&hdr(7, 0, 2), b"hi").unwrap();
471 bad_idx[8] = 5; assert!(F.parse_chunk(&bad_idx).is_none());
473 }
474
475 #[test]
476 fn chunk_message_splits_and_reassembles_in_order() {
477 let body: Vec<u8> = (0..(MAX_CHUNK_DATA * 3 + 7)).map(|i| i as u8).collect();
478 let chunks = F.chunk_message(42, true, false, &body, 512).unwrap();
479 assert_eq!(chunks.len(), 4);
480 let mut re = Reassembler::new(F, 512, 1 << 20);
481 let now = Instant::now();
482 let mut done = None;
483 for c in &chunks {
484 match re.push(c, now) {
485 PushOutcome::Incomplete => {}
486 PushOutcome::Complete(m) => done = Some(m),
487 other => panic!("unexpected {other:?}"),
488 }
489 }
490 let m = done.expect("completed");
491 assert_eq!(m.request_id, 42);
492 assert!(m.is_response && !m.is_error);
493 assert_eq!(m.body, body);
494 assert_eq!(re.in_flight(), 0);
495 }
496
497 #[test]
498 fn empty_body_travels_as_one_chunk() {
499 let chunks = F.chunk_message(1, false, false, b"", 8).unwrap();
500 assert_eq!(chunks.len(), 1);
501 let mut re = Reassembler::new(F, 8, 64);
502 match re.push(&chunks[0], Instant::now()) {
503 PushOutcome::Complete(m) => assert!(m.body.is_empty()),
504 other => panic!("unexpected {other:?}"),
505 }
506 }
507
508 #[test]
509 fn reassembles_out_of_order_and_tolerates_duplicates() {
510 let body: Vec<u8> = (0..250u16).map(|i| i as u8).collect();
511 let chunks = F.chunk_message(99, false, false, &body, 512).unwrap();
512 assert_eq!(chunks.len(), 3);
513 let mut re = Reassembler::new(F, 512, 1 << 20);
514 let now = Instant::now();
515 assert_eq!(re.push(&chunks[2], now), PushOutcome::Incomplete);
517 assert_eq!(re.push(&chunks[1], now), PushOutcome::Incomplete);
518 assert_eq!(re.push(&chunks[2], now), PushOutcome::Incomplete); match re.push(&chunks[0], now) {
520 PushOutcome::Complete(m) => assert_eq!(m.body, body),
521 other => panic!("unexpected {other:?}"),
522 }
523 }
524
525 #[test]
526 fn a_lost_chunk_never_completes_and_sweeps_out() {
527 let body: Vec<u8> = vec![7u8; MAX_CHUNK_DATA * 2];
528 let chunks = F.chunk_message(3, false, false, &body, 8).unwrap();
529 let mut re = Reassembler::new(F, 8, 1 << 20);
530 let base = Instant::now();
531 assert_eq!(re.push(&chunks[0], base), PushOutcome::Incomplete);
532 assert_eq!(re.in_flight(), 1);
534 assert_eq!(
535 re.sweep(base + Duration::from_secs(9), Duration::from_secs(10)),
536 0
537 );
538 assert_eq!(
539 re.sweep(base + Duration::from_secs(11), Duration::from_secs(10)),
540 1
541 );
542 assert_eq!(re.in_flight(), 0);
543 }
544
545 #[test]
546 fn rejects_over_cap_total_and_over_cap_body() {
547 let chunk = F.build_chunk(&hdr(5, 0, 100), b"x").unwrap();
549 let mut re = Reassembler::new(F, 8, 1 << 20);
550 assert!(matches!(
551 re.push(&chunk, Instant::now()),
552 PushOutcome::Rejected(_)
553 ));
554 assert_eq!(re.in_flight(), 0);
555
556 let body = vec![0u8; MAX_CHUNK_DATA * 3];
558 let chunks = F.chunk_message(6, false, false, &body, 512).unwrap();
559 let mut re = Reassembler::new(F, 512, MAX_CHUNK_DATA * 2); let now = Instant::now();
561 assert_eq!(re.push(&chunks[0], now), PushOutcome::Incomplete);
562 assert_eq!(re.push(&chunks[1], now), PushOutcome::Incomplete);
563 assert!(matches!(re.push(&chunks[2], now), PushOutcome::Rejected(_)));
564 assert_eq!(re.in_flight(), 0);
565 }
566
567 #[test]
568 fn chunk_message_rejects_a_body_over_the_chunk_limit() {
569 let body = vec![0u8; MAX_CHUNK_DATA * 5];
570 assert!(matches!(
571 F.chunk_message(1, false, false, &body, 3),
572 Err(TunnelError::TooManyChunks {
573 needed: 5,
574 limit: 3
575 })
576 ));
577 }
578
579 #[test]
580 fn a_reused_id_with_a_new_shape_resets_the_prior_message() {
581 let mut re = Reassembler::new(F, 512, 1 << 20);
582 let now = Instant::now();
583 let first = F
585 .chunk_message(8, false, false, &vec![1u8; MAX_CHUNK_DATA * 3], 512)
586 .unwrap();
587 assert_eq!(re.push(&first[0], now), PushOutcome::Incomplete);
588 assert_eq!(re.in_flight(), 1);
589 let second = F.chunk_message(8, false, false, b"fresh", 512).unwrap();
592 match re.push(&second[0], now) {
593 PushOutcome::Complete(m) => assert_eq!(m.body, b"fresh"),
594 other => panic!("unexpected {other:?}"),
595 }
596 assert_eq!(re.in_flight(), 0);
597 }
598}