From 7adf8c25790628172db9db60ebd945ed23073e8a Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Tue, 21 Jul 2026 12:21:32 +0200 Subject: [PATCH 1/8] Add initial TCP reconstruction implementation --- changelog.md | 5 + etherparse/examples/tcp_reassembly.rs | 74 ++ .../tcp_stream_reassembly_buf.txt | 7 + etherparse/src/lib.rs | 5 + etherparse/src/tcp_reassembly/mod.rs | 21 + .../tcp_reassembly/tcp_reassemble_error.rs | 112 +++ .../src/tcp_reassembly/tcp_segment_range.rs | 118 +++ .../src/tcp_reassembly/tcp_stream_id.rs | 79 ++ .../src/tcp_reassembly/tcp_stream_ip_id.rs | 53 ++ .../tcp_stream_reassembly_buf.rs | 750 ++++++++++++++++++ .../tcp_stream_reassembly_pool.rs | 559 +++++++++++++ 11 files changed, 1783 insertions(+) create mode 100644 etherparse/examples/tcp_reassembly.rs create mode 100644 etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_buf.txt create mode 100644 etherparse/src/tcp_reassembly/mod.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_reassemble_error.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_segment_range.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_stream_id.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs diff --git a/changelog.md b/changelog.md index 3c25886c..03414fb7 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ ## Unreleased +* Added TCP stream reassembly support (in the `std`-only `tcp_reassembly` module, contains allocations): + * `TcpStreamReassemblyBuf`, a re-usable buffer that reconstructs the payload byte stream of a single direction of a TCP connection. It is robust against retransmits, re-ordered & duplicated/overlapping segments, big offset jumps and 32 bit sequence number wrap-around (all bookkeeping is done in a monotonic 64 bit "absolute stream offset" space). Data is exposed in-place via `contiguous` and freed via `consume` ("collect then clean"). + * `TcpStreamReassemblyPool`, a pool that reassembles many streams in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). It interprets the TCP control flags automatically (`SYN` (re)bases a stream / handles reconnects, `FIN` marks the stream end, `RST` drops & recycles) and bounds per-stream memory via a configurable `max_capacity`, returning `TcpReassembleError` when a segment lands beyond the window. + * Supporting types `TcpStreamId`, `TcpStreamIpId`, `TcpSegmentRange`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY` constant, plus a `tcp_reassembly` example. + * Added zero-copy IGMP support (IGMPv1, IGMPv2 & IGMPv3): * `IgmpSlice`, an enum with one zero-copy variant per message type (`MembershipQuery`, `MembershipQueryWithSources`, `MembershipReportV1`, `MembershipReportV2`, `MembershipReportV3`, `LeaveGroup` & `Unknown`), plus common accessors (`header`, `payload`, `slice`, `checksum`, `is_checksum_valid`). * Per-variant slice types (`MembershipQuerySlice`, `MembershipQueryWithSourcesSlice`, `MembershipReportV1Slice`, `MembershipReportV2Slice`, `MembershipReportV3Slice`, `LeaveGroupSlice` & `IgmpUnknownSlice`) with typed field accessors. Variable-length data is exposed where it exists: `MembershipReportV3Slice::group_records` and `MembershipQueryWithSourcesSlice::source_addresses` / `source_addrs_bytes` (no longer `Option`-returning). diff --git a/etherparse/examples/tcp_reassembly.rs b/etherparse/examples/tcp_reassembly.rs new file mode 100644 index 00000000..9d1e06f0 --- /dev/null +++ b/etherparse/examples/tcp_reassembly.rs @@ -0,0 +1,74 @@ +use etherparse::{tcp_reassembly::*, *}; + +/// Small helper that builds an ethernet + ipv4 + tcp packet with the given +/// sequence number, flags and payload. +fn build_packet(seq: u32, syn: bool, fin: bool, payload: &[u8]) -> Vec { + let mut builder = PacketBuilder::ethernet2([1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]) + .ipv4([1, 2, 3, 4], [2, 3, 4, 5], 20) + .tcp(1234, 80, seq, 4096); + if syn { + builder = builder.syn(); + } + if fin { + builder = builder.fin(); + } + let mut serialized = Vec::::with_capacity(builder.size(payload.len())); + builder.write(&mut serialized, payload).unwrap(); + serialized +} + +fn main() { + // pool that manages the different TCP streams & re-uses the memory buffers + let mut pool = TcpStreamReassemblyPool::<(), ()>::new(); + + // The segments below arrive out of order (the middle segment is delayed) + // and one segment is duplicated - the reassembly handles all of that. + let packets = [ + build_packet(1000, true, false, &[]), // SYN (isn = 1000, data starts at 1001) + build_packet(1001, false, false, b"Hello, "), // first chunk (seq 1001..1008) + build_packet(1020, false, true, b"stream!"), // last chunk (seq 1020..1027, arrives early, FIN) + build_packet(1008, false, false, b"reassembled "), // middle chunk (seq 1008..1020, delayed) + build_packet(1008, false, false, b"reassembled "), // duplicate of the middle chunk (replay) + ]; + + let mut fin_announced = false; + for packet in &packets { + let sliced_packet = match SlicedPacket::from_ethernet(packet) { + Ok(v) => v, + Err(err) => { + println!("Err {:?}", err); + continue; + } + }; + + match pool.process_sliced_packet(&sliced_packet, (), ()) { + Ok(Some(stream)) => { + // collect the in-order data that is available so far + let available = stream.contiguous(); + if false == available.is_empty() { + println!( + "in-order data available: {:?}", + core::str::from_utf8(available).unwrap_or("") + ); + + // ... process the data here ... + + // "clean" the processed bytes so the buffer memory is freed + let len = available.len(); + stream.consume(len); + } + + if stream.is_fin_reached() && false == fin_announced { + println!("stream finished (FIN reached)"); + fin_announced = true; + } + } + Ok(None) => { + // not a TCP packet (or a RST reset the stream) + } + Err(err) => { + println!("Error reassembling TCP stream: {err}"); + } + } + } +} diff --git a/etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_buf.txt b/etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_buf.txt new file mode 100644 index 00000000..a441ba61 --- /dev/null +++ b/etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_buf.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6eda8ccb87aa453a573f9d58f73193fa3ca10a67ccaddb3ceb4975588677ead0 # shrinks to reference = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 27, 241, 31, 32, 211, 194, 118, 76, 109, 140, 197, 80, 214, 100, 87, 184, 171, 215, 232, 96, 254, 173, 108, 7, 242, 149, 188, 150, 27, 222, 207, 153, 217, 0, 229, 207, 76, 165, 2, 55, 209, 26, 203, 163, 6, 154, 140, 50, 67, 130, 245, 242, 92, 177, 46, 186, 55, 146, 203, 157, 122, 142, 106, 212, 204, 43, 85, 59, 114, 254, 175, 161, 45, 171, 128, 48, 5, 52, 230, 185, 145, 116, 155, 163, 61, 67, 205, 244, 86, 72, 4, 138, 73, 84, 150, 246, 132, 74, 150, 223, 191, 158, 31, 43, 93, 118, 112, 245, 16, 220, 162, 67, 29, 86, 11, 134, 122, 34, 237, 179, 9, 24, 71, 134, 183, 21, 117, 135, 163, 71, 31, 86, 109, 149, 142, 17, 155, 131, 197, 112, 88, 62, 109, 41, 88, 160, 201, 22, 87, 101, 118, 101, 194, 213, 4, 2, 99, 197, 88, 216, 102, 215, 53, 179, 229, 224, 115, 157, 169, 179, 227, 33, 79, 55, 214, 255, 115, 189, 247, 228, 164, 107, 226, 254, 139, 163, 179, 228, 226, 205, 206, 186, 162, 105, 16, 1, 105, 151, 209, 180, 254, 119, 232, 130, 25, 205, 209, 137, 202, 171, 7, 58, 73, 7, 136, 74, 47, 108, 160, 27, 26, 76, 213, 67, 227, 154, 238, 114, 146, 244, 67, 244, 48, 147, 232, 210, 223, 169, 250, 142, 174, 126, 133, 19, 187, 135, 63, 226, 39, 94, 223, 200, 238, 4, 51, 92, 234, 191, 109, 223, 214, 6, 126, 135, 193, 63, 6, 100, 227, 63, 24, 143, 115, 202, 209, 74, 13, 86, 207, 181, 173, 204, 161, 73, 165, 198, 215, 97, 54, 93, 187, 211, 77, 161, 244, 9, 16, 132, 25, 0, 223, 13, 68, 250, 27, 229, 76, 228, 88, 160, 248, 247, 1, 54, 32, 54, 239, 108, 170, 164, 63, 239, 221, 154, 99, 234, 53, 113, 197, 158, 112, 127, 55, 24, 14, 33, 164, 123, 96, 119, 115, 92, 31, 70, 58, 69, 242, 27], isn = 463541154, seed = 6966445999374976574 diff --git a/etherparse/src/lib.rs b/etherparse/src/lib.rs index f71f7b53..74867cc5 100644 --- a/etherparse/src/lib.rs +++ b/etherparse/src/lib.rs @@ -325,6 +325,11 @@ pub mod err; #[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub mod defrag; +/// Module containing helpers to re-assemble TCP payload streams (contains allocations). +#[cfg(feature = "std")] +#[cfg_attr(docsrs, doc(cfg(feature = "std")))] +pub mod tcp_reassembly; + mod link; pub use link::*; diff --git a/etherparse/src/tcp_reassembly/mod.rs b/etherparse/src/tcp_reassembly/mod.rs new file mode 100644 index 00000000..21cfb082 --- /dev/null +++ b/etherparse/src/tcp_reassembly/mod.rs @@ -0,0 +1,21 @@ +mod tcp_reassemble_error; +pub use tcp_reassemble_error::*; + +mod tcp_segment_range; +pub use tcp_segment_range::*; + +mod tcp_stream_id; +pub use tcp_stream_id::*; + +mod tcp_stream_ip_id; +pub use tcp_stream_ip_id::*; + +mod tcp_stream_reassembly_buf; +pub use tcp_stream_reassembly_buf::*; + +mod tcp_stream_reassembly_pool; +pub use tcp_stream_reassembly_pool::*; + +/// Default maximum number of bytes buffered ahead of the read cursor per +/// TCP stream (used by [`TcpStreamReassemblyPool::new`]). +pub const DEFAULT_MAX_TCP_STREAM_CAPACITY: usize = 1 << 20; diff --git a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs new file mode 100644 index 00000000..d083b001 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs @@ -0,0 +1,112 @@ +/// Error while adding a TCP segment to a [`crate::tcp_reassembly::TcpStreamReassemblyBuf`]. +/// +/// Note that "normal" occurrences during a TCP capture (retransmits, +/// re-ordered segments and duplicated/overlapping payloads) are **not** +/// treated as errors. They are silently handled by the reassembly. Only +/// conditions that require a decision by the caller (a segment landing far +/// outside the buffered window or a failed allocation) are reported here. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TcpReassembleError { + /// Error if a segment references data that is more than `max_capacity` + /// bytes ahead of the current read cursor (the start of the still + /// buffered data). + /// + /// This guards against unbounded memory growth caused by big jumps in + /// the sequence number (e.g. from lost segments, re-orderings or + /// maliciously crafted packets). + SegmentBeyondMaxWindow { + /// Absolute stream offset (relative to the current read cursor) + /// at which the received segment would have ended. + end_offset: u64, + + /// Maximum number of bytes that can be buffered ahead of the read + /// cursor. + max_capacity: usize, + }, + + /// Error if not enough memory could be allocated to store the payload. + AllocationFailure { + /// Number of bytes that were attempted to be allocated. + len: usize, + }, +} + +impl core::fmt::Display for TcpReassembleError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + use TcpReassembleError::*; + match self { + SegmentBeyondMaxWindow { end_offset, max_capacity } => write!(f, "Received a TCP segment that ends {end_offset} bytes ahead of the read cursor which exceeds the maximum buffer capacity of {max_capacity} bytes."), + AllocationFailure { len } => write!(f, "Failed to allocate {len} bytes of memory to reconstruct the TCP stream."), + } + } +} + +impl core::error::Error for TcpReassembleError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + None + } +} + +#[cfg(test)] +mod tests { + use super::TcpReassembleError::*; + use std::format; + + #[test] + fn debug() { + let err = AllocationFailure { len: 16 }; + let _ = format!("{err:?}"); + } + + #[test] + fn clone_eq_hash_ord() { + use core::cmp::Ordering; + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let err = AllocationFailure { len: 16 }; + assert_eq!(err, err.clone()); + let hash_a = { + let mut hasher = DefaultHasher::new(); + err.hash(&mut hasher); + hasher.finish() + }; + let hash_b = { + let mut hasher = DefaultHasher::new(); + err.clone().hash(&mut hasher); + hasher.finish() + }; + assert_eq!(hash_a, hash_b); + assert_eq!(Ordering::Equal, err.cmp(&err)); + assert_eq!(Some(Ordering::Equal), err.partial_cmp(&err)); + } + + #[test] + fn fmt() { + let tests = [ + ( + SegmentBeyondMaxWindow { end_offset: 5000, max_capacity: 4096 }, + "Received a TCP segment that ends 5000 bytes ahead of the read cursor which exceeds the maximum buffer capacity of 4096 bytes.", + ), + ( + AllocationFailure { len: 128 }, + "Failed to allocate 128 bytes of memory to reconstruct the TCP stream.", + ), + ]; + for test in tests { + assert_eq!(format!("{}", test.0), test.1); + } + } + + #[test] + fn source() { + use core::error::Error; + assert!(AllocationFailure { len: 0 }.source().is_none()); + assert!(SegmentBeyondMaxWindow { + end_offset: 0, + max_capacity: 0 + } + .source() + .is_none()); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_segment_range.rs b/etherparse/src/tcp_reassembly/tcp_segment_range.rs new file mode 100644 index 00000000..270343c7 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_segment_range.rs @@ -0,0 +1,118 @@ +/// Describes a range of reconstructed stream data in absolute stream offsets. +/// +/// The offsets are "absolute stream offsets" as used by +/// [`crate::tcp_reassembly::TcpStreamReassemblyBuf`] (a 64 bit, non-wrapping +/// position within the stream), not raw 32 bit TCP sequence numbers. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Ord, PartialOrd)] +pub struct TcpSegmentRange { + /// Absolute offset of the first byte of the section. + pub start: u64, + /// Absolute offset one past the last byte of the section (offset + length). + pub end: u64, +} + +impl TcpSegmentRange { + /// Return if the value is contained within the section. + fn is_value_connected(&self, value: u64) -> bool { + self.start <= value && self.end >= value + } + + /// Combine both sections if they overlap or are directly adjacent. + pub fn merge(&self, other: TcpSegmentRange) -> Option { + if self.is_value_connected(other.start) + || self.is_value_connected(other.end) + || other.is_value_connected(self.start) + || other.is_value_connected(self.end) + { + Some(TcpSegmentRange { + start: core::cmp::min(self.start, other.start), + end: core::cmp::max(self.end, other.end), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + + #[test] + fn debug_clone_eq() { + let section = TcpSegmentRange { start: 1, end: 2 }; + let _ = format!("{:?}", section); + assert_eq!(section, section.clone()); + assert_eq!(section.cmp(§ion), core::cmp::Ordering::Equal); + assert_eq!( + section.partial_cmp(§ion), + Some(core::cmp::Ordering::Equal) + ); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + section.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + section.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } + + #[test] + fn is_value_connected() { + let s = TcpSegmentRange { start: 5, end: 9 }; + assert_eq!(false, s.is_value_connected(3)); + assert_eq!(false, s.is_value_connected(4)); + assert!(s.is_value_connected(5)); + assert!(s.is_value_connected(6)); + assert!(s.is_value_connected(7)); + assert!(s.is_value_connected(8)); + assert!(s.is_value_connected(9)); + assert_eq!(false, s.is_value_connected(10)); + assert_eq!(false, s.is_value_connected(11)); + } + + #[test] + fn merge() { + let tests = [ + ((0, 1), (1, 2), Some((0, 2))), + ((0, 1), (2, 3), None), + ((3, 7), (1, 2), None), + ((3, 7), (1, 3), Some((1, 7))), + ((3, 7), (1, 4), Some((1, 7))), + ((3, 7), (1, 5), Some((1, 7))), + ((3, 7), (1, 6), Some((1, 7))), + ((3, 7), (1, 7), Some((1, 7))), + ((3, 7), (1, 8), Some((1, 8))), + // large offsets (would overflow u16, valid for u64) + ( + (0x00ff_ffff_ffff, 0x0100_0000_0000), + (0x0100_0000_0000, 0x0100_0000_0001), + Some((0x00ff_ffff_ffff, 0x0100_0000_0001)), + ), + ]; + for t in tests { + let a = TcpSegmentRange { + start: t.0 .0, + end: t.0 .1, + }; + let b = TcpSegmentRange { + start: t.1 .0, + end: t.1 .1, + }; + let expected = t.2.map(|v| TcpSegmentRange { + start: v.0, + end: v.1, + }); + assert_eq!(a.merge(b), expected); + assert_eq!(b.merge(a), expected); + } + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_id.rs b/etherparse/src/tcp_reassembly/tcp_stream_id.rs new file mode 100644 index 00000000..b6aa9720 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_stream_id.rs @@ -0,0 +1,79 @@ +use crate::{tcp_reassembly::*, *}; +use arrayvec::ArrayVec; + +/// Values identifying a single **direction** of a TCP stream. +/// +/// A full TCP connection consists of two [`TcpStreamId`]s (one for each +/// direction) as the source & destination addresses and ports are swapped +/// between the two directions. +/// +/// The identifier can be extended with a custom "channel id" to further +/// differentiate streams if the addresses & ports alone are not enough +/// (e.g. when capturing from multiple interfaces). +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct TcpStreamId +where + CustomChannelId: core::hash::Hash + Eq + PartialEq + Clone + Sized, +{ + /// VLAN id's of the original packets. + pub vlan_ids: ArrayVec, + + /// IP source & destination address. + pub ip: TcpStreamIpId, + + /// TCP source port. + pub source_port: u16, + + /// TCP destination port. + pub destination_port: u16, + + /// Custom user defined channel identifier (can be used to differentiate + /// packet sources if the normal ethernet packet identifiers are not + /// enough). + pub channel_id: CustomChannelId, +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + + fn example() -> TcpStreamId { + TcpStreamId { + vlan_ids: Default::default(), + ip: TcpStreamIpId::Ipv4 { + source: [1, 2, 3, 4], + destination: [5, 6, 7, 8], + }, + source_port: 1234, + destination_port: 80, + channel_id: 7, + } + } + + #[test] + fn debug_clone_eq_hash() { + let value = example(); + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + assert_ne!(value, { + let mut other = example(); + other.source_port = 4321; + other + }); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs b/etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs new file mode 100644 index 00000000..b08521a5 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs @@ -0,0 +1,53 @@ +/// IPv4 & IPv6 specific source & destination addresses identifying a TCP stream. +#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] +pub enum TcpStreamIpId { + /// IPv4 source & destination address. + Ipv4 { + source: [u8; 4], + destination: [u8; 4], + }, + /// IPv6 source & destination address. + Ipv6 { + source: [u8; 16], + destination: [u8; 16], + }, +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + + #[test] + fn debug_clone_eq_hash_ord() { + let value = TcpStreamIpId::Ipv4 { + source: [1, 2, 3, 4], + destination: [5, 6, 7, 8], + }; + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + assert_eq!(value.cmp(&value), core::cmp::Ordering::Equal); + assert_eq!(value.partial_cmp(&value), Some(core::cmp::Ordering::Equal)); + assert_ne!( + value, + TcpStreamIpId::Ipv6 { + source: [0; 16], + destination: [0; 16], + } + ); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs new file mode 100644 index 00000000..a70f696c --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs @@ -0,0 +1,750 @@ +use crate::tcp_reassembly::*; +use std::vec::Vec; + +/// Buffer to reconstruct the payload byte stream of a single direction of a +/// TCP connection (re-usable to minimize allocations). +/// +/// # Absolute stream offsets & sequence number wrap-around +/// +/// TCP sequence numbers are 32 bit values that wrap around at `2^32`. To make +/// the reconstruction robust against wrap-around all internal bookkeeping is +/// done in a monotonic 64 bit "absolute stream offset" space. An incoming +/// sequence number is mapped into this space relative to the current read +/// cursor using the "serial number arithmetic" rule of +/// [RFC 1982](https://www.rfc-editor.org/rfc/rfc1982) (a `2^31` window +/// distinguishes "ahead" from "behind"). As a consequence the wrap-around only +/// ever affects the single [`TcpStreamReassemblyBuf::base_sequence_number`] +/// value and never the stored data or ranges. +/// +/// # Typical usage +/// +/// * [`TcpStreamReassemblyBuf::add`] feeds the payload of a received segment. +/// * [`TcpStreamReassemblyBuf::contiguous`] returns the in-order bytes that are +/// available starting at the current read cursor. +/// * [`TcpStreamReassemblyBuf::consume`] advances the read cursor and frees the +/// processed bytes so the buffer memory can be re-used. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct TcpStreamReassemblyBuf { + /// Sequence number that is mapped to `base_offset`. + /// + /// `None` until the first segment (or SYN) established the base. + base_seq: Option, + + /// Absolute stream offset of the first byte in `data` (the read cursor). + base_offset: u64, + + /// Buffer holding the reconstructed bytes starting at `base_offset`. + /// + /// Not yet received gaps are present as zeroed bytes and are only exposed + /// once they are part of the contiguous prefix (see [`Self::contiguous`]). + data: Vec, + + /// Ranges (in absolute stream offsets) that have been filled with data. + sections: Vec, + + /// Absolute stream offset one past the last byte of the stream (set once a + /// segment with the FIN flag was received). + fin_offset: Option, + + /// Maximum number of bytes that may be buffered ahead of the read cursor. + max_capacity: usize, +} + +impl TcpStreamReassemblyBuf { + /// Creates a new buffer re-using the given (cleared) buffers. + pub fn new( + mut data: Vec, + mut sections: Vec, + max_capacity: usize, + ) -> TcpStreamReassemblyBuf { + data.clear(); + sections.clear(); + TcpStreamReassemblyBuf { + base_seq: None, + base_offset: 0, + data, + sections, + fin_offset: None, + max_capacity, + } + } + + /// Sequence number that is currently mapped to the read cursor (start of + /// the still buffered data), or `None` if no data was added yet. + #[inline] + pub fn base_sequence_number(&self) -> Option { + self.base_seq + } + + /// Absolute stream offset of the read cursor (number of bytes that were + /// already consumed since the base was established). + #[inline] + pub fn base_offset(&self) -> u64 { + self.base_offset + } + + /// Raw buffer (including not yet contiguous, zeroed gaps). + #[inline] + pub fn data(&self) -> &[u8] { + &self.data + } + + /// Filled ranges (in absolute stream offsets). + #[inline] + pub fn sections(&self) -> &[TcpSegmentRange] { + &self.sections + } + + /// Absolute stream offset one past the last byte of the stream (set once a + /// FIN was received). + #[inline] + pub fn fin_offset(&self) -> Option { + self.fin_offset + } + + /// Maximum number of bytes that may be buffered ahead of the read cursor. + #[inline] + pub fn max_capacity(&self) -> usize { + self.max_capacity + } + + /// Re-base the buffer to a new starting sequence number (e.g. after a new + /// SYN / reconnect) and drop all previously buffered data. + /// + /// The underlying allocations are kept for re-use. + pub fn reset(&mut self, base_seq: u32) { + self.data.clear(); + self.sections.clear(); + self.base_seq = Some(base_seq); + self.base_offset = 0; + self.fin_offset = None; + } + + /// Shift the whole buffer forward by `shift` bytes and re-anchor it to a + /// new (lower) base sequence number. + /// + /// Only valid while nothing has been consumed yet (`base_offset == 0`). + #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] + fn rebase_backwards( + &mut self, + new_base_seq: u32, + shift: u64, + ) -> Result<(), TcpReassembleError> { + use TcpReassembleError::*; + + let old_len = self.data.len(); + let Ok(shift_usize) = usize::try_from(shift) else { + return Err(SegmentBeyondMaxWindow { + end_offset: shift, + max_capacity: self.max_capacity, + }); + }; + let Some(new_len) = shift_usize.checked_add(old_len) else { + return Err(SegmentBeyondMaxWindow { + end_offset: shift, + max_capacity: self.max_capacity, + }); + }; + // the whole buffer sits ahead of the (still at 0) read cursor + if new_len > self.max_capacity { + return Err(SegmentBeyondMaxWindow { + end_offset: new_len as u64, + max_capacity: self.max_capacity, + }); + } + if self.data.capacity() < new_len && self.data.try_reserve(new_len - old_len).is_err() { + return Err(AllocationFailure { len: new_len }); + } + + // move existing data forward and zero the newly exposed front + self.data.resize(new_len, 0); + self.data.copy_within(0..old_len, shift_usize); + for b in &mut self.data[..shift_usize] { + *b = 0; + } + + // shift the recorded ranges & fin position + for s in &mut self.sections { + s.start += shift; + s.end += shift; + } + if let Some(fin) = &mut self.fin_offset { + *fin += shift; + } + + self.base_seq = Some(new_base_seq); + Ok(()) + } + + /// Maps a sequence number into the absolute stream offset space relative to + /// the current read cursor. + /// + /// Uses RFC 1982 serial number arithmetic so wrap-arounds are handled + /// transparently. The result can be negative (segment references data that + /// was already consumed). + fn seq_to_abs_offset(&self, base_seq: u32, seq: u32) -> i128 { + let delta = seq.wrapping_sub(base_seq) as u64; + let rel: i64 = if delta < 0x8000_0000 { + delta as i64 + } else { + (delta as i64) - 0x1_0000_0000 + }; + self.base_offset as i128 + rel as i128 + } + + /// Add the payload of a received TCP segment. + /// + /// * `seq` is the sequence number of the first payload byte. + /// * `fin` records that this segment carried the FIN flag (marks the end of + /// the stream one byte past the payload). + /// + /// Retransmits, re-ordered segments and duplicated / overlapping payloads + /// are handled silently. Only segments landing more than `max_capacity` + /// bytes ahead of the read cursor or allocation failures are reported as + /// errors. + #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] + pub fn add(&mut self, seq: u32, payload: &[u8], fin: bool) -> Result<(), TcpReassembleError> { + use TcpReassembleError::*; + + // establish the base on the first ever segment + if self.base_seq.is_none() { + self.base_seq = Some(seq); + } + let base_seq = self.base_seq.unwrap(); + + let mut start_abs = self.seq_to_abs_offset(base_seq, seq); + + // If data arrives that starts before the front of the buffer while + // nothing has been consumed yet (`base_offset == 0`), the base was + // anchored too late (e.g. segments arriving highest-sequence-first at + // the start of the capture). Re-anchor the buffer backwards so the + // earlier data can be kept. + if false == payload.is_empty() && self.base_offset == 0 && start_abs < 0 { + let shift = (-start_abs) as u64; + self.rebase_backwards(seq, shift)?; + start_abs = 0; + } + + let end_abs = start_abs + payload.len() as i128; + + // record the end of the stream if the FIN flag is set (FIN occupies the + // sequence number right after the payload) + if fin { + let fin_pos = if end_abs < 0 { 0 } else { end_abs as u64 }; + self.fin_offset = Some(fin_pos); + } + + // nothing to store for an empty payload (e.g. pure SYN/ACK/FIN segments) + if payload.is_empty() { + return Ok(()); + } + + // trim the part that lies before the read cursor (already consumed / + // retransmitted data) + let cursor = self.base_offset as i128; + let (payload, start_abs) = if start_abs < cursor { + let trim = (cursor - start_abs) as usize; + if trim >= payload.len() { + // fully before the cursor -> nothing to store + return Ok(()); + } + (&payload[trim..], cursor) + } else { + (payload, start_abs) + }; + let end_abs = start_abs + payload.len() as i128; + + // enforce the maximum buffer window (measured from the read cursor) + let end_offset = (end_abs - cursor) as u64; + if end_offset > self.max_capacity as u64 { + return Err(SegmentBeyondMaxWindow { + end_offset, + max_capacity: self.max_capacity, + }); + } + + let data_start = (start_abs - self.base_offset as i128) as usize; + let data_end = data_start + payload.len(); + + // grow the buffer if required (gaps are zero filled) + if self.data.len() < data_end { + if self.data.capacity() < data_end + && self.data.try_reserve(data_end - self.data.len()).is_err() + { + return Err(AllocationFailure { len: data_end }); + } + self.data.resize(data_end, 0); + } + + // write the payload + self.data[data_start..data_end].copy_from_slice(payload); + + // merge the new range into the existing sections (dedup / overlap) + let mut new_section = TcpSegmentRange { + start: start_abs as u64, + end: end_abs as u64, + }; + self.sections.retain(|it| -> bool { + if let Some(merged) = new_section.merge(*it) { + new_section = merged; + false + } else { + true + } + }); + self.sections.push(new_section); + + Ok(()) + } + + /// Length of the in-order data available starting at the read cursor. + fn contiguous_len(&self) -> usize { + self.sections + .iter() + .find(|s| s.start == self.base_offset) + .map(|s| (s.end - self.base_offset) as usize) + .unwrap_or(0) + } + + /// Returns the in-order bytes that are available starting at the read + /// cursor. + /// + /// Returns an empty slice if the byte at the read cursor has not been + /// received yet (a gap at the front of the stream). + #[inline] + pub fn contiguous(&self) -> &[u8] { + &self.data[..self.contiguous_len()] + } + + /// Advance the read cursor by `len` bytes and free the consumed data for + /// re-use. + /// + /// `len` is clamped to the currently available contiguous data so it is not + /// possible to consume into a not yet received gap. + pub fn consume(&mut self, len: usize) { + let len = core::cmp::min(len, self.contiguous_len()); + if len == 0 { + return; + } + self.data.drain(..len); + self.base_offset += len as u64; + self.base_seq = self.base_seq.map(|s| s.wrapping_add(len as u32)); + + // drop / trim sections that are now before the read cursor + let base_offset = self.base_offset; + self.sections.retain_mut(|s| { + if s.end <= base_offset { + false + } else { + if s.start < base_offset { + s.start = base_offset; + } + true + } + }); + } + + /// Returns `true` once all bytes up to the FIN have been received and are + /// available as contiguous data. + pub fn is_fin_reached(&self) -> bool { + match self.fin_offset { + Some(fin) => self.base_offset + self.contiguous_len() as u64 >= fin, + None => false, + } + } + + /// Consume the buffer and return the underlying buffers for re-use. + #[inline] + pub fn take_bufs(self) -> (Vec, Vec) { + (self.data, self.sections) + } +} + +#[cfg(test)] +mod test { + use super::*; + use proptest::prelude::*; + use std::{format, vec, vec::Vec}; + + const MAX: usize = 1 << 20; + + fn new_buf() -> TcpStreamReassemblyBuf { + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX) + } + + /// Returns a u8 vec counting up from "start" (truncating to u8). + fn sequence(start: usize, len: usize) -> Vec { + let mut result = Vec::with_capacity(len); + for i in start..start + len { + result.push((i & 0xff) as u8); + } + result + } + + #[test] + fn debug_clone_eq_hash() { + let buf = new_buf(); + let _ = format!("{:?}", buf); + assert_eq!(buf, buf.clone()); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + buf.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + buf.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } + + #[test] + fn new_clears_bufs() { + let buf = TcpStreamReassemblyBuf::new( + vec![1, 2, 3], + vec![TcpSegmentRange { start: 0, end: 3 }], + 4096, + ); + assert_eq!(buf.base_sequence_number(), None); + assert_eq!(buf.base_offset(), 0); + assert!(buf.data().is_empty()); + assert!(buf.sections().is_empty()); + assert_eq!(buf.fin_offset(), None); + assert_eq!(buf.max_capacity(), 4096); + } + + #[test] + fn in_order() { + let mut buf = new_buf(); + buf.add(1000, &sequence(0, 4), false).unwrap(); + assert_eq!(buf.base_sequence_number(), Some(1000)); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + + buf.add(1004, &sequence(4, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); + + // consume the first 6 bytes + buf.consume(6); + assert_eq!(buf.base_offset(), 6); + assert_eq!(buf.base_sequence_number(), Some(1006)); + assert_eq!(buf.contiguous(), &sequence(6, 2)[..]); + + buf.add(1008, &sequence(8, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(6, 6)[..]); + buf.consume(6); + assert_eq!(buf.contiguous(), &[]); + } + + #[test] + fn out_of_order_with_gap() { + let mut buf = new_buf(); + // establish the base with the first segment + buf.add(100, &sequence(0, 4), false).unwrap(); + // segment far ahead (gap between 104 and 200) + buf.add(200, &sequence(100, 4), false).unwrap(); + // only the first part is contiguous + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + // fill the gap + buf.add(104, &sequence(4, 96), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 104)[..]); + } + + #[test] + fn reverse_order() { + let mut buf = new_buf(); + // base established from the first (highest) segment + buf.add(108, &sequence(8, 4), false).unwrap(); + buf.add(104, &sequence(4, 4), false).unwrap(); + buf.add(100, &sequence(0, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 12)[..]); + } + + #[test] + fn duplicates_and_overlaps() { + let mut buf = new_buf(); + buf.add(100, &sequence(0, 8), false).unwrap(); + // exact duplicate + buf.add(100, &sequence(0, 8), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); + assert_eq!(buf.sections().len(), 1); + + // overlap at the back + buf.add(104, &sequence(4, 8), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 12)[..]); + assert_eq!(buf.sections().len(), 1); + + // fully contained + buf.add(102, &sequence(2, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 12)[..]); + assert_eq!(buf.sections().len(), 1); + } + + #[test] + fn retransmit_of_consumed_data() { + let mut buf = new_buf(); + buf.add(100, &sequence(0, 8), false).unwrap(); + buf.consume(8); + assert_eq!(buf.base_offset(), 8); + // retransmit fully before the cursor -> ignored, no panic + buf.add(100, &sequence(0, 8), false).unwrap(); + assert_eq!(buf.contiguous(), &[]); + assert!(buf.sections().is_empty()); + + // partial retransmit (4 before cursor, 4 new) + buf.add(104, &sequence(4, 8), false).unwrap(); + assert_eq!(buf.base_offset(), 8); + assert_eq!(buf.contiguous(), &sequence(8, 4)[..]); + } + + #[test] + fn beyond_max_window() { + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + buf.add(0, &sequence(0, 4), false).unwrap(); + // just inside the window (ends at 16) + buf.add(12, &sequence(12, 4), false).unwrap(); + // one past the window (ends at 17) + let err = buf.add(13, &sequence(13, 4), false).unwrap_err(); + assert_eq!( + err, + TcpReassembleError::SegmentBeyondMaxWindow { + end_offset: 17, + max_capacity: 16 + } + ); + } + + #[test] + fn consume_clamped_to_contiguous() { + let mut buf = new_buf(); + buf.add(0, &sequence(0, 4), false).unwrap(); + // gap: nothing at offset 4..8, data at 8 + buf.add(8, &sequence(8, 4), false).unwrap(); + // consuming more than contiguous only consumes the contiguous prefix + buf.consume(100); + assert_eq!(buf.base_offset(), 4); + assert_eq!(buf.contiguous(), &[]); + // fill the gap + buf.add(4, &sequence(4, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(4, 8)[..]); + } + + #[test] + fn consume_into_gap_is_noop() { + let mut buf = new_buf(); + buf.add(0, &sequence(0, 4), false).unwrap(); + buf.consume(4); + // front is now a gap + buf.add(8, &sequence(8, 4), false).unwrap(); + buf.consume(4); + assert_eq!(buf.base_offset(), 4); + } + + #[test] + fn fin_handling() { + let mut buf = new_buf(); + buf.add(100, &sequence(0, 4), false).unwrap(); + // FIN arrives but there is a gap in front + buf.add(108, &sequence(8, 4), true).unwrap(); + assert_eq!(buf.fin_offset(), Some(12)); + assert_eq!(false, buf.is_fin_reached()); + // fill the gap + buf.add(104, &sequence(4, 4), false).unwrap(); + assert!(buf.is_fin_reached()); + assert_eq!(buf.contiguous(), &sequence(0, 12)[..]); + } + + #[test] + fn fin_empty_payload() { + let mut buf = new_buf(); + buf.add(100, &sequence(0, 4), false).unwrap(); + // pure FIN segment (no payload) at seq 104 + buf.add(104, &[], true).unwrap(); + assert_eq!(buf.fin_offset(), Some(4)); + assert!(buf.is_fin_reached()); + } + + #[test] + fn reset_rebases() { + let mut buf = new_buf(); + buf.add(100, &sequence(0, 8), false).unwrap(); + buf.reset(5000); + assert_eq!(buf.base_sequence_number(), Some(5000)); + assert_eq!(buf.base_offset(), 0); + assert!(buf.contiguous().is_empty()); + assert!(buf.sections().is_empty()); + assert_eq!(buf.fin_offset(), None); + buf.add(5000, &sequence(0, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + } + + #[test] + fn wrap_around_add_and_consume() { + // base sequence number close to the u32 wrap boundary + let isn: u32 = 0xFFFF_FFF0; + let mut buf = new_buf(); + // 8 bytes ending exactly at the wrap boundary + buf.add(isn, &sequence(0, 8), false).unwrap(); + assert_eq!(buf.base_sequence_number(), Some(isn)); + // next segment's seq wrapped around to a small number (0xFFFFFFF8 + 8 = 0x00000000) + let seq_after_wrap = isn.wrapping_add(8); // == 0xFFFFFFF8 + buf.add(seq_after_wrap, &sequence(8, 8), false).unwrap(); + // seq that is numerically smaller than the base but actually ahead + let seq_wrapped = isn.wrapping_add(16); // == 0x00000000 + buf.add(seq_wrapped, &sequence(16, 8), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 24)[..]); + + // consume across the wrap boundary + buf.consume(20); + assert_eq!(buf.base_offset(), 20); + assert_eq!(buf.base_sequence_number(), Some(isn.wrapping_add(20))); + assert_eq!(buf.contiguous(), &sequence(20, 4)[..]); + } + + #[test] + fn wrap_around_retransmit() { + let isn: u32 = 0xFFFF_FFF8; + let mut buf = new_buf(); + buf.add(isn, &sequence(0, 16), false).unwrap(); + buf.consume(16); + // base_seq is now 0x00000008; a retransmit of the pre-wrap data must be + // recognised as "behind" and ignored. + buf.add(isn, &sequence(0, 16), false).unwrap(); + assert_eq!(buf.contiguous(), &[]); + assert!(buf.sections().is_empty()); + } + + #[test] + fn allocation_and_zero_fill() { + let mut buf = new_buf(); + // create a gap; the gap bytes must be zero filled and safe to read once + // filled + buf.add(0, &[0xAA; 4], false).unwrap(); + buf.add(8, &[0xBB; 4], false).unwrap(); + // the gap (offset 4..8) is currently zeroed in the raw buffer + assert_eq!(&buf.data()[4..8], &[0, 0, 0, 0]); + buf.add(4, &[0xCC; 4], false).unwrap(); + let mut expected = Vec::new(); + expected.extend_from_slice(&[0xAA; 4]); + expected.extend_from_slice(&[0xCC; 4]); + expected.extend_from_slice(&[0xBB; 4]); + assert_eq!(buf.contiguous(), &expected[..]); + } + + #[test] + fn take_bufs_returns_allocations() { + let mut buf = new_buf(); + buf.add(0, &sequence(0, 8), false).unwrap(); + let (data, sections) = buf.take_bufs(); + assert!(data.capacity() >= 8); + // sections vec was used + assert!(sections.capacity() >= 1); + } + + #[test] + fn rebase_backwards_bounds() { + // a segment that would force a prepend beyond max_capacity is rejected + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + buf.add(1000, &sequence(0, 4), false).unwrap(); + // seq 980 is 20 bytes behind the anchor -> prepend of 20 > 16 + let err = buf.add(980, &sequence(0, 4), false).unwrap_err(); + assert_eq!( + err, + TcpReassembleError::SegmentBeyondMaxWindow { + end_offset: 24, + max_capacity: 16 + } + ); + // the original data is untouched + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + } + + #[test] + fn rebase_only_before_consume() { + // after a consume, earlier data is a retransmit and must NOT re-anchor + let mut buf = new_buf(); + buf.add(1000, &sequence(0, 4), false).unwrap(); + buf.consume(4); + buf.add(996, &sequence(0, 4), false).unwrap(); + // ignored (before cursor), nothing buffered + assert_eq!(buf.base_offset(), 4); + assert_eq!(buf.contiguous(), &[]); + } + + proptest! { + /// Feed a reference byte stream split into random, re-ordered, + /// duplicated and re-transmitted segments (with an ISN chosen anywhere + /// in the u32 range, including near the wrap boundary) and assert the + /// consumed bytes exactly reconstruct the reference stream. + #[test] + fn reassemble_random( + reference in proptest::collection::vec(any::(), 0..600usize), + isn in any::(), + seed in any::(), + ) { + use std::vec::Vec; + + // simple deterministic xorshift RNG so no extra dependency is needed + let mut state = seed | 1; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + + // split the reference into contiguous segments + let mut segments: Vec<(usize, usize)> = Vec::new(); // (offset, len) + let mut pos = 0usize; + while pos < reference.len() { + let remaining = reference.len() - pos; + let len = 1 + (next() as usize % remaining.min(40)); + segments.push((pos, len)); + pos += len; + } + + // duplicate some segments and shuffle the send order + let mut to_send = segments.clone(); + for seg in &segments { + if next() & 1 == 0 { + to_send.push(*seg); + } + } + for i in (1..to_send.len()).rev() { + let j = next() as usize % (i + 1); + to_send.swap(i, j); + } + + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 1 << 20); + // model a known stream start (as if the ISN was learned from a SYN): + // offset 0 of the reference maps to sequence number `isn`. + buf.reset(isn); + let mut collected: Vec = Vec::new(); + + for (offset, len) in to_send { + let seq = isn.wrapping_add(offset as u32); + let is_last = offset + len == reference.len(); + buf.add(seq, &reference[offset..offset + len], is_last).unwrap(); + // occasionally drain the available contiguous data + if next() & 3 == 0 { + let avail = buf.contiguous().len(); + collected.extend_from_slice(buf.contiguous()); + buf.consume(avail); + } + } + + // final drain + collected.extend_from_slice(buf.contiguous()); + let avail = buf.contiguous().len(); + buf.consume(avail); + + prop_assert_eq!(&collected, &reference); + if false == reference.is_empty() { + prop_assert!(buf.is_fin_reached()); + } + } + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs new file mode 100644 index 00000000..884ddf98 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs @@ -0,0 +1,559 @@ +use crate::{tcp_reassembly::*, *}; +use std::collections::HashMap; +use std::vec::Vec; + +/// Pool to reassemble the payload byte streams of multiple TCP connections in +/// parallel (re-uses buffers to minimize allocations). +/// +/// Streams are differentiated by their VLAN ids, IP source & destination +/// address and TCP source & destination port. A custom "channel id" can be +/// added to further differentiate streams (e.g. when capturing from multiple +/// interfaces). Note that each *direction* of a connection is a separate stream +/// (the source & destination are swapped between the two directions). +/// +/// # This implementation is NOT safe against "Out of Memory" attacks +/// +/// While each individual stream is bounded by `max_capacity`, the number of +/// parallel streams is not. If you use the [`TcpStreamReassemblyPool`] in an +/// untrusted environment an attacker could cause an "out of memory error" by +/// opening up many parallel connections. Use [`TcpStreamReassemblyPool::retain`] +/// (or a custom `channel_id` limit) to evict stale streams. +#[derive(Debug, Clone)] +pub struct TcpStreamReassemblyPool +where + Timestamp: Sized + core::fmt::Debug + Clone, + CustomChannelId: Sized + core::fmt::Debug + Clone + core::hash::Hash + Eq + PartialEq, +{ + /// Currently reconstructing TCP streams. + active: HashMap, (TcpStreamReassemblyBuf, Timestamp)>, + + /// Data buffers that can be re-used. + finished_data_bufs: Vec>, + + /// Section buffers that can be re-used. + finished_section_bufs: Vec>, + + /// Maximum number of bytes buffered ahead of the read cursor per stream. + default_max_capacity: usize, +} + +impl TcpStreamReassemblyPool +where + Timestamp: Sized + core::fmt::Debug + Clone, + CustomChannelId: Sized + core::fmt::Debug + Clone + core::hash::Hash + Eq + PartialEq, +{ + /// Creates a new pool with the [`DEFAULT_MAX_TCP_STREAM_CAPACITY`] per + /// stream limit. + pub fn new() -> TcpStreamReassemblyPool { + Self::with_max_capacity(DEFAULT_MAX_TCP_STREAM_CAPACITY) + } + + /// Creates a new pool with a custom per stream buffer limit. + pub fn with_max_capacity( + max_capacity: usize, + ) -> TcpStreamReassemblyPool { + TcpStreamReassemblyPool { + active: HashMap::new(), + finished_data_bufs: Vec::new(), + finished_section_bufs: Vec::new(), + default_max_capacity: max_capacity, + } + } + + /// Process a TCP segment contained in a [`SlicedPacket`]. + /// + /// Returns: + /// * `Ok(None)` if the packet did not contain a TCP segment (or a RST reset + /// the stream). + /// * `Ok(Some(&mut buf))` giving access to the affected stream. Read the + /// available in-order data via [`TcpStreamReassemblyBuf::contiguous`] and + /// free it via [`TcpStreamReassemblyBuf::consume`]. + /// * `Err` if the segment could not be added (see [`TcpReassembleError`]). + pub fn process_sliced_packet( + &mut self, + slice: &SlicedPacket, + timestamp: Timestamp, + channel_id: CustomChannelId, + ) -> Result, TcpReassembleError> { + // only TCP segments are relevant + let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { + return Ok(None); + }; + + // extract the source & destination addresses + let ip = match &slice.net { + Some(NetSlice::Ipv4(v4)) => TcpStreamIpId::Ipv4 { + source: v4.header().source(), + destination: v4.header().destination(), + }, + Some(NetSlice::Ipv6(v6)) => TcpStreamIpId::Ipv6 { + source: v6.header().source(), + destination: v6.header().destination(), + }, + Some(NetSlice::Arp(_)) | None => { + return Ok(None); + } + }; + + let id = TcpStreamId { + vlan_ids: slice.vlan_ids(), + ip, + source_port: tcp.source_port(), + destination_port: tcp.destination_port(), + channel_id, + }; + + self.process( + id, + tcp.sequence_number(), + tcp.payload(), + tcp.syn(), + tcp.fin(), + tcp.rst(), + timestamp, + ) + } + + /// Process an already parsed TCP segment (lower level entry point that does + /// not require a [`SlicedPacket`]). + #[allow(clippy::too_many_arguments)] + pub fn process_tcp( + &mut self, + id: TcpStreamId, + sequence_number: u32, + payload: &[u8], + syn: bool, + fin: bool, + rst: bool, + timestamp: Timestamp, + ) -> Result, TcpReassembleError> { + self.process(id, sequence_number, payload, syn, fin, rst, timestamp) + } + + #[allow(clippy::too_many_arguments)] + fn process( + &mut self, + id: TcpStreamId, + seq: u32, + payload: &[u8], + syn: bool, + fin: bool, + rst: bool, + timestamp: Timestamp, + ) -> Result, TcpReassembleError> { + use std::collections::hash_map::Entry; + + // a RST tears the stream down and recycles its buffers + if rst { + if let Some((buf, _)) = self.active.remove(&id) { + let (data, sections) = buf.take_bufs(); + self.finished_data_bufs.push(data); + self.finished_section_bufs.push(sections); + } + return Ok(None); + } + + // a SYN (re)establishes the stream base (handles reconnects). The SYN + // flag consumes one sequence number, so the payload starts at seq + 1. + if syn { + let base = seq.wrapping_add(1); + match self.active.entry(id) { + Entry::Occupied(mut entry) => { + let value = entry.get_mut(); + value.0.reset(base); + value.1 = timestamp; + // TCP Fast Open: a SYN may already carry payload + if false == payload.is_empty() { + value.0.add(base, payload, fin)?; + } + Ok(Some(&mut entry.into_mut().0)) + } + Entry::Vacant(entry) => { + let data_buf = if let Some(mut d) = self.finished_data_bufs.pop() { + d.clear(); + d + } else { + Vec::new() + }; + let sections = if let Some(mut s) = self.finished_section_bufs.pop() { + s.clear(); + s + } else { + Vec::new() + }; + let mut buf = + TcpStreamReassemblyBuf::new(data_buf, sections, self.default_max_capacity); + buf.reset(base); + if false == payload.is_empty() { + if let Err(err) = buf.add(base, payload, fin) { + let (data, sections) = buf.take_bufs(); + self.finished_data_bufs.push(data); + self.finished_section_bufs.push(sections); + return Err(err); + } + } + Ok(Some(&mut entry.insert((buf, timestamp)).0)) + } + } + } else { + // regular data / FIN / ACK segment + match self.active.entry(id) { + Entry::Occupied(mut entry) => { + let value = entry.get_mut(); + value.1 = timestamp; + value.0.add(seq, payload, fin)?; + Ok(Some(&mut entry.into_mut().0)) + } + Entry::Vacant(entry) => { + let data_buf = if let Some(mut d) = self.finished_data_bufs.pop() { + d.clear(); + d + } else { + Vec::new() + }; + let sections = if let Some(mut s) = self.finished_section_bufs.pop() { + s.clear(); + s + } else { + Vec::new() + }; + let mut buf = + TcpStreamReassemblyBuf::new(data_buf, sections, self.default_max_capacity); + match buf.add(seq, payload, fin) { + Ok(()) => Ok(Some(&mut entry.insert((buf, timestamp)).0)), + Err(err) => { + let (data, sections) = buf.take_bufs(); + self.finished_data_bufs.push(data); + self.finished_section_bufs.push(sections); + Err(err) + } + } + } + } + } + } + + /// Direct mutable access to an active stream (e.g. to read & consume data + /// outside of a `process_*` call). + pub fn stream_mut( + &mut self, + id: &TcpStreamId, + ) -> Option<&mut TcpStreamReassemblyBuf> { + self.active.get_mut(id).map(|(buf, _)| buf) + } + + /// Explicitly end a stream and recycle its buffers. + pub fn end_stream(&mut self, id: &TcpStreamId) { + if let Some((buf, _)) = self.active.remove(id) { + let (data, sections) = buf.take_bufs(); + self.finished_data_bufs.push(data); + self.finished_section_bufs.push(sections); + } + } + + /// Number of currently active streams. + #[inline] + pub fn active_streams(&self) -> usize { + self.active.len() + } + + /// Retains only the streams specified by the predicate and recycles the + /// buffers of the evicted ones (e.g. to remove streams that have not + /// received data for a while based on the `Timestamp`). + pub fn retain(&mut self, f: F) + where + F: Fn(&Timestamp) -> bool, + { + if self.active.iter().any(|(_, (_, t))| false == f(t)) { + self.active = self + .active + .drain() + .filter_map(|(k, v)| { + if f(&v.1) { + Some((k, v)) + } else { + let (data, sections) = v.0.take_bufs(); + self.finished_data_bufs.push(data); + self.finished_section_bufs.push(sections); + None + } + }) + .collect(); + } + } +} + +impl Default for TcpStreamReassemblyPool +where + Timestamp: Sized + core::fmt::Debug + Clone, + CustomChannelId: Sized + core::fmt::Debug + Clone + core::hash::Hash + Eq + PartialEq, +{ + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod test { + use super::*; + use arrayvec::ArrayVec; + use std::vec::Vec; + + fn ipv4_id(channel_id: u16) -> TcpStreamId { + TcpStreamId { + vlan_ids: ArrayVec::new_const(), + ip: TcpStreamIpId::Ipv4 { + source: [1, 2, 3, 4], + destination: [5, 6, 7, 8], + }, + source_port: 1234, + destination_port: 80, + channel_id, + } + } + + fn sequence(start: usize, len: usize) -> Vec { + (start..start + len).map(|i| (i & 0xff) as u8).collect() + } + + #[test] + fn new_default() { + let pool = TcpStreamReassemblyPool::<(), ()>::new(); + assert_eq!(pool.active_streams(), 0); + let pool: TcpStreamReassemblyPool = Default::default(); + assert_eq!(pool.active_streams(), 0); + let pool = TcpStreamReassemblyPool::<(), ()>::with_max_capacity(16); + assert_eq!(pool.default_max_capacity, 16); + } + + #[test] + fn debug_clone() { + let pool = TcpStreamReassemblyPool::<(), ()>::new(); + let _ = std::format!("{:?}", pool.clone()); + } + + #[test] + fn basic_syn_data_flow() { + let mut pool = TcpStreamReassemblyPool::::new(); + let id = ipv4_id(0); + + // SYN (isn = 999, so data starts at 1000) + let buf = pool + .process_tcp(id.clone(), 999, &[], true, false, false, 1) + .unwrap() + .unwrap(); + assert_eq!(buf.base_sequence_number(), Some(1000)); + assert_eq!(pool.active_streams(), 1); + + // data + let buf = pool + .process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, 2) + .unwrap() + .unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); + buf.consume(8); + + // more data + FIN + let buf = pool + .process_tcp(id.clone(), 1008, &sequence(8, 4), false, true, false, 3) + .unwrap() + .unwrap(); + assert_eq!(buf.contiguous(), &sequence(8, 4)[..]); + assert!(buf.is_fin_reached()); + } + + #[test] + fn lazy_init_without_syn() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + // first ever segment is data (no SYN captured) + let buf = pool + .process_tcp(id.clone(), 5000, &sequence(0, 4), false, false, false, ()) + .unwrap() + .unwrap(); + assert_eq!(buf.base_sequence_number(), Some(5000)); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + } + + #[test] + fn rst_recycles_stream() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + .unwrap(); + assert_eq!(pool.active_streams(), 1); + assert_eq!(pool.finished_data_bufs.len(), 0); + + // RST tears down the stream and recycles buffers + let r = pool + .process_tcp(id.clone(), 1008, &[], false, false, true, ()) + .unwrap(); + assert!(r.is_none()); + assert_eq!(pool.active_streams(), 0); + assert_eq!(pool.finished_data_bufs.len(), 1); + assert_eq!(pool.finished_section_bufs.len(), 1); + + // buffers get re-used for the next stream + pool.process_tcp(ipv4_id(1), 1, &sequence(0, 4), false, false, false, ()) + .unwrap(); + assert_eq!(pool.finished_data_bufs.len(), 0); + assert_eq!(pool.finished_section_bufs.len(), 0); + } + + #[test] + fn reconnect_via_syn() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + .unwrap(); + + // reconnect: new SYN with a fresh ISN re-bases the stream and drops the + // old buffered data + let buf = pool + .process_tcp(id.clone(), 42, &[], true, false, false, ()) + .unwrap() + .unwrap(); + assert_eq!(buf.base_sequence_number(), Some(43)); + assert!(buf.contiguous().is_empty()); + assert_eq!(pool.active_streams(), 1); + + let buf = pool + .process_tcp(id.clone(), 43, &sequence(100, 4), false, false, false, ()) + .unwrap() + .unwrap(); + assert_eq!(buf.contiguous(), &sequence(100, 4)[..]); + } + + #[test] + fn error_on_fresh_stream_recycles_buf() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::with_max_capacity(8); + let id = ipv4_id(0); + // first segment already exceeds the window -> error, no stream created, + // buffers recycled + let err = pool + .process_tcp(id, 0, &sequence(0, 16), false, false, false, ()) + .unwrap_err(); + assert_eq!( + err, + TcpReassembleError::SegmentBeyondMaxWindow { + end_offset: 16, + max_capacity: 8 + } + ); + assert_eq!(pool.active_streams(), 0); + assert_eq!(pool.finished_data_bufs.len(), 1); + assert_eq!(pool.finished_section_bufs.len(), 1); + } + + #[test] + fn error_on_existing_stream_keeps_it() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::with_max_capacity(8); + let id = ipv4_id(0); + pool.process_tcp(id.clone(), 0, &sequence(0, 4), false, false, false, ()) + .unwrap(); + let err = pool + .process_tcp(id.clone(), 4, &sequence(4, 16), false, false, false, ()) + .unwrap_err(); + assert_eq!( + err, + TcpReassembleError::SegmentBeyondMaxWindow { + end_offset: 20, + max_capacity: 8 + } + ); + // the stream is retained with its previously received data + assert_eq!(pool.active_streams(), 1); + let buf = pool.stream_mut(&id).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + } + + #[test] + fn stream_mut_and_end_stream() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + assert!(pool.stream_mut(&id).is_none()); + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + .unwrap(); + assert!(pool.stream_mut(&id).is_some()); + pool.end_stream(&id); + assert_eq!(pool.active_streams(), 0); + assert_eq!(pool.finished_data_bufs.len(), 1); + } + + #[test] + fn retain_evicts_and_recycles() { + let mut pool = TcpStreamReassemblyPool::::new(); + pool.process_tcp(ipv4_id(0), 1000, &sequence(0, 8), false, false, false, 1) + .unwrap(); + pool.process_tcp(ipv4_id(1), 1000, &sequence(0, 8), false, false, false, 2) + .unwrap(); + assert_eq!(pool.active_streams(), 2); + + // no-op retain + pool.retain(|ts| *ts > 0); + assert_eq!(pool.active_streams(), 2); + + // evict timestamp 1 + pool.retain(|ts| *ts > 1); + assert_eq!(pool.active_streams(), 1); + assert_eq!(pool.finished_data_bufs.len(), 1); + assert_eq!(pool.finished_section_bufs.len(), 1); + } + + #[test] + fn non_tcp_and_process_sliced_packet() { + let mut pool = TcpStreamReassemblyPool::<(), ()>::new(); + + // empty sliced packet -> None + let empty = SlicedPacket { + link: None, + link_exts: ArrayVec::new_const(), + net: None, + transport: None, + }; + assert!(pool + .process_sliced_packet(&empty, (), ()) + .unwrap() + .is_none()); + + // build a real ethernet/ipv4/tcp packet and feed it + let payload = sequence(0, 8); + let pdata = build_ipv4_tcp_packet(1000, false, false, false, &payload); + let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); + let buf = pool.process_sliced_packet(&slice, (), ()).unwrap().unwrap(); + assert_eq!(buf.contiguous(), &payload[..]); + assert_eq!(pool.active_streams(), 1); + } + + fn build_ipv4_tcp_packet(seq: u32, syn: bool, fin: bool, rst: bool, payload: &[u8]) -> Vec { + let mut tcp = TcpHeader::new(1234, 80, seq, 4096); + tcp.syn = syn; + tcp.fin = fin; + tcp.rst = rst; + let tcp_bytes = tcp.to_bytes(); + + let mut ipv4 = Ipv4Header { + protocol: IpNumber::TCP, + source: [1, 2, 3, 4], + destination: [5, 6, 7, 8], + total_len: (Ipv4Header::MIN_LEN + tcp_bytes.len() + payload.len()) as u16, + time_to_live: 2, + ..Default::default() + }; + ipv4.header_checksum = ipv4.calc_header_checksum(); + + let mut buf = Vec::new(); + buf.extend_from_slice( + &Ethernet2Header { + source: [0; 6], + destination: [0; 6], + ether_type: EtherType::IPV4, + } + .to_bytes(), + ); + buf.extend_from_slice(&ipv4.to_bytes()); + buf.extend_from_slice(&tcp_bytes); + buf.extend_from_slice(payload); + buf + } +} From 85847f8f0bd644e7a76308da97fc8a66c3601980 Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Mon, 27 Jul 2026 16:14:00 +0200 Subject: [PATCH 2/8] Address review comments --- changelog.md | 9 +- etherparse/examples/tcp_reassembly.rs | 33 +- etherparse/src/lib.rs | 8 +- etherparse/src/tcp_reassembly/mod.rs | 7 + .../tcp_reassembly/tcp_reassemble_error.rs | 18 + .../src/tcp_reassembly/tcp_stream_id.rs | 5 +- .../tcp_stream_reassembly_buf.rs | 615 ++++++++++++----- .../tcp_stream_reassembly_pool.rs | 634 ++++++++++++++---- 8 files changed, 1015 insertions(+), 314 deletions(-) diff --git a/changelog.md b/changelog.md index 03414fb7..0c484e76 100644 --- a/changelog.md +++ b/changelog.md @@ -2,10 +2,11 @@ ## Unreleased -* Added TCP stream reassembly support (in the `std`-only `tcp_reassembly` module, contains allocations): - * `TcpStreamReassemblyBuf`, a re-usable buffer that reconstructs the payload byte stream of a single direction of a TCP connection. It is robust against retransmits, re-ordered & duplicated/overlapping segments, big offset jumps and 32 bit sequence number wrap-around (all bookkeeping is done in a monotonic 64 bit "absolute stream offset" space). Data is exposed in-place via `contiguous` and freed via `consume` ("collect then clean"). - * `TcpStreamReassemblyPool`, a pool that reassembles many streams in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). It interprets the TCP control flags automatically (`SYN` (re)bases a stream / handles reconnects, `FIN` marks the stream end, `RST` drops & recycles) and bounds per-stream memory via a configurable `max_capacity`, returning `TcpReassembleError` when a segment lands beyond the window. - * Supporting types `TcpStreamId`, `TcpStreamIpId`, `TcpSegmentRange`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY` constant, plus a `tcp_reassembly` example. +* Added TCP stream reassembly support (in the `tcp_reassembly` module, contains allocations): + * `TcpStreamReassemblyBuf` (requires the `alloc` feature), a re-usable buffer that reconstructs the payload byte stream of a single direction of a TCP connection. It is robust against retransmits, re-ordered & duplicated/overlapping segments, stale FINs, big offset jumps and 32 bit sequence number wrap-around (all bookkeeping is done in a monotonic 64 bit "absolute stream offset" space). Data is exposed in-place via `contiguous` and freed via `consume` ("collect then clean", amortized O(1) via deferred compaction). Streams whose start was never observed (capture started mid-connection) are reconstructed from the first seen segment and can be identified via `syn_observed`. + * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many streams in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). It interprets the TCP control flags automatically (`SYN` establishes/replaces a stream — duplicated & late SYNs are recognized and keep the buffered state, `FIN` marks the stream end, `RST` closes the stream) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Stream` / `Closed`, where `Closed` still exposes never consumed leftover data of a stream ended by a RST or replaced by a new connection). Streams can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. + * Per-stream memory is bounded via a configurable `max_capacity` (buffered bytes ahead of the read cursor) and `max_sections` (tracked non-contiguous ranges), returning `TcpReassembleError` when a limit would be exceeded (the buffer is left unchanged in that case). + * Supporting types `TcpStreamId`, `TcpStreamIpId`, `TcpSegmentRange`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY` & `DEFAULT_MAX_TCP_STREAM_SECTIONS` constants, plus a `tcp_reassembly` example. * Added zero-copy IGMP support (IGMPv1, IGMPv2 & IGMPv3): * `IgmpSlice`, an enum with one zero-copy variant per message type (`MembershipQuery`, `MembershipQueryWithSources`, `MembershipReportV1`, `MembershipReportV2`, `MembershipReportV3`, `LeaveGroup` & `Unknown`), plus common accessors (`header`, `payload`, `slice`, `checksum`, `is_checksum_valid`). diff --git a/etherparse/examples/tcp_reassembly.rs b/etherparse/examples/tcp_reassembly.rs index 9d1e06f0..dda63bcb 100644 --- a/etherparse/examples/tcp_reassembly.rs +++ b/etherparse/examples/tcp_reassembly.rs @@ -42,12 +42,17 @@ fn main() { }; match pool.process_sliced_packet(&sliced_packet, (), ()) { - Ok(Some(stream)) => { + Ok(TcpReassemblyEvent::Stream(stream)) => { // collect the in-order data that is available so far let available = stream.contiguous(); if false == available.is_empty() { println!( - "in-order data available: {:?}", + "in-order data available{}: {:?}", + if stream.syn_observed() { + "" + } else { + " (stream start not observed, prefix missing)" + }, core::str::from_utf8(available).unwrap_or("") ); @@ -63,12 +68,32 @@ fn main() { fin_announced = true; } } - Ok(None) => { - // not a TCP packet (or a RST reset the stream) + Ok(TcpReassemblyEvent::Closed(stream)) => { + // a RST ended the stream (or a new connection replaced it): + // the not yet consumed data can still be drained here + println!( + "stream closed, leftover data: {:?}", + core::str::from_utf8(stream.contiguous()).unwrap_or("") + ); + } + Ok(TcpReassemblyEvent::Ignored) => { + // not a TCP packet (or an empty segment of an unknown stream) } Err(err) => { println!("Error reassembling TCP stream: {err}"); } } } + + // at the end of the capture: drain whatever is left in the still + // tracked streams + for (_id, stream, _timestamp) in pool.iter_mut() { + let leftover = stream.contiguous(); + if false == leftover.is_empty() { + println!( + "leftover data at end of capture: {:?}", + core::str::from_utf8(leftover).unwrap_or("") + ); + } + } } diff --git a/etherparse/src/lib.rs b/etherparse/src/lib.rs index 74867cc5..b0bcc113 100644 --- a/etherparse/src/lib.rs +++ b/etherparse/src/lib.rs @@ -326,8 +326,12 @@ pub mod err; pub mod defrag; /// Module containing helpers to re-assemble TCP payload streams (contains allocations). -#[cfg(feature = "std")] -#[cfg_attr(docsrs, doc(cfg(feature = "std")))] +/// +/// [`tcp_reassembly::TcpStreamReassemblyBuf`] only requires the `alloc` +/// feature, while [`tcp_reassembly::TcpStreamReassemblyPool`] additionally +/// requires the `std` feature. +#[cfg(feature = "alloc")] +#[cfg_attr(docsrs, doc(cfg(feature = "alloc")))] pub mod tcp_reassembly; mod link; diff --git a/etherparse/src/tcp_reassembly/mod.rs b/etherparse/src/tcp_reassembly/mod.rs index 21cfb082..95e33fe1 100644 --- a/etherparse/src/tcp_reassembly/mod.rs +++ b/etherparse/src/tcp_reassembly/mod.rs @@ -13,9 +13,16 @@ pub use tcp_stream_ip_id::*; mod tcp_stream_reassembly_buf; pub use tcp_stream_reassembly_buf::*; +#[cfg(feature = "std")] mod tcp_stream_reassembly_pool; +#[cfg(feature = "std")] pub use tcp_stream_reassembly_pool::*; /// Default maximum number of bytes buffered ahead of the read cursor per /// TCP stream (used by [`TcpStreamReassemblyPool::new`]). pub const DEFAULT_MAX_TCP_STREAM_CAPACITY: usize = 1 << 20; + +/// Default maximum number of separate (non-contiguous) data sections that +/// are tracked per TCP stream (used by [`TcpStreamReassemblyBuf::new`] and +/// [`TcpStreamReassemblyPool::new`]). +pub const DEFAULT_MAX_TCP_STREAM_SECTIONS: usize = 1024; diff --git a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs index d083b001..72cae2c4 100644 --- a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs +++ b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs @@ -29,6 +29,18 @@ pub enum TcpReassembleError { /// Number of bytes that were attempted to be allocated. len: usize, }, + + /// Error if storing a segment would require tracking more separate + /// (non-contiguous) data sections than allowed. + /// + /// This guards against unbounded growth of the section bookkeeping + /// caused by maliciously crafted packets (e.g. many one byte segments + /// that are separated by gaps). + TooManySections { + /// Maximum number of separate (non-contiguous) data sections that + /// can be tracked at the same time. + max_sections: usize, + }, } impl core::fmt::Display for TcpReassembleError { @@ -37,6 +49,7 @@ impl core::fmt::Display for TcpReassembleError { match self { SegmentBeyondMaxWindow { end_offset, max_capacity } => write!(f, "Received a TCP segment that ends {end_offset} bytes ahead of the read cursor which exceeds the maximum buffer capacity of {max_capacity} bytes."), AllocationFailure { len } => write!(f, "Failed to allocate {len} bytes of memory to reconstruct the TCP stream."), + TooManySections { max_sections } => write!(f, "Received a TCP segment that would require tracking more than the maximum of {max_sections} separate data sections."), } } } @@ -92,6 +105,10 @@ mod tests { AllocationFailure { len: 128 }, "Failed to allocate 128 bytes of memory to reconstruct the TCP stream.", ), + ( + TooManySections { max_sections: 1024 }, + "Received a TCP segment that would require tracking more than the maximum of 1024 separate data sections.", + ), ]; for test in tests { assert_eq!(format!("{}", test.0), test.1); @@ -108,5 +125,6 @@ mod tests { } .source() .is_none()); + assert!(TooManySections { max_sections: 0 }.source().is_none()); } } diff --git a/etherparse/src/tcp_reassembly/tcp_stream_id.rs b/etherparse/src/tcp_reassembly/tcp_stream_id.rs index b6aa9720..d48ed0a3 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_id.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_id.rs @@ -11,10 +11,7 @@ use arrayvec::ArrayVec; /// differentiate streams if the addresses & ports alone are not enough /// (e.g. when capturing from multiple interfaces). #[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct TcpStreamId -where - CustomChannelId: core::hash::Hash + Eq + PartialEq + Clone + Sized, -{ +pub struct TcpStreamId { /// VLAN id's of the original packets. pub vlan_ids: ArrayVec, diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs index a70f696c..bf9c879b 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs @@ -1,5 +1,10 @@ use crate::tcp_reassembly::*; -use std::vec::Vec; +use alloc::vec::Vec; + +/// Number of consumed bytes that have to accumulate at the front of +/// [`TcpStreamReassemblyBuf::data`] before a compaction (copy of the +/// remaining data to the front) is triggered. +const COMPACT_THRESHOLD: usize = 4096; /// Buffer to reconstruct the payload byte stream of a single direction of a /// TCP connection (re-usable to minimize allocations). @@ -23,6 +28,15 @@ use std::vec::Vec; /// available starting at the current read cursor. /// * [`TcpStreamReassemblyBuf::consume`] advances the read cursor and frees the /// processed bytes so the buffer memory can be re-used. +/// +/// # Streams without an observed connection setup +/// +/// If the start of a stream is unknown (no SYN was observed, e.g. when a +/// capture starts in the middle of a connection) the buffer anchors itself on +/// the first added segment. Such streams are still reconstructed, but their +/// prefix is missing. [`TcpStreamReassemblyBuf::syn_observed`] allows +/// differentiating these streams from streams that were re-based to a known +/// stream start via [`TcpStreamReassemblyBuf::reset`]. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct TcpStreamReassemblyBuf { /// Sequence number that is mapped to `base_offset`. @@ -30,28 +44,50 @@ pub struct TcpStreamReassemblyBuf { /// `None` until the first segment (or SYN) established the base. base_seq: Option, - /// Absolute stream offset of the first byte in `data` (the read cursor). + /// Absolute stream offset of the read cursor (start of the still + /// buffered data). base_offset: u64, - /// Buffer holding the reconstructed bytes starting at `base_offset`. + /// Number of already consumed bytes at the start of `data` (kept around + /// to make `consume` cheap, freed in batches via compaction). + head: usize, + + /// Buffer holding the reconstructed bytes (`data[head]` is the byte at + /// `base_offset`). /// /// Not yet received gaps are present as zeroed bytes and are only exposed /// once they are part of the contiguous prefix (see [`Self::contiguous`]). data: Vec, /// Ranges (in absolute stream offsets) that have been filled with data. + /// + /// Sorted by start offset, the ranges are non-overlapping and + /// non-adjacent (they get merged). sections: Vec, /// Absolute stream offset one past the last byte of the stream (set once a /// segment with the FIN flag was received). fin_offset: Option, + /// True if the stream start is known (established via [`Self::reset`], + /// e.g. from an observed SYN), false if the buffer had to anchor itself + /// on the first added segment. + syn_observed: bool, + /// Maximum number of bytes that may be buffered ahead of the read cursor. max_capacity: usize, + + /// Maximum number of separate (non-contiguous) data sections that may + /// be tracked at the same time. + max_sections: usize, } impl TcpStreamReassemblyBuf { /// Creates a new buffer re-using the given (cleared) buffers. + /// + /// The maximum number of separately tracked data sections is set to + /// [`DEFAULT_MAX_TCP_STREAM_SECTIONS`] (adjustable via + /// [`TcpStreamReassemblyBuf::with_max_sections`]). pub fn new( mut data: Vec, mut sections: Vec, @@ -62,13 +98,24 @@ impl TcpStreamReassemblyBuf { TcpStreamReassemblyBuf { base_seq: None, base_offset: 0, + head: 0, data, sections, fin_offset: None, + syn_observed: false, max_capacity, + max_sections: DEFAULT_MAX_TCP_STREAM_SECTIONS, } } + /// Sets the maximum number of separate (non-contiguous) data sections + /// that may be tracked at the same time (see + /// [`TcpReassembleError::TooManySections`]). + pub fn with_max_sections(mut self, max_sections: usize) -> TcpStreamReassemblyBuf { + self.max_sections = max_sections; + self + } + /// Sequence number that is currently mapped to the read cursor (start of /// the still buffered data), or `None` if no data was added yet. #[inline] @@ -83,13 +130,14 @@ impl TcpStreamReassemblyBuf { self.base_offset } - /// Raw buffer (including not yet contiguous, zeroed gaps). + /// Buffered bytes starting at the read cursor (including not yet + /// contiguous, zeroed gaps). #[inline] pub fn data(&self) -> &[u8] { - &self.data + &self.data[self.head..] } - /// Filled ranges (in absolute stream offsets). + /// Filled ranges (in absolute stream offsets), sorted by start offset. #[inline] pub fn sections(&self) -> &[TcpSegmentRange] { &self.sections @@ -102,78 +150,63 @@ impl TcpStreamReassemblyBuf { self.fin_offset } + /// True if the stream start is known (the buffer was re-based to a known + /// start via [`TcpStreamReassemblyBuf::reset`], e.g. because a SYN was + /// observed). + /// + /// False if the buffer anchored itself on the first added segment (e.g. + /// the capture started in the middle of a connection). The stream is + /// still reconstructed in that case, but an unknown prefix is missing. + #[inline] + pub fn syn_observed(&self) -> bool { + self.syn_observed + } + /// Maximum number of bytes that may be buffered ahead of the read cursor. #[inline] pub fn max_capacity(&self) -> usize { self.max_capacity } - /// Re-base the buffer to a new starting sequence number (e.g. after a new - /// SYN / reconnect) and drop all previously buffered data. + /// Maximum number of separate (non-contiguous) data sections that may be + /// tracked at the same time. + #[inline] + pub fn max_sections(&self) -> usize { + self.max_sections + } + + /// Re-base the buffer to a new known starting sequence number (e.g. from + /// the SYN of a new connection) and drop all previously buffered data. /// - /// The underlying allocations are kept for re-use. + /// This also marks the stream start as known (see + /// [`TcpStreamReassemblyBuf::syn_observed`]). The underlying allocations + /// are kept for re-use. pub fn reset(&mut self, base_seq: u32) { self.data.clear(); self.sections.clear(); self.base_seq = Some(base_seq); self.base_offset = 0; + self.head = 0; self.fin_offset = None; + self.syn_observed = true; } - /// Shift the whole buffer forward by `shift` bytes and re-anchor it to a - /// new (lower) base sequence number. - /// - /// Only valid while nothing has been consumed yet (`base_offset == 0`). - #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] - fn rebase_backwards( - &mut self, - new_base_seq: u32, - shift: u64, - ) -> Result<(), TcpReassembleError> { - use TcpReassembleError::*; - - let old_len = self.data.len(); - let Ok(shift_usize) = usize::try_from(shift) else { - return Err(SegmentBeyondMaxWindow { - end_offset: shift, - max_capacity: self.max_capacity, - }); - }; - let Some(new_len) = shift_usize.checked_add(old_len) else { - return Err(SegmentBeyondMaxWindow { - end_offset: shift, - max_capacity: self.max_capacity, - }); - }; - // the whole buffer sits ahead of the (still at 0) read cursor - if new_len > self.max_capacity { - return Err(SegmentBeyondMaxWindow { - end_offset: new_len as u64, - max_capacity: self.max_capacity, - }); - } - if self.data.capacity() < new_len && self.data.try_reserve(new_len - old_len).is_err() { - return Err(AllocationFailure { len: new_len }); - } - - // move existing data forward and zero the newly exposed front - self.data.resize(new_len, 0); - self.data.copy_within(0..old_len, shift_usize); - for b in &mut self.data[..shift_usize] { - *b = 0; - } - - // shift the recorded ranges & fin position - for s in &mut self.sections { - s.start += shift; - s.end += shift; - } - if let Some(fin) = &mut self.fin_offset { - *fin += shift; - } + /// Marks the stream start as known without re-basing (e.g. when a SYN + /// matching the already established base is observed late). + pub fn mark_syn_observed(&mut self) { + self.syn_observed = true; + } - self.base_seq = Some(new_base_seq); - Ok(()) + /// Maps a sequence number into the absolute stream offset space (using + /// RFC 1982 serial number arithmetic relative to the read cursor), or + /// `None` if no base was established yet. + /// + /// A result of `0` references the start of the stream, negative values + /// reference positions before the established stream start (e.g. useful + /// to check if the sequence number of a late SYN matches the already + /// established stream start). + pub fn seq_stream_offset(&self, seq: u32) -> Option { + self.base_seq.map(|base| self.seq_to_abs_offset(base, seq)) } /// Maps a sequence number into the absolute stream offset space relative to @@ -199,111 +232,221 @@ impl TcpStreamReassemblyBuf { /// the stream one byte past the payload). /// /// Retransmits, re-ordered segments and duplicated / overlapping payloads - /// are handled silently. Only segments landing more than `max_capacity` - /// bytes ahead of the read cursor or allocation failures are reported as - /// errors. - #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] + /// are handled silently. Errors are only returned for segments landing + /// more than `max_capacity` bytes ahead of the read cursor, segments that + /// would require tracking more than `max_sections` separate data sections + /// and allocation failures. In case of an error the buffer is left + /// unmodified. pub fn add(&mut self, seq: u32, payload: &[u8], fin: bool) -> Result<(), TcpReassembleError> { use TcpReassembleError::*; - // establish the base on the first ever segment - if self.base_seq.is_none() { - self.base_seq = Some(seq); - } - let base_seq = self.base_seq.unwrap(); + // base used for the offset mapping (anchor on the first segment if + // no base was established, only committed to `self` on success) + let base_seq = self.base_seq.unwrap_or(seq); - let mut start_abs = self.seq_to_abs_offset(base_seq, seq); + let start_abs = self.seq_to_abs_offset(base_seq, seq); + let end_abs = start_abs + payload.len() as i128; + let cursor = self.base_offset as i128; + let buffered_len = self.data.len() - self.head; // If data arrives that starts before the front of the buffer while // nothing has been consumed yet (`base_offset == 0`), the base was // anchored too late (e.g. segments arriving highest-sequence-first at - // the start of the capture). Re-anchor the buffer backwards so the - // earlier data can be kept. - if false == payload.is_empty() && self.base_offset == 0 && start_abs < 0 { - let shift = (-start_abs) as u64; - self.rebase_backwards(seq, shift)?; - start_abs = 0; - } - - let end_abs = start_abs + payload.len() as i128; + // the start of the capture). Plan a backwards re-anchoring of the + // buffer (shift of the buffered data) so the earlier data can be kept. + // Note that `base_offset == 0` implies `head == 0` (nothing consumed). + let rebase_shift: usize = + if false == payload.is_empty() && self.base_offset == 0 && start_abs < 0 { + let Ok(shift) = usize::try_from(-start_abs) else { + return Err(SegmentBeyondMaxWindow { + end_offset: u64::try_from(-start_abs) + .unwrap_or(u64::MAX) + .saturating_add(buffered_len as u64), + max_capacity: self.max_capacity, + }); + }; + // the whole shifted buffer sits ahead of the (still at 0) + // read cursor + let rebased_len = shift.checked_add(buffered_len); + match rebased_len { + Some(rebased_len) if rebased_len <= self.max_capacity => shift, + _ => { + return Err(SegmentBeyondMaxWindow { + end_offset: (shift as u64).saturating_add(buffered_len as u64), + max_capacity: self.max_capacity, + }); + } + } + } else { + 0 + }; - // record the end of the stream if the FIN flag is set (FIN occupies the - // sequence number right after the payload) - if fin { - let fin_pos = if end_abs < 0 { 0 } else { end_abs as u64 }; - self.fin_offset = Some(fin_pos); - } + // absolute payload position after the potential re-anchoring (the + // re-anchoring moves the payload start to offset 0) + let (eff_start, eff_end) = if rebase_shift > 0 { + (0i128, payload.len() as i128) + } else { + (start_abs, end_abs) + }; - // nothing to store for an empty payload (e.g. pure SYN/ACK/FIN segments) - if payload.is_empty() { - return Ok(()); - } + // the end of the stream indicated by the FIN flag (the FIN occupies + // the sequence number right after the payload). FINs before the read + // cursor are stale (e.g. wrapped far-future sequence numbers) and + // ignored, as is a second FIN at a different position (first wins). + let planned_fin: Option = if fin && self.fin_offset.is_none() && eff_end >= cursor { + Some(eff_end as u64) + } else { + None + }; // trim the part that lies before the read cursor (already consumed / - // retransmitted data) - let cursor = self.base_offset as i128; - let (payload, start_abs) = if start_abs < cursor { - let trim = (cursor - start_abs) as usize; - if trim >= payload.len() { - // fully before the cursor -> nothing to store - return Ok(()); - } - (&payload[trim..], cursor) + // retransmitted data) & enforce the maximum buffer window + let write: Option<(&[u8], i128)> = if payload.is_empty() { + None + } else if cursor - eff_start >= payload.len() as i128 { + // fully before the cursor -> nothing to store + None } else { - (payload, start_abs) + let (p, s) = if eff_start < cursor { + (&payload[(cursor - eff_start) as usize..], cursor) + } else { + (payload, eff_start) + }; + let end_offset = (s + p.len() as i128 - cursor) as u64; + if end_offset > self.max_capacity as u64 { + return Err(SegmentBeyondMaxWindow { + end_offset, + max_capacity: self.max_capacity, + }); + } + Some((p, s)) }; - let end_abs = start_abs + payload.len() as i128; - // enforce the maximum buffer window (measured from the read cursor) - let end_offset = (end_abs - cursor) as u64; - if end_offset > self.max_capacity as u64 { - return Err(SegmentBeyondMaxWindow { - end_offset, - max_capacity: self.max_capacity, - }); + // check the section limit (in pre-re-anchoring coordinates, as the + // recorded sections are not shifted yet) + if let Some((p, s)) = write { + let (cur_start, cur_end) = if rebase_shift > 0 { + (start_abs, end_abs) + } else { + (s, s + p.len() as i128) + }; + let lo = self + .sections + .partition_point(|sec| (sec.end as i128) < cur_start); + let hi = self + .sections + .partition_point(|sec| (sec.start as i128) <= cur_end); + // `lo == hi` means the payload connects to no existing section + if lo == hi && self.sections.len() >= self.max_sections { + return Err(TooManySections { + max_sections: self.max_sections, + }); + } } - let data_start = (start_abs - self.base_offset as i128) as usize; - let data_end = data_start + payload.len(); - - // grow the buffer if required (gaps are zero filled) - if self.data.len() < data_end { - if self.data.capacity() < data_end + // pre-allocate the required space (last fallible step) + if let Some((p, s)) = write { + let data_end: usize = if rebase_shift > 0 { + // head == 0 in the re-anchoring case + core::cmp::max(rebase_shift + buffered_len, p.len()) + } else { + let end_off = (s + p.len() as i128 - cursor) as usize; + match self.head.checked_add(end_off) { + Some(v) => v, + None => { + // free the consumed prefix to make the write indexable + self.compact(); + end_off + } + } + }; + if self.data.len() < data_end + && self.data.capacity() < data_end && self.data.try_reserve(data_end - self.data.len()).is_err() { return Err(AllocationFailure { len: data_end }); } - self.data.resize(data_end, 0); } - // write the payload - self.data[data_start..data_end].copy_from_slice(payload); + // -- all checks done, commit (no errors past this point) -- - // merge the new range into the existing sections (dedup / overlap) - let mut new_section = TcpSegmentRange { - start: start_abs as u64, - end: end_abs as u64, - }; - self.sections.retain(|it| -> bool { - if let Some(merged) = new_section.merge(*it) { - new_section = merged; - false - } else { - true + self.base_seq = Some(base_seq); + + if rebase_shift > 0 { + // move existing data forward, zero the newly exposed front and + // re-anchor to the new (lower) base sequence number + let old_len = self.data.len(); + self.data.resize(old_len + rebase_shift, 0); + self.data.copy_within(0..old_len, rebase_shift); + for b in &mut self.data[..rebase_shift] { + *b = 0; + } + for sec in &mut self.sections { + sec.start += rebase_shift as u64; + sec.end += rebase_shift as u64; + } + if let Some(fin_offset) = &mut self.fin_offset { + *fin_offset += rebase_shift as u64; + } + self.base_seq = Some(seq); + } + + if let Some(fin_offset) = planned_fin { + self.fin_offset = Some(fin_offset); + } + + if let Some((p, s)) = write { + // grow the buffer if required (gaps are zero filled) & write + let data_start = self.head + (s - cursor) as usize; + let data_end = data_start + p.len(); + if self.data.len() < data_end { + self.data.resize(data_end, 0); } - }); - self.sections.push(new_section); + self.data[data_start..data_end].copy_from_slice(p); + + self.insert_section(TcpSegmentRange { + start: s as u64, + end: (s + p.len() as i128) as u64, + }); + } Ok(()) } + /// Insert a filled range into the sorted section list (merging it with + /// overlapping or directly adjacent sections). + fn insert_section(&mut self, mut range: TcpSegmentRange) { + // sections connected to the new range (sections are sorted by start + // & disjoint, so they are also sorted by end) + let lo = self.sections.partition_point(|s| s.end < range.start); + let hi = self.sections.partition_point(|s| s.start <= range.end); + if lo < hi { + range.start = core::cmp::min(range.start, self.sections[lo].start); + range.end = core::cmp::max(range.end, self.sections[hi - 1].end); + self.sections[lo] = range; + self.sections.drain(lo + 1..hi); + } else { + self.sections.insert(lo, range); + } + } + + /// Moves the still buffered data to the front of `data` (freeing the + /// memory of the already consumed prefix for re-use). + fn compact(&mut self) { + if self.head > 0 { + let len = self.data.len(); + self.data.copy_within(self.head..len, 0); + self.data.truncate(len - self.head); + self.head = 0; + } + } + /// Length of the in-order data available starting at the read cursor. fn contiguous_len(&self) -> usize { - self.sections - .iter() - .find(|s| s.start == self.base_offset) - .map(|s| (s.end - self.base_offset) as usize) - .unwrap_or(0) + match self.sections.first() { + Some(s) if s.start == self.base_offset => (s.end - self.base_offset) as usize, + _ => 0, + } } /// Returns the in-order bytes that are available starting at the read @@ -313,7 +456,7 @@ impl TcpStreamReassemblyBuf { /// received yet (a gap at the front of the stream). #[inline] pub fn contiguous(&self) -> &[u8] { - &self.data[..self.contiguous_len()] + &self.data[self.head..self.head + self.contiguous_len()] } /// Advance the read cursor by `len` bytes and free the consumed data for @@ -326,22 +469,25 @@ impl TcpStreamReassemblyBuf { if len == 0 { return; } - self.data.drain(..len); + self.head += len; self.base_offset += len as u64; self.base_seq = self.base_seq.map(|s| s.wrapping_add(len as u32)); - // drop / trim sections that are now before the read cursor - let base_offset = self.base_offset; - self.sections.retain_mut(|s| { - if s.end <= base_offset { - false - } else { - if s.start < base_offset { - s.start = base_offset; - } - true - } - }); + // `len` was clamped to the contiguous prefix, so only the first + // section is affected + if self.sections[0].end == self.base_offset { + self.sections.remove(0); + } else { + self.sections[0].start = self.base_offset; + } + + // free the consumed prefix once it dominates the buffer + if self.head == self.data.len() { + self.data.clear(); + self.head = 0; + } else if self.head >= COMPACT_THRESHOLD && self.head >= self.data.len() - self.head { + self.compact(); + } } /// Returns `true` once all bytes up to the FIN have been received and are @@ -381,6 +527,16 @@ mod test { result } + /// Checks the section invariants (sorted, non-overlapping, non-adjacent). + fn assert_section_invariants(buf: &TcpStreamReassemblyBuf) { + for w in buf.sections().windows(2) { + assert!(w[0].end < w[1].start); + } + for s in buf.sections() { + assert!(s.start < s.end); + } + } + #[test] fn debug_clone_eq_hash() { let buf = new_buf(); @@ -414,7 +570,11 @@ mod test { assert!(buf.data().is_empty()); assert!(buf.sections().is_empty()); assert_eq!(buf.fin_offset(), None); + assert_eq!(false, buf.syn_observed()); assert_eq!(buf.max_capacity(), 4096); + assert_eq!(buf.max_sections(), DEFAULT_MAX_TCP_STREAM_SECTIONS); + let buf = buf.with_max_sections(3); + assert_eq!(buf.max_sections(), 3); } #[test] @@ -517,6 +677,25 @@ mod test { ); } + #[test] + fn error_leaves_state_unchanged() { + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + buf.add(0, &sequence(0, 4), false).unwrap(); + // segment (with FIN) beyond the window -> error & no state change + let err = buf.add(20, &sequence(20, 8), true).unwrap_err(); + assert_eq!( + err, + TcpReassembleError::SegmentBeyondMaxWindow { + end_offset: 28, + max_capacity: 16 + } + ); + assert_eq!(buf.fin_offset(), None); + assert_eq!(buf.sections().len(), 1); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + assert_eq!(buf.base_sequence_number(), Some(0)); + } + #[test] fn consume_clamped_to_contiguous() { let mut buf = new_buf(); @@ -567,6 +746,66 @@ mod test { assert!(buf.is_fin_reached()); } + #[test] + fn stale_fin_is_ignored() { + // FIN behind the read cursor (e.g. from a wrapped far-future + // sequence number that serial arithmetic interprets as "behind") + let mut buf = new_buf(); + buf.add(1000, &sequence(0, 8), false).unwrap(); + buf.consume(8); + buf.add(900, &[], true).unwrap(); + assert_eq!(buf.fin_offset(), None); + assert_eq!(false, buf.is_fin_reached()); + + // a FIN exactly at the read cursor is still valid (retransmit of + // the FIN after everything was consumed) + buf.add(1008, &[], true).unwrap(); + assert_eq!(buf.fin_offset(), Some(8)); + assert!(buf.is_fin_reached()); + } + + #[test] + fn first_fin_wins() { + let mut buf = new_buf(); + buf.add(100, &sequence(0, 4), true).unwrap(); + assert_eq!(buf.fin_offset(), Some(4)); + // a second FIN at a different position does not overwrite the first + buf.add(100, &sequence(0, 8), true).unwrap(); + assert_eq!(buf.fin_offset(), Some(4)); + } + + #[test] + fn syn_observed_flag() { + // anchored on the first segment -> stream start unknown + let mut buf = new_buf(); + assert_eq!(false, buf.syn_observed()); + buf.add(1000, &sequence(0, 4), false).unwrap(); + assert_eq!(false, buf.syn_observed()); + + // re-based to a known start -> stream start known + buf.reset(2000); + assert!(buf.syn_observed()); + + // late marking without re-base + let mut buf = new_buf(); + buf.add(1000, &sequence(0, 4), false).unwrap(); + buf.mark_syn_observed(); + assert!(buf.syn_observed()); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + } + + #[test] + fn seq_stream_offset() { + let mut buf = new_buf(); + assert_eq!(buf.seq_stream_offset(123), None); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), false).unwrap(); + buf.consume(8); + assert_eq!(buf.seq_stream_offset(1000), Some(0)); + assert_eq!(buf.seq_stream_offset(1008), Some(8)); + assert_eq!(buf.seq_stream_offset(996), Some(-4)); + } + #[test] fn reset_rebases() { let mut buf = new_buf(); @@ -649,7 +888,7 @@ mod test { // a segment that would force a prepend beyond max_capacity is rejected let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); buf.add(1000, &sequence(0, 4), false).unwrap(); - // seq 980 is 20 bytes behind the anchor -> prepend of 20 > 16 + // seq 980 is 20 bytes behind the anchor -> prepend of 20 + 4 buffered > 16 let err = buf.add(980, &sequence(0, 4), false).unwrap_err(); assert_eq!( err, @@ -660,6 +899,7 @@ mod test { ); // the original data is untouched assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + assert_eq!(buf.base_sequence_number(), Some(1000)); } #[test] @@ -674,6 +914,65 @@ mod test { assert_eq!(buf.contiguous(), &[]); } + #[test] + fn rebase_with_fin_and_overlap() { + let mut buf = new_buf(); + // anchor at seq 1000 & record a FIN at relative offset 8 + buf.add(1000, &sequence(10, 4), false).unwrap(); + buf.add(1004, &sequence(14, 4), true).unwrap(); + assert_eq!(buf.fin_offset(), Some(8)); + // earlier data re-anchors the buffer; the recorded fin must shift + buf.add(990, &sequence(0, 10), false).unwrap(); + assert_eq!(buf.base_sequence_number(), Some(990)); + assert_eq!(buf.fin_offset(), Some(18)); + assert_eq!(buf.contiguous(), &sequence(0, 18)[..]); + assert!(buf.is_fin_reached()); + assert_section_invariants(&buf); + } + + #[test] + fn too_many_sections() { + let mut buf = + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 1 << 16).with_max_sections(2); + buf.add(0, &sequence(0, 1), false).unwrap(); + buf.add(10, &sequence(10, 1), false).unwrap(); + assert_eq!(buf.sections().len(), 2); + // a third separate section is rejected ... + let err = buf.add(20, &sequence(20, 1), false).unwrap_err(); + assert_eq!(err, TcpReassembleError::TooManySections { max_sections: 2 }); + // ... while extending / merging existing sections still works + buf.add(1, &sequence(1, 1), false).unwrap(); + buf.add(2, &sequence(2, 8), false).unwrap(); + assert_eq!(buf.sections().len(), 1); + assert_eq!(buf.contiguous(), &sequence(0, 11)[..]); + assert_section_invariants(&buf); + } + + #[test] + fn consume_compaction() { + let mut buf = new_buf(); + let payload = sequence(0, 3 * COMPACT_THRESHOLD); + buf.add(0, &payload, false).unwrap(); + + // small consumes below the compaction threshold + buf.consume(100); + assert_eq!(buf.contiguous(), &payload[100..]); + + // consume enough that the consumed prefix dominates the buffer + buf.consume(2 * COMPACT_THRESHOLD); + let consumed = 100 + 2 * COMPACT_THRESHOLD; + assert_eq!(buf.contiguous(), &payload[consumed..]); + assert_eq!(buf.data().len(), payload.len() - consumed); + + // adding & consuming afterwards still works + let extra = sequence(payload.len(), 32); + buf.add(payload.len() as u32, &extra, false).unwrap(); + buf.consume(payload.len() - consumed); + assert_eq!(buf.contiguous(), &extra[..]); + buf.consume(32); + assert_eq!(buf.contiguous(), &[]); + } + proptest! { /// Feed a reference byte stream split into random, re-ordered, /// duplicated and re-transmitted segments (with an ISN chosen anywhere @@ -728,11 +1027,18 @@ mod test { let seq = isn.wrapping_add(offset as u32); let is_last = offset + len == reference.len(); buf.add(seq, &reference[offset..offset + len], is_last).unwrap(); - // occasionally drain the available contiguous data + + // section invariants: sorted, non-overlapping, non-adjacent + for w in buf.sections().windows(2) { + prop_assert!(w[0].end < w[1].start); + } + + // occasionally drain a random part of the contiguous data if next() & 3 == 0 { let avail = buf.contiguous().len(); - collected.extend_from_slice(buf.contiguous()); - buf.consume(avail); + let take = if avail == 0 { 0 } else { next() as usize % (avail + 1) }; + collected.extend_from_slice(&buf.contiguous()[..take]); + buf.consume(take); } } @@ -742,6 +1048,7 @@ mod test { buf.consume(avail); prop_assert_eq!(&collected, &reference); + prop_assert!(buf.syn_observed()); if false == reference.is_empty() { prop_assert!(buf.is_fin_reached()); } diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs index 884ddf98..c41a64a5 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs @@ -2,6 +2,33 @@ use crate::{tcp_reassembly::*, *}; use std::collections::HashMap; use std::vec::Vec; +/// Result of processing a packet with a [`TcpStreamReassemblyPool`]. +#[derive(Debug)] +pub enum TcpReassemblyEvent<'a> { + /// The packet did not affect any stream (not a TCP segment, a RST for an + /// unknown stream or an empty segment of an unknown stream, e.g. a pure + /// ACK). + Ignored, + + /// The segment belongs to this (potentially newly created) stream. + /// + /// Read the available in-order data via + /// [`TcpStreamReassemblyBuf::contiguous`] and free it via + /// [`TcpStreamReassemblyBuf::consume`]. + Stream(&'a mut TcpStreamReassemblyBuf), + + /// A stream was ended by a RST or replaced by a SYN with a new initial + /// sequence number. + /// + /// In-order data that was never consumed can still be read from the + /// returned buffer. The buffer is automatically recycled on the next + /// `process_*` call. + /// + /// In the "replaced by a SYN" case the newly created stream can be + /// accessed via [`TcpStreamReassemblyPool::stream_mut`]. + Closed(&'a mut TcpStreamReassemblyBuf), +} + /// Pool to reassemble the payload byte streams of multiple TCP connections in /// parallel (re-uses buffers to minimize allocations). /// @@ -11,22 +38,50 @@ use std::vec::Vec; /// interfaces). Note that each *direction* of a connection is a separate stream /// (the source & destination are swapped between the two directions). /// +/// # Interpretation of the TCP flags +/// +/// * `SYN` establishes the stream start. Duplicated/retransmitted SYNs and a +/// late SYN of an already tracked stream are recognized and do **not** drop +/// already buffered data. A SYN with a new initial sequence number replaces +/// the tracked stream (see [`TcpReassemblyEvent::Closed`]). +/// * `FIN` marks the end of the stream (see +/// [`TcpStreamReassemblyBuf::is_fin_reached`]). Note that a stream is *not* +/// automatically removed from the pool when the FIN is reached (use +/// [`TcpStreamReassemblyPool::end_stream`] or +/// [`TcpStreamReassemblyPool::retain`]). +/// * `RST` ends the stream & recycles its buffers (see +/// [`TcpReassemblyEvent::Closed`]). +/// +/// Streams for which no SYN was observed (e.g. when the capture starts in the +/// middle of a connection) are anchored on their first segment and +/// reconstructed from there on. They can be identified via +/// [`TcpStreamReassemblyBuf::syn_observed`]. +/// +/// # IP fragmentation +/// +/// [`TcpStreamReassemblyPool::process_sliced_packet`] ignores IP fragments +/// (the TCP layer of a fragmented packet is not decoded). Re-assemble +/// fragmented packets first (e.g. via [`crate::defrag::IpDefragPool`]) and +/// feed the result via [`TcpStreamReassemblyPool::process_tcp`]. +/// /// # This implementation is NOT safe against "Out of Memory" attacks /// -/// While each individual stream is bounded by `max_capacity`, the number of -/// parallel streams is not. If you use the [`TcpStreamReassemblyPool`] in an -/// untrusted environment an attacker could cause an "out of memory error" by -/// opening up many parallel connections. Use [`TcpStreamReassemblyPool::retain`] -/// (or a custom `channel_id` limit) to evict stale streams. +/// While each individual stream is bounded (`max_capacity` bytes of buffered +/// data & `max_sections` tracked ranges), the number of parallel streams is +/// not. If you use the [`TcpStreamReassemblyPool`] in an untrusted environment +/// an attacker could cause an "out of memory error" by opening up many +/// parallel connections. Use [`TcpStreamReassemblyPool::retain`] (or a custom +/// `channel_id` limit) to evict stale streams. #[derive(Debug, Clone)] -pub struct TcpStreamReassemblyPool -where - Timestamp: Sized + core::fmt::Debug + Clone, - CustomChannelId: Sized + core::fmt::Debug + Clone + core::hash::Hash + Eq + PartialEq, -{ +pub struct TcpStreamReassemblyPool { /// Currently reconstructing TCP streams. active: HashMap, (TcpStreamReassemblyBuf, Timestamp)>, + /// Stream that was closed by the last `process_*` call (RST or replaced + /// via a SYN), kept around so the caller can still drain the leftover + /// data. Recycled at the start of the next `process_*` call. + pending_closed: Option, + /// Data buffers that can be re-used. finished_data_bufs: Vec>, @@ -35,15 +90,43 @@ where /// Maximum number of bytes buffered ahead of the read cursor per stream. default_max_capacity: usize, + + /// Maximum number of separate (non-contiguous) data sections per stream. + default_max_sections: usize, +} + +/// Takes a buffer from the free lists (or allocates a new one). +fn pop_free_buf( + free_data: &mut Vec>, + free_sections: &mut Vec>, + max_capacity: usize, + max_sections: usize, +) -> TcpStreamReassemblyBuf { + TcpStreamReassemblyBuf::new( + free_data.pop().unwrap_or_default(), + free_sections.pop().unwrap_or_default(), + max_capacity, + ) + .with_max_sections(max_sections) +} + +/// Returns the allocations of the given buffer to the free lists. +fn recycle_buf( + free_data: &mut Vec>, + free_sections: &mut Vec>, + buf: TcpStreamReassemblyBuf, +) { + let (data, sections) = buf.take_bufs(); + free_data.push(data); + free_sections.push(sections); } impl TcpStreamReassemblyPool where - Timestamp: Sized + core::fmt::Debug + Clone, - CustomChannelId: Sized + core::fmt::Debug + Clone + core::hash::Hash + Eq + PartialEq, + CustomChannelId: core::hash::Hash + Eq, { - /// Creates a new pool with the [`DEFAULT_MAX_TCP_STREAM_CAPACITY`] per - /// stream limit. + /// Creates a new pool with the [`DEFAULT_MAX_TCP_STREAM_CAPACITY`] & + /// [`DEFAULT_MAX_TCP_STREAM_SECTIONS`] per stream limits. pub fn new() -> TcpStreamReassemblyPool { Self::with_max_capacity(DEFAULT_MAX_TCP_STREAM_CAPACITY) } @@ -51,33 +134,44 @@ where /// Creates a new pool with a custom per stream buffer limit. pub fn with_max_capacity( max_capacity: usize, + ) -> TcpStreamReassemblyPool { + Self::with_limits(max_capacity, DEFAULT_MAX_TCP_STREAM_SECTIONS) + } + + /// Creates a new pool with custom per stream buffer & section limits. + pub fn with_limits( + max_capacity: usize, + max_sections: usize, ) -> TcpStreamReassemblyPool { TcpStreamReassemblyPool { active: HashMap::new(), + pending_closed: None, finished_data_bufs: Vec::new(), finished_section_bufs: Vec::new(), default_max_capacity: max_capacity, + default_max_sections: max_sections, } } /// Process a TCP segment contained in a [`SlicedPacket`]. /// /// Returns: - /// * `Ok(None)` if the packet did not contain a TCP segment (or a RST reset - /// the stream). - /// * `Ok(Some(&mut buf))` giving access to the affected stream. Read the - /// available in-order data via [`TcpStreamReassemblyBuf::contiguous`] and - /// free it via [`TcpStreamReassemblyBuf::consume`]. + /// * `Ok(TcpReassemblyEvent::Ignored)` if the packet did not affect any + /// stream (e.g. not a TCP segment). + /// * `Ok(TcpReassemblyEvent::Stream(..))` giving access to the affected + /// stream. + /// * `Ok(TcpReassemblyEvent::Closed(..))` if a stream was ended by a RST + /// or replaced by a new connection (leftover data can be drained). /// * `Err` if the segment could not be added (see [`TcpReassembleError`]). pub fn process_sliced_packet( &mut self, slice: &SlicedPacket, timestamp: Timestamp, channel_id: CustomChannelId, - ) -> Result, TcpReassembleError> { + ) -> Result, TcpReassembleError> { // only TCP segments are relevant let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { - return Ok(None); + return Ok(TcpReassemblyEvent::Ignored); }; // extract the source & destination addresses @@ -91,7 +185,7 @@ where destination: v6.header().destination(), }, Some(NetSlice::Arp(_)) | None => { - return Ok(None); + return Ok(TcpReassemblyEvent::Ignored); } }; @@ -126,7 +220,7 @@ where fin: bool, rst: bool, timestamp: Timestamp, - ) -> Result, TcpReassembleError> { + ) -> Result, TcpReassembleError> { self.process(id, sequence_number, payload, syn, fin, rst, timestamp) } @@ -140,91 +234,138 @@ where fin: bool, rst: bool, timestamp: Timestamp, - ) -> Result, TcpReassembleError> { + ) -> Result, TcpReassembleError> { use std::collections::hash_map::Entry; + use TcpReassemblyEvent::*; + + // recycle the buffer of the stream that was closed by the previous + // call (the caller had the chance to drain it until now) + if let Some(closed) = self.pending_closed.take() { + recycle_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + closed, + ); + } - // a RST tears the stream down and recycles its buffers + // a RST tears the stream down if rst { - if let Some((buf, _)) = self.active.remove(&id) { - let (data, sections) = buf.take_bufs(); - self.finished_data_bufs.push(data); - self.finished_section_bufs.push(sections); - } - return Ok(None); + return Ok(match self.active.remove(&id) { + Some((buf, _)) => Closed(self.pending_closed.insert(buf)), + None => Ignored, + }); } - // a SYN (re)establishes the stream base (handles reconnects). The SYN - // flag consumes one sequence number, so the payload starts at seq + 1. if syn { + // the SYN flag consumes one sequence number, so the payload + // starts at seq + 1 let base = seq.wrapping_add(1); match self.active.entry(id) { - Entry::Occupied(mut entry) => { - let value = entry.get_mut(); - value.0.reset(base); + Entry::Occupied(entry) => { + let value = entry.into_mut(); value.1 = timestamp; - // TCP Fast Open: a SYN may already carry payload - if false == payload.is_empty() { - value.0.add(base, payload, fin)?; + + // Position of the data start indicated by the SYN + // relative to the tracked stream start. `<= 0` means the + // SYN belongs to the tracked stream (duplicated or + // retransmitted SYN, or the late SYN of a stream that + // had to anchor itself mid-stream) and the buffered + // state must be kept. `> 0` means a new connection + // re-using the same addresses & ports. + let rel = value.0.seq_stream_offset(base).unwrap_or(0); + if rel <= 0 { + value.0.mark_syn_observed(); + // TCP Fast Open: a SYN may already carry payload + // (and a FIN that has to be recorded) + if fin || false == payload.is_empty() { + value.0.add(base, payload, fin)?; + } + Ok(Stream(&mut value.0)) + } else { + // reconnect: replace the stream with a fresh one + let mut fresh = pop_free_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + self.default_max_capacity, + self.default_max_sections, + ); + fresh.reset(base); + let old = core::mem::replace(&mut value.0, fresh); + let has_leftover = false == old.contiguous().is_empty(); + if has_leftover { + self.pending_closed = Some(old); + } else { + recycle_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + old, + ); + } + if fin || false == payload.is_empty() { + // on error the old stream stays drainable via + // the recycling stash, the fresh stream is kept + value.0.add(base, payload, fin)?; + } + if has_leftover { + // "value" can no longer be returned, but the + // leftover data of the replaced stream can + Ok(Closed(self.pending_closed.as_mut().unwrap())) + } else { + Ok(Stream(&mut value.0)) + } } - Ok(Some(&mut entry.into_mut().0)) } Entry::Vacant(entry) => { - let data_buf = if let Some(mut d) = self.finished_data_bufs.pop() { - d.clear(); - d - } else { - Vec::new() - }; - let sections = if let Some(mut s) = self.finished_section_bufs.pop() { - s.clear(); - s - } else { - Vec::new() - }; - let mut buf = - TcpStreamReassemblyBuf::new(data_buf, sections, self.default_max_capacity); + let mut buf = pop_free_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + self.default_max_capacity, + self.default_max_sections, + ); buf.reset(base); - if false == payload.is_empty() { + if fin || false == payload.is_empty() { if let Err(err) = buf.add(base, payload, fin) { - let (data, sections) = buf.take_bufs(); - self.finished_data_bufs.push(data); - self.finished_section_bufs.push(sections); + recycle_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + buf, + ); return Err(err); } } - Ok(Some(&mut entry.insert((buf, timestamp)).0)) + Ok(Stream(&mut entry.insert((buf, timestamp)).0)) } } } else { // regular data / FIN / ACK segment match self.active.entry(id) { - Entry::Occupied(mut entry) => { - let value = entry.get_mut(); + Entry::Occupied(entry) => { + let value = entry.into_mut(); value.1 = timestamp; value.0.add(seq, payload, fin)?; - Ok(Some(&mut entry.into_mut().0)) + Ok(Stream(&mut value.0)) } Entry::Vacant(entry) => { - let data_buf = if let Some(mut d) = self.finished_data_bufs.pop() { - d.clear(); - d - } else { - Vec::new() - }; - let sections = if let Some(mut s) = self.finished_section_bufs.pop() { - s.clear(); - s - } else { - Vec::new() - }; - let mut buf = - TcpStreamReassemblyBuf::new(data_buf, sections, self.default_max_capacity); + // segments without payload & FIN carry no data for the + // reassembly -> don't create a stream for them (e.g. + // pure ACKs, port scans, keep alives of unknown streams) + if payload.is_empty() && false == fin { + return Ok(Ignored); + } + let mut buf = pop_free_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + self.default_max_capacity, + self.default_max_sections, + ); match buf.add(seq, payload, fin) { - Ok(()) => Ok(Some(&mut entry.insert((buf, timestamp)).0)), + Ok(()) => Ok(Stream(&mut entry.insert((buf, timestamp)).0)), Err(err) => { - let (data, sections) = buf.take_bufs(); - self.finished_data_bufs.push(data); - self.finished_section_bufs.push(sections); + recycle_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + buf, + ); Err(err) } } @@ -245,9 +386,11 @@ where /// Explicitly end a stream and recycle its buffers. pub fn end_stream(&mut self, id: &TcpStreamId) { if let Some((buf, _)) = self.active.remove(id) { - let (data, sections) = buf.take_bufs(); - self.finished_data_bufs.push(data); - self.finished_section_bufs.push(sections); + recycle_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + buf, + ); } } @@ -257,36 +400,50 @@ where self.active.len() } + /// Iterates over all active streams (e.g. to drain the remaining data of + /// all streams at the end of a capture). + pub fn iter_mut( + &mut self, + ) -> impl Iterator< + Item = ( + &TcpStreamId, + &mut TcpStreamReassemblyBuf, + &Timestamp, + ), + > { + self.active.iter_mut().map(|(id, v)| (id, &mut v.0, &v.1)) + } + /// Retains only the streams specified by the predicate and recycles the /// buffers of the evicted ones (e.g. to remove streams that have not /// received data for a while based on the `Timestamp`). - pub fn retain(&mut self, f: F) + pub fn retain(&mut self, mut f: F) where - F: Fn(&Timestamp) -> bool, + F: FnMut(&TcpStreamId, &Timestamp) -> bool, { - if self.active.iter().any(|(_, (_, t))| false == f(t)) { - self.active = self - .active - .drain() - .filter_map(|(k, v)| { - if f(&v.1) { - Some((k, v)) - } else { - let (data, sections) = v.0.take_bufs(); - self.finished_data_bufs.push(data); - self.finished_section_bufs.push(sections); - None - } - }) - .collect(); - } + let finished_data_bufs = &mut self.finished_data_bufs; + let finished_section_bufs = &mut self.finished_section_bufs; + self.active.retain(|id, value| { + if f(id, &value.1) { + true + } else { + recycle_buf( + finished_data_bufs, + finished_section_bufs, + core::mem::replace( + &mut value.0, + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 0), + ), + ); + false + } + }); } } impl Default for TcpStreamReassemblyPool where - Timestamp: Sized + core::fmt::Debug + Clone, - CustomChannelId: Sized + core::fmt::Debug + Clone + core::hash::Hash + Eq + PartialEq, + CustomChannelId: core::hash::Hash + Eq, { fn default() -> Self { Self::new() @@ -316,6 +473,26 @@ mod test { (start..start + len).map(|i| (i & 0xff) as u8).collect() } + /// Unwraps a [`TcpReassemblyEvent::Stream`]. + fn stream(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { + match ev { + TcpReassemblyEvent::Stream(buf) => buf, + other => panic!("expected TcpReassemblyEvent::Stream, got {other:?}"), + } + } + + /// Unwraps a [`TcpReassemblyEvent::Closed`]. + fn closed(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { + match ev { + TcpReassemblyEvent::Closed(buf) => buf, + other => panic!("expected TcpReassemblyEvent::Closed, got {other:?}"), + } + } + + fn assert_ignored(ev: TcpReassemblyEvent<'_>) { + assert!(matches!(ev, TcpReassemblyEvent::Ignored)); + } + #[test] fn new_default() { let pool = TcpStreamReassemblyPool::<(), ()>::new(); @@ -324,6 +501,10 @@ mod test { assert_eq!(pool.active_streams(), 0); let pool = TcpStreamReassemblyPool::<(), ()>::with_max_capacity(16); assert_eq!(pool.default_max_capacity, 16); + assert_eq!(pool.default_max_sections, DEFAULT_MAX_TCP_STREAM_SECTIONS); + let pool = TcpStreamReassemblyPool::<(), ()>::with_limits(16, 4); + assert_eq!(pool.default_max_capacity, 16); + assert_eq!(pool.default_max_sections, 4); } #[test] @@ -338,26 +519,27 @@ mod test { let id = ipv4_id(0); // SYN (isn = 999, so data starts at 1000) - let buf = pool - .process_tcp(id.clone(), 999, &[], true, false, false, 1) - .unwrap() - .unwrap(); + let buf = stream( + pool.process_tcp(id.clone(), 999, &[], true, false, false, 1) + .unwrap(), + ); assert_eq!(buf.base_sequence_number(), Some(1000)); + assert!(buf.syn_observed()); assert_eq!(pool.active_streams(), 1); // data - let buf = pool - .process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, 2) - .unwrap() - .unwrap(); + let buf = stream( + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, 2) + .unwrap(), + ); assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); buf.consume(8); // more data + FIN - let buf = pool - .process_tcp(id.clone(), 1008, &sequence(8, 4), false, true, false, 3) - .unwrap() - .unwrap(); + let buf = stream( + pool.process_tcp(id.clone(), 1008, &sequence(8, 4), false, true, false, 3) + .unwrap(), + ); assert_eq!(buf.contiguous(), &sequence(8, 4)[..]); assert!(buf.is_fin_reached()); } @@ -367,16 +549,101 @@ mod test { let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); let id = ipv4_id(0); // first ever segment is data (no SYN captured) - let buf = pool - .process_tcp(id.clone(), 5000, &sequence(0, 4), false, false, false, ()) - .unwrap() - .unwrap(); + let buf = stream( + pool.process_tcp(id.clone(), 5000, &sequence(0, 4), false, false, false, ()) + .unwrap(), + ); assert_eq!(buf.base_sequence_number(), Some(5000)); assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + // identifiable as "stream start not observed" + assert_eq!(false, buf.syn_observed()); + } + + #[test] + fn pure_ack_of_unknown_stream_is_ignored() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + // empty segment without SYN/FIN/RST of an unknown stream -> no stream + assert_ignored( + pool.process_tcp(ipv4_id(0), 5000, &[], false, false, false, ()) + .unwrap(), + ); + assert_eq!(pool.active_streams(), 0); + } + + #[test] + fn duplicated_syn_keeps_stream_state() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + + // SYN + data, partially consumed + pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) + .unwrap(); + let buf = stream( + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + .unwrap(), + ); + buf.consume(4); + + // a duplicate of the SYN arrives late -> state must be kept + let buf = stream( + pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) + .unwrap(), + ); + assert_eq!(buf.base_offset(), 4); + assert_eq!(buf.contiguous(), &sequence(4, 4)[..]); + + // the stream continues seamlessly + let buf = stream( + pool.process_tcp(id.clone(), 1008, &sequence(8, 4), false, false, false, ()) + .unwrap(), + ); + assert_eq!(buf.contiguous(), &sequence(4, 8)[..]); + } + + #[test] + fn late_syn_marks_stream_start_observed() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + + // stream anchored mid-stream (data before the SYN was seen) + let buf = stream( + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + .unwrap(), + ); + assert_eq!(false, buf.syn_observed()); + + // the SYN arrives re-ordered (isn = 999 -> data starts at 1000) + let buf = stream( + pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) + .unwrap(), + ); + assert!(buf.syn_observed()); + // buffered data was kept + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); + } + + #[test] + fn syn_with_fin_and_payload() { + // TCP Fast Open SYN with payload & FIN in one segment + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let buf = stream( + pool.process_tcp(ipv4_id(0), 999, &sequence(0, 4), true, true, false, ()) + .unwrap(), + ); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + assert!(buf.is_fin_reached()); + + // SYN+FIN without payload must record the FIN as well + let buf = stream( + pool.process_tcp(ipv4_id(1), 42, &[], true, true, false, ()) + .unwrap(), + ); + assert_eq!(buf.fin_offset(), Some(0)); + assert!(buf.is_fin_reached()); } #[test] - fn rst_recycles_stream() { + fn rst_closes_stream_and_recycles_on_next_call() { let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); let id = ipv4_id(0); pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) @@ -384,12 +651,22 @@ mod test { assert_eq!(pool.active_streams(), 1); assert_eq!(pool.finished_data_bufs.len(), 0); - // RST tears down the stream and recycles buffers - let r = pool - .process_tcp(id.clone(), 1008, &[], false, false, true, ()) - .unwrap(); - assert!(r.is_none()); + // RST closes the stream, the not yet consumed data stays drainable + let buf = closed( + pool.process_tcp(id.clone(), 1008, &[], false, false, true, ()) + .unwrap(), + ); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); assert_eq!(pool.active_streams(), 0); + // not yet recycled (still drainable) + assert_eq!(pool.finished_data_bufs.len(), 0); + + // a RST for an unknown stream is ignored & the closed buffer of the + // previous call is recycled + assert_ignored( + pool.process_tcp(ipv4_id(1), 1, &[], false, false, true, ()) + .unwrap(), + ); assert_eq!(pool.finished_data_bufs.len(), 1); assert_eq!(pool.finished_section_bufs.len(), 1); @@ -407,23 +684,58 @@ mod test { pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) .unwrap(); - // reconnect: new SYN with a fresh ISN re-bases the stream and drops the - // old buffered data - let buf = pool - .process_tcp(id.clone(), 42, &[], true, false, false, ()) - .unwrap() - .unwrap(); - assert_eq!(buf.base_sequence_number(), Some(43)); - assert!(buf.contiguous().is_empty()); + // reconnect: new SYN with a fresh ISN replaces the stream; the old + // stream data stays drainable via the "Closed" event + let buf = closed( + pool.process_tcp(id.clone(), 20000, &[], true, false, false, ()) + .unwrap(), + ); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); assert_eq!(pool.active_streams(), 1); - let buf = pool - .process_tcp(id.clone(), 43, &sequence(100, 4), false, false, false, ()) - .unwrap() - .unwrap(); + // the new stream is registered & re-based + let buf = pool.stream_mut(&id).unwrap(); + assert_eq!(buf.base_sequence_number(), Some(20001)); + assert!(buf.contiguous().is_empty()); + + let buf = stream( + pool.process_tcp( + id.clone(), + 20001, + &sequence(100, 4), + false, + false, + false, + (), + ) + .unwrap(), + ); assert_eq!(buf.contiguous(), &sequence(100, 4)[..]); } + #[test] + fn reconnect_via_syn_without_leftover() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + + // stream with fully consumed data + let buf = stream( + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + .unwrap(), + ); + buf.consume(8); + + // reconnect with a new ISN -> nothing left to drain, so the new + // stream is returned directly & the old buffer is recycled + let buf = stream( + pool.process_tcp(id.clone(), 20000, &[], true, false, false, ()) + .unwrap(), + ); + assert_eq!(buf.base_sequence_number(), Some(20001)); + assert_eq!(pool.active_streams(), 1); + assert_eq!(pool.finished_data_bufs.len(), 1); + } + #[test] fn error_on_fresh_stream_recycles_buf() { let mut pool = TcpStreamReassemblyPool::<(), u16>::with_max_capacity(8); @@ -480,6 +792,28 @@ mod test { assert_eq!(pool.finished_data_bufs.len(), 1); } + #[test] + fn iter_mut_drains_leftovers() { + let mut pool = TcpStreamReassemblyPool::::new(); + pool.process_tcp(ipv4_id(0), 1000, &sequence(0, 8), false, false, false, 1) + .unwrap(); + pool.process_tcp(ipv4_id(1), 2000, &sequence(8, 4), false, false, false, 2) + .unwrap(); + + // e.g. at the end of a capture: collect the data of all streams + let mut collected = Vec::new(); + for (id, buf, timestamp) in pool.iter_mut() { + let len = buf.contiguous().len(); + collected.push((id.channel_id, buf.contiguous().to_vec(), *timestamp)); + buf.consume(len); + } + collected.sort(); + assert_eq!( + collected, + std::vec![(0, sequence(0, 8), 1), (1, sequence(8, 4), 2)] + ); + } + #[test] fn retain_evicts_and_recycles() { let mut pool = TcpStreamReassemblyPool::::new(); @@ -490,39 +824,47 @@ mod test { assert_eq!(pool.active_streams(), 2); // no-op retain - pool.retain(|ts| *ts > 0); + pool.retain(|_, ts| *ts > 0); assert_eq!(pool.active_streams(), 2); - // evict timestamp 1 - pool.retain(|ts| *ts > 1); + // evict timestamp 1 (the stream id is passed to the predicate too) + pool.retain(|id, ts| { + assert_eq!(id.destination_port, 80); + *ts > 1 + }); assert_eq!(pool.active_streams(), 1); assert_eq!(pool.finished_data_bufs.len(), 1); assert_eq!(pool.finished_section_bufs.len(), 1); + assert!(pool.stream_mut(&ipv4_id(1)).is_some()); } #[test] fn non_tcp_and_process_sliced_packet() { let mut pool = TcpStreamReassemblyPool::<(), ()>::new(); - // empty sliced packet -> None + // empty sliced packet -> Ignored let empty = SlicedPacket { link: None, link_exts: ArrayVec::new_const(), net: None, transport: None, }; - assert!(pool - .process_sliced_packet(&empty, (), ()) - .unwrap() - .is_none()); + assert_ignored(pool.process_sliced_packet(&empty, (), ()).unwrap()); // build a real ethernet/ipv4/tcp packet and feed it let payload = sequence(0, 8); let pdata = build_ipv4_tcp_packet(1000, false, false, false, &payload); let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); - let buf = pool.process_sliced_packet(&slice, (), ()).unwrap().unwrap(); + let buf = stream(pool.process_sliced_packet(&slice, (), ()).unwrap()); assert_eq!(buf.contiguous(), &payload[..]); assert_eq!(pool.active_streams(), 1); + + // RST via a sliced packet closes the stream + let pdata = build_ipv4_tcp_packet(1008, false, false, true, &[]); + let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); + let buf = closed(pool.process_sliced_packet(&slice, (), ()).unwrap()); + assert_eq!(buf.contiguous(), &payload[..]); + assert_eq!(pool.active_streams(), 0); } fn build_ipv4_tcp_packet(seq: u32, syn: bool, fin: bool, rst: bool, payload: &[u8]) -> Vec { From efbfc96332b8ed34c9d7bd39225cf9bd3b63ca29 Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Mon, 3 Aug 2026 17:39:24 +0200 Subject: [PATCH 3/8] Additional iteration & bugfixes --- changelog.md | 5 +- etherparse/src/tcp_reassembly/mod.rs | 3 + .../tcp_reassembly/tcp_reassemble_error.rs | 20 +- .../src/tcp_reassembly/tcp_segment_outcome.rs | 47 ++ .../tcp_stream_reassembly_buf.rs | 539 +++++++++++++++--- .../tcp_stream_reassembly_pool.rs | 202 ++++--- 6 files changed, 636 insertions(+), 180 deletions(-) create mode 100644 etherparse/src/tcp_reassembly/tcp_segment_outcome.rs diff --git a/changelog.md b/changelog.md index 0c484e76..3dcb743c 100644 --- a/changelog.md +++ b/changelog.md @@ -4,9 +4,12 @@ * Added TCP stream reassembly support (in the `tcp_reassembly` module, contains allocations): * `TcpStreamReassemblyBuf` (requires the `alloc` feature), a re-usable buffer that reconstructs the payload byte stream of a single direction of a TCP connection. It is robust against retransmits, re-ordered & duplicated/overlapping segments, stale FINs, big offset jumps and 32 bit sequence number wrap-around (all bookkeeping is done in a monotonic 64 bit "absolute stream offset" space). Data is exposed in-place via `contiguous` and freed via `consume` ("collect then clean", amortized O(1) via deferred compaction). Streams whose start was never observed (capture started mid-connection) are reconstructed from the first seen segment and can be identified via `syn_observed`. + * `TcpStreamReassemblyBuf::add_segment` additionally interprets the `SYN` flag (establishing the stream start, recognizing duplicated, re-transmitted & late SYNs and reporting the SYN of a new connection re-using the same ports via `TcpSegmentOutcome::NewConnection`), while `add` just takes the payload of a segment. + * Bytes that are not part of the stream are dropped instead of being handed out: data before the read cursor, data behind a received `FIN` and data in front of a *known* stream start. Overlapping segments carrying differing content are resolved deterministically ("first writer wins") so a reassembly cannot be de-synchronized from the actual receiver by overlapping re-transmits. + * `TcpStreamReassemblyBuf::skip_gap` gives up on segments that were lost and never re-transmitted (e.g. dropped by the capture) by advancing the read cursor to the next received data section, which would otherwise stall the stream forever. * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many streams in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). It interprets the TCP control flags automatically (`SYN` establishes/replaces a stream — duplicated & late SYNs are recognized and keep the buffered state, `FIN` marks the stream end, `RST` closes the stream) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Stream` / `Closed`, where `Closed` still exposes never consumed leftover data of a stream ended by a RST or replaced by a new connection). Streams can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. * Per-stream memory is bounded via a configurable `max_capacity` (buffered bytes ahead of the read cursor) and `max_sections` (tracked non-contiguous ranges), returning `TcpReassembleError` when a limit would be exceeded (the buffer is left unchanged in that case). - * Supporting types `TcpStreamId`, `TcpStreamIpId`, `TcpSegmentRange`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY` & `DEFAULT_MAX_TCP_STREAM_SECTIONS` constants, plus a `tcp_reassembly` example. + * Supporting types `TcpStreamId`, `TcpStreamIpId`, `TcpSegmentRange`, `TcpSegmentOutcome`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY` & `DEFAULT_MAX_TCP_STREAM_SECTIONS` constants, plus a `tcp_reassembly` example. * Added zero-copy IGMP support (IGMPv1, IGMPv2 & IGMPv3): * `IgmpSlice`, an enum with one zero-copy variant per message type (`MembershipQuery`, `MembershipQueryWithSources`, `MembershipReportV1`, `MembershipReportV2`, `MembershipReportV3`, `LeaveGroup` & `Unknown`), plus common accessors (`header`, `payload`, `slice`, `checksum`, `is_checksum_valid`). diff --git a/etherparse/src/tcp_reassembly/mod.rs b/etherparse/src/tcp_reassembly/mod.rs index 95e33fe1..f2092bc4 100644 --- a/etherparse/src/tcp_reassembly/mod.rs +++ b/etherparse/src/tcp_reassembly/mod.rs @@ -1,6 +1,9 @@ mod tcp_reassemble_error; pub use tcp_reassemble_error::*; +mod tcp_segment_outcome; +pub use tcp_segment_outcome::*; + mod tcp_segment_range; pub use tcp_segment_range::*; diff --git a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs index 72cae2c4..c67eeac1 100644 --- a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs +++ b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs @@ -15,9 +15,15 @@ pub enum TcpReassembleError { /// the sequence number (e.g. from lost segments, re-orderings or /// maliciously crafted packets). SegmentBeyondMaxWindow { - /// Absolute stream offset (relative to the current read cursor) - /// at which the received segment would have ended. - end_offset: u64, + /// Number of bytes that would have to be buffered ahead of the read + /// cursor to store the segment. + /// + /// For a segment landing ahead of the cursor this is the offset at + /// which it would have ended. For a segment that would re-anchor the + /// buffer backwards (see + /// [`crate::tcp_reassembly::TcpStreamReassemblyBuf::syn_observed`]) + /// it is the size of the re-anchored buffer. + required_capacity: u64, /// Maximum number of bytes that can be buffered ahead of the read /// cursor. @@ -47,7 +53,7 @@ impl core::fmt::Display for TcpReassembleError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { use TcpReassembleError::*; match self { - SegmentBeyondMaxWindow { end_offset, max_capacity } => write!(f, "Received a TCP segment that ends {end_offset} bytes ahead of the read cursor which exceeds the maximum buffer capacity of {max_capacity} bytes."), + SegmentBeyondMaxWindow { required_capacity, max_capacity } => write!(f, "Received a TCP segment that would require buffering {required_capacity} bytes ahead of the read cursor which exceeds the maximum buffer capacity of {max_capacity} bytes."), AllocationFailure { len } => write!(f, "Failed to allocate {len} bytes of memory to reconstruct the TCP stream."), TooManySections { max_sections } => write!(f, "Received a TCP segment that would require tracking more than the maximum of {max_sections} separate data sections."), } @@ -98,8 +104,8 @@ mod tests { fn fmt() { let tests = [ ( - SegmentBeyondMaxWindow { end_offset: 5000, max_capacity: 4096 }, - "Received a TCP segment that ends 5000 bytes ahead of the read cursor which exceeds the maximum buffer capacity of 4096 bytes.", + SegmentBeyondMaxWindow { required_capacity: 5000, max_capacity: 4096 }, + "Received a TCP segment that would require buffering 5000 bytes ahead of the read cursor which exceeds the maximum buffer capacity of 4096 bytes.", ), ( AllocationFailure { len: 128 }, @@ -120,7 +126,7 @@ mod tests { use core::error::Error; assert!(AllocationFailure { len: 0 }.source().is_none()); assert!(SegmentBeyondMaxWindow { - end_offset: 0, + required_capacity: 0, max_capacity: 0 } .source() diff --git a/etherparse/src/tcp_reassembly/tcp_segment_outcome.rs b/etherparse/src/tcp_reassembly/tcp_segment_outcome.rs new file mode 100644 index 00000000..c8122815 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_segment_outcome.rs @@ -0,0 +1,47 @@ +/// Result of adding a TCP segment via +/// [`crate::tcp_reassembly::TcpStreamReassemblyBuf::add_segment`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TcpSegmentOutcome { + /// The segment was added to the currently reconstructed stream. + Continued, + + /// The segment is the `SYN` of a **new** connection re-using the same + /// addresses & ports (its initial sequence number does not match the + /// currently reconstructed stream). + /// + /// The buffer was left completely unmodified, so the data of the previous + /// connection can still be drained. Start reconstructing the new + /// connection via [`crate::tcp_reassembly::TcpStreamReassemblyBuf::reset`] + /// (or by switching to a different buffer). + NewConnection, +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + + #[test] + fn debug_clone_eq_hash_ord() { + let value = TcpSegmentOutcome::Continued; + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + assert_ne!(value, TcpSegmentOutcome::NewConnection); + assert_eq!(value.cmp(&value), core::cmp::Ordering::Equal); + assert_eq!(value.partial_cmp(&value), Some(core::cmp::Ordering::Equal)); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs index bf9c879b..0a276a99 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs @@ -23,7 +23,9 @@ const COMPACT_THRESHOLD: usize = 4096; /// /// # Typical usage /// -/// * [`TcpStreamReassemblyBuf::add`] feeds the payload of a received segment. +/// * [`TcpStreamReassemblyBuf::add_segment`] feeds a received segment +/// including the interpretation of its `SYN` flag (use +/// [`TcpStreamReassemblyBuf::add`] to feed just the payload). /// * [`TcpStreamReassemblyBuf::contiguous`] returns the in-order bytes that are /// available starting at the current read cursor. /// * [`TcpStreamReassemblyBuf::consume`] advances the read cursor and frees the @@ -35,8 +37,9 @@ const COMPACT_THRESHOLD: usize = 4096; /// capture starts in the middle of a connection) the buffer anchors itself on /// the first added segment. Such streams are still reconstructed, but their /// prefix is missing. [`TcpStreamReassemblyBuf::syn_observed`] allows -/// differentiating these streams from streams that were re-based to a known -/// stream start via [`TcpStreamReassemblyBuf::reset`]. +/// differentiating these streams from streams with a known start (established +/// via [`TcpStreamReassemblyBuf::add_segment`] or +/// [`TcpStreamReassemblyBuf::reset`]). #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct TcpStreamReassemblyBuf { /// Sequence number that is mapped to `base_offset`. @@ -150,13 +153,18 @@ impl TcpStreamReassemblyBuf { self.fin_offset } - /// True if the stream start is known (the buffer was re-based to a known - /// start via [`TcpStreamReassemblyBuf::reset`], e.g. because a SYN was - /// observed). + /// True if the stream start is known (established by a SYN via + /// [`TcpStreamReassemblyBuf::add_segment`] or by + /// [`TcpStreamReassemblyBuf::reset`]). /// /// False if the buffer anchored itself on the first added segment (e.g. /// the capture started in the middle of a connection). The stream is /// still reconstructed in that case, but an unknown prefix is missing. + /// + /// Note that data *in front* of a known stream start is not part of the + /// stream and gets discarded, while a buffer that anchored itself is + /// re-anchored backwards if earlier data arrives (see + /// [`TcpStreamReassemblyBuf::add`]). #[inline] pub fn syn_observed(&self) -> bool { self.syn_observed @@ -191,10 +199,130 @@ impl TcpStreamReassemblyBuf { self.syn_observed = true; } - /// Marks the stream start as known without re-basing (e.g. when a SYN - /// matching the already established base is observed late). - pub fn mark_syn_observed(&mut self) { + /// Re-anchors the buffer to a now known stream start (from a late SYN) + /// while keeping the already buffered data. + /// + /// `start_abs` is the absolute stream offset `base` maps to and must be + /// negative (the stream started before the current anchor). + fn anchor_stream_start( + &mut self, + base: u32, + start_abs: i128, + ) -> Result<(), TcpReassembleError> { + use TcpReassembleError::*; + debug_assert!(start_abs < 0); + + // re-anchoring shifts the buffered data, which is only possible while + // nothing was consumed yet. If data was already handed out the missing + // prefix cannot be re-introduced, so the SYN is ignored and the stream + // keeps being flagged as "start not observed". + if self.base_offset > 0 { + return Ok(()); + } + + let buffered_len = self.data.len(); + let Ok(shift) = usize::try_from(-start_abs) else { + return Err(SegmentBeyondMaxWindow { + required_capacity: u64::try_from(-start_abs) + .unwrap_or(u64::MAX) + .saturating_add(buffered_len as u64), + max_capacity: self.max_capacity, + }); + }; + let required = match shift.checked_add(buffered_len) { + Some(required) if required <= self.max_capacity => required, + _ => { + return Err(SegmentBeyondMaxWindow { + required_capacity: (shift as u64).saturating_add(buffered_len as u64), + max_capacity: self.max_capacity, + }); + } + }; + if self.data.capacity() < required + && self.data.try_reserve(required - buffered_len).is_err() + { + return Err(AllocationFailure { len: required }); + } + + // -- all checks done, commit -- + self.shift_forward(shift); + self.base_seq = Some(base); self.syn_observed = true; + Ok(()) + } + + /// Add a received TCP segment, interpreting the `SYN` flag. + /// + /// This is the counterpart of [`Self::add`] for callers that want the + /// connection setup handled for them: + /// + /// * A `SYN` establishes the start of the stream (the `SYN` flag consumes + /// one sequence number, so the payload starts at `seq + 1`). Duplicated + /// and re-transmitted `SYN`s as well as a `SYN` arriving after the first + /// data segments are recognized and keep the already buffered data. + /// * A `SYN` carrying a different initial sequence number belongs to a new + /// connection re-using the same addresses & ports. It is reported via + /// [`TcpSegmentOutcome::NewConnection`] and leaves the buffer untouched. + /// * `SYN`s carrying payload (TCP Fast Open) and/or a `FIN` are handled. + /// + /// See [`Self::add`] for the error conditions (the buffer is left + /// unmodified in case of an error). + pub fn add_segment( + &mut self, + seq: u32, + payload: &[u8], + syn: bool, + fin: bool, + ) -> Result { + if false == syn { + self.add(seq, payload, fin)?; + return Ok(TcpSegmentOutcome::Continued); + } + + // the SYN flag consumes one sequence number, the payload starts after it + let base = seq.wrapping_add(1); + + match self.base_seq { + None => { + self.base_seq = Some(base); + self.syn_observed = true; + } + Some(base_seq) => { + // Position of the stream start indicated by the SYN within the + // already reconstructed stream (offset 0 is where the buffer is + // anchored). + let start_abs = self.seq_to_abs_offset(base_seq, base); + + // If the stream start is already known only an exact match is a + // duplicated/re-transmitted SYN. Initial sequence numbers are + // random, so *any* other value (in either direction) belongs to + // a new connection. + // + // If the start is not known yet the buffer anchored itself on + // the first seen segment, so a SYN at or before that anchor is + // the (re-ordered) start of the very same stream. + let same_stream = if self.syn_observed { + start_abs == 0 + } else { + start_abs <= 0 + }; + if false == same_stream { + return Ok(TcpSegmentOutcome::NewConnection); + } + + if start_abs < 0 { + self.anchor_stream_start(base, start_abs)?; + } else { + self.syn_observed = true; + } + } + } + + // TCP Fast Open: a SYN may already carry payload (and a FIN) + if fin || false == payload.is_empty() { + self.add(base, payload, fin)?; + } + Ok(TcpSegmentOutcome::Continued) } /// Maps a sequence number into the absolute stream offset space (using @@ -232,11 +360,21 @@ impl TcpStreamReassemblyBuf { /// the stream one byte past the payload). /// /// Retransmits, re-ordered segments and duplicated / overlapping payloads - /// are handled silently. Errors are only returned for segments landing - /// more than `max_capacity` bytes ahead of the read cursor, segments that - /// would require tracking more than `max_sections` separate data sections - /// and allocation failures. In case of an error the buffer is left - /// unmodified. + /// are handled silently. Payload bytes that are not part of the stream are + /// dropped: + /// + /// * bytes before the read cursor (already consumed data), + /// * bytes behind a received FIN (the end of the stream), + /// * bytes before a *known* stream start (see [`Self::syn_observed`]). + /// + /// If the stream start is not known yet and the segment starts before the + /// buffer anchor, the buffer is re-anchored backwards so the earlier data + /// can be kept (only possible while nothing was consumed yet). + /// + /// Errors are only returned for segments landing more than `max_capacity` + /// bytes ahead of the read cursor, segments that would require tracking + /// more than `max_sections` separate data sections and allocation + /// failures. In case of an error the buffer is left unmodified. pub fn add(&mut self, seq: u32, payload: &[u8], fin: bool) -> Result<(), TcpReassembleError> { use TcpReassembleError::*; @@ -254,32 +392,42 @@ impl TcpStreamReassemblyBuf { // anchored too late (e.g. segments arriving highest-sequence-first at // the start of the capture). Plan a backwards re-anchoring of the // buffer (shift of the buffered data) so the earlier data can be kept. + // + // This is only done if the start of the stream is *not* known. Once a + // SYN established it (see `syn_observed`) data in front of it is not + // part of this stream (e.g. a retransmit of a previous connection + // re-using the same ports) and gets trimmed like already consumed + // data instead. + // // Note that `base_offset == 0` implies `head == 0` (nothing consumed). - let rebase_shift: usize = - if false == payload.is_empty() && self.base_offset == 0 && start_abs < 0 { - let Ok(shift) = usize::try_from(-start_abs) else { + let rebase_shift: usize = if false == payload.is_empty() + && false == self.syn_observed + && self.base_offset == 0 + && start_abs < 0 + { + let Ok(shift) = usize::try_from(-start_abs) else { + return Err(SegmentBeyondMaxWindow { + required_capacity: u64::try_from(-start_abs) + .unwrap_or(u64::MAX) + .saturating_add(buffered_len as u64), + max_capacity: self.max_capacity, + }); + }; + // the whole shifted buffer sits ahead of the (still at 0) + // read cursor + let rebased_len = shift.checked_add(buffered_len); + match rebased_len { + Some(rebased_len) if rebased_len <= self.max_capacity => shift, + _ => { return Err(SegmentBeyondMaxWindow { - end_offset: u64::try_from(-start_abs) - .unwrap_or(u64::MAX) - .saturating_add(buffered_len as u64), + required_capacity: (shift as u64).saturating_add(buffered_len as u64), max_capacity: self.max_capacity, }); - }; - // the whole shifted buffer sits ahead of the (still at 0) - // read cursor - let rebased_len = shift.checked_add(buffered_len); - match rebased_len { - Some(rebased_len) if rebased_len <= self.max_capacity => shift, - _ => { - return Err(SegmentBeyondMaxWindow { - end_offset: (shift as u64).saturating_add(buffered_len as u64), - max_capacity: self.max_capacity, - }); - } } - } else { - 0 - }; + } + } else { + 0 + }; // absolute payload position after the potential re-anchoring (the // re-anchoring moves the payload start to offset 0) @@ -299,8 +447,18 @@ impl TcpStreamReassemblyBuf { None }; + // end of the stream after this segment (in the potentially re-anchored + // coordinates). `planned_fin` is only set while `self.fin_offset` is + // `None`, so at most one of the two is `Some`. + let effective_fin: Option = match (planned_fin, self.fin_offset) { + (Some(fin_offset), _) => Some(fin_offset), + (None, Some(fin_offset)) => Some(fin_offset + rebase_shift as u64), + (None, None) => None, + }; + // trim the part that lies before the read cursor (already consumed / - // retransmitted data) & enforce the maximum buffer window + // retransmitted data) & behind the end of the stream (data behind the + // FIN is not part of the stream) and enforce the maximum buffer window let write: Option<(&[u8], i128)> = if payload.is_empty() { None } else if cursor - eff_start >= payload.len() as i128 { @@ -312,21 +470,39 @@ impl TcpStreamReassemblyBuf { } else { (payload, eff_start) }; - let end_offset = (s + p.len() as i128 - cursor) as u64; - if end_offset > self.max_capacity as u64 { - return Err(SegmentBeyondMaxWindow { - end_offset, - max_capacity: self.max_capacity, - }); + // `s >= cursor >= 0`, so the cast is lossless + let p = match effective_fin { + Some(fin_offset) => { + let max_len = fin_offset.saturating_sub(s as u64) as usize; + if p.len() > max_len { + &p[..max_len] + } else { + p + } + } + None => p, + }; + if p.is_empty() { + None + } else { + let required_capacity = (s + p.len() as i128 - cursor) as u64; + if required_capacity > self.max_capacity as u64 { + return Err(SegmentBeyondMaxWindow { + required_capacity, + max_capacity: self.max_capacity, + }); + } + Some((p, s)) } - Some((p, s)) }; // check the section limit (in pre-re-anchoring coordinates, as the // recorded sections are not shifted yet) if let Some((p, s)) = write { let (cur_start, cur_end) = if rebase_shift > 0 { - (start_abs, end_abs) + // in the re-anchoring case the payload starts at the (still + // negative) `start_abs` and `s` is the post-shift position + (start_abs, start_abs + p.len() as i128) } else { (s, s + p.len() as i128) }; @@ -350,15 +526,10 @@ impl TcpStreamReassemblyBuf { // head == 0 in the re-anchoring case core::cmp::max(rebase_shift + buffered_len, p.len()) } else { - let end_off = (s + p.len() as i128 - cursor) as usize; - match self.head.checked_add(end_off) { - Some(v) => v, - None => { - // free the consumed prefix to make the write indexable - self.compact(); - end_off - } - } + // saturates only for absurd `max_capacity` values, in which + // case the `try_reserve` below fails & reports the error + self.head + .saturating_add((s + p.len() as i128 - cursor) as usize) }; if self.data.len() < data_end && self.data.capacity() < data_end @@ -373,21 +544,7 @@ impl TcpStreamReassemblyBuf { self.base_seq = Some(base_seq); if rebase_shift > 0 { - // move existing data forward, zero the newly exposed front and - // re-anchor to the new (lower) base sequence number - let old_len = self.data.len(); - self.data.resize(old_len + rebase_shift, 0); - self.data.copy_within(0..old_len, rebase_shift); - for b in &mut self.data[..rebase_shift] { - *b = 0; - } - for sec in &mut self.sections { - sec.start += rebase_shift as u64; - sec.end += rebase_shift as u64; - } - if let Some(fin_offset) = &mut self.fin_offset { - *fin_offset += rebase_shift as u64; - } + self.shift_forward(rebase_shift); self.base_seq = Some(seq); } @@ -396,23 +553,77 @@ impl TcpStreamReassemblyBuf { } if let Some((p, s)) = write { - // grow the buffer if required (gaps are zero filled) & write + // grow the buffer if required (gaps are zero filled) let data_start = self.head + (s - cursor) as usize; let data_end = data_start + p.len(); if self.data.len() < data_end { self.data.resize(data_end, 0); } - self.data[data_start..data_end].copy_from_slice(p); - self.insert_section(TcpSegmentRange { - start: s as u64, - end: (s + p.len() as i128) as u64, - }); + // Only write the bytes that were not received before ("first + // writer wins"). Overlapping segments carrying differing content + // are a known way to de-synchronize a reassembly from the actual + // receiver, so they are resolved deterministically instead of + // depending on when the caller happened to call `consume`. + let start = s as u64; + let end = start + p.len() as u64; + let mut pos = start; + let lo = self.sections.partition_point(|sec| sec.end <= start); + for idx in lo..self.sections.len() { + let sec = self.sections[idx]; + if sec.start >= end { + break; + } + if sec.start > pos { + // gap in front of an already received section + let from = (pos - start) as usize; + let to = (sec.start - start) as usize; + self.data[data_start + from..data_start + to] + .copy_from_slice(&p[from..to]); + } + pos = core::cmp::max(pos, sec.end); + if pos >= end { + break; + } + } + if pos < end { + let from = (pos - start) as usize; + self.data[data_start + from..data_end].copy_from_slice(&p[from..]); + } + + self.insert_section(TcpSegmentRange { start, end }); } Ok(()) } + /// Moves the buffered data `shift` bytes forward (making room for earlier + /// data at the front) and shifts every recorded offset accordingly. + /// + /// Only valid while nothing was consumed yet (`base_offset == 0`, which + /// implies `head == 0`). The caller has to make sure the required capacity + /// was reserved beforehand. + fn shift_forward(&mut self, shift: usize) { + debug_assert_eq!(0, self.base_offset); + debug_assert_eq!(0, self.head); + + // move the existing data forward & zero the newly exposed front + let old_len = self.data.len(); + self.data.resize(old_len + shift, 0); + self.data.copy_within(0..old_len, shift); + for b in &mut self.data[..shift] { + *b = 0; + } + + for sec in &mut self.sections { + sec.start += shift as u64; + sec.end += shift as u64; + } + if let Some(fin_offset) = &mut self.fin_offset { + *fin_offset += shift as u64; + } + } + /// Insert a filled range into the sorted section list (merging it with /// overlapping or directly adjacent sections). fn insert_section(&mut self, mut range: TcpSegmentRange) { @@ -443,9 +654,18 @@ impl TcpStreamReassemblyBuf { /// Length of the in-order data available starting at the read cursor. fn contiguous_len(&self) -> usize { - match self.sections.first() { + let len = match self.sections.first() { Some(s) if s.start == self.base_offset => (s.end - self.base_offset) as usize, _ => 0, + }; + // never hand out data behind the end of the stream (a FIN can be + // received after data behind it was already buffered) + match self.fin_offset { + Some(fin_offset) => core::cmp::min( + len, + fin_offset.saturating_sub(self.base_offset) as usize, + ), + None => len, } } @@ -490,6 +710,49 @@ impl TcpStreamReassemblyBuf { } } + /// Skip a gap at the read cursor by advancing it to the start of the next + /// received data section & free the skipped bytes. + /// + /// Segments that are lost and never re-transmitted (e.g. dropped by the + /// capture) would otherwise stall the stream forever: the missing bytes + /// never arrive, so [`Self::contiguous`] stays empty while the following + /// data keeps accumulating until `max_capacity` is reached and every + /// further segment is rejected with + /// [`TcpReassembleError::SegmentBeyondMaxWindow`]. Skipping the gap gives + /// up on the missing bytes and lets the reconstruction continue. + /// + /// Returns the number of skipped (permanently lost) bytes. Returns `0` if + /// there is no gap at the read cursor, either because in-order data is + /// available (check [`Self::contiguous`] first) or because nothing is + /// buffered at all. + pub fn skip_gap(&mut self) -> u64 { + let Some(first) = self.sections.first() else { + return 0; + }; + if first.start <= self.base_offset { + return 0; + } + + let skipped = first.start - self.base_offset; + // the buffer covers all data up to the last section, so the skipped + // range is guaranteed to be present in `data` + self.head += skipped as usize; + self.base_offset = first.start; + // truncating cast is the intended "mod 2^32" mapping back into the + // 32 bit sequence number space + self.base_seq = self.base_seq.map(|s| s.wrapping_add(skipped as u32)); + + // free the skipped prefix once it dominates the buffer + if self.head == self.data.len() { + self.data.clear(); + self.head = 0; + } else if self.head >= COMPACT_THRESHOLD && self.head >= self.data.len() - self.head { + self.compact(); + } + + skipped + } + /// Returns `true` once all bytes up to the FIN have been received and are /// available as contiguous data. pub fn is_fin_reached(&self) -> bool { @@ -671,7 +934,7 @@ mod test { assert_eq!( err, TcpReassembleError::SegmentBeyondMaxWindow { - end_offset: 17, + required_capacity: 17, max_capacity: 16 } ); @@ -686,7 +949,7 @@ mod test { assert_eq!( err, TcpReassembleError::SegmentBeyondMaxWindow { - end_offset: 28, + required_capacity: 28, max_capacity: 16 } ); @@ -786,10 +1049,14 @@ mod test { buf.reset(2000); assert!(buf.syn_observed()); - // late marking without re-base + // late SYN matching the anchor marks the start as known without + // dropping the buffered data let mut buf = new_buf(); buf.add(1000, &sequence(0, 4), false).unwrap(); - buf.mark_syn_observed(); + assert_eq!( + buf.add_segment(999, &[], true, false).unwrap(), + TcpSegmentOutcome::Continued + ); assert!(buf.syn_observed()); assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); } @@ -893,7 +1160,7 @@ mod test { assert_eq!( err, TcpReassembleError::SegmentBeyondMaxWindow { - end_offset: 24, + required_capacity: 24, max_capacity: 16 } ); @@ -973,6 +1240,118 @@ mod test { assert_eq!(buf.contiguous(), &[]); } + #[test] + fn rebase_ignores_known_stream_start() { + // if the stream start is known (SYN observed) data before it is not + // part of the stream and must not re-anchor the buffer + let mut buf = new_buf(); + buf.reset(1000); + buf.add(1000, &sequence(0, 4), false).unwrap(); + + buf.add(996, &sequence(100, 4), false).unwrap(); + + assert_eq!(buf.base_sequence_number(), Some(1000)); + assert_eq!(buf.base_offset(), 0); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + assert_section_invariants(&buf); + } + + #[test] + fn data_past_fin_is_ignored() { + // the FIN marks the end of the stream, bytes behind it are not part + // of it and must not be handed out + let mut buf = new_buf(); + buf.reset(1000); + buf.add(1000, &sequence(0, 4), true).unwrap(); + assert_eq!(buf.fin_offset(), Some(4)); + + buf.add(1004, &sequence(100, 8), false).unwrap(); + + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + assert!(buf.is_fin_reached()); + assert_section_invariants(&buf); + } + + #[test] + fn overlap_keeps_first_received_bytes() { + // overlapping segments with differing content must resolve + // deterministically (first writer wins) instead of depending on when + // the caller happened to call `consume` + let mut buf = new_buf(); + buf.add(1000, b"AAAA", false).unwrap(); + + // full overlap with different content + buf.add(1000, b"BBBB", false).unwrap(); + assert_eq!(buf.contiguous(), b"AAAA"); + + // partial overlap: only the not yet received bytes are taken + buf.add(1002, b"CCCC", false).unwrap(); + assert_eq!(buf.contiguous(), b"AAAACC"); + assert_section_invariants(&buf); + } + + #[test] + fn overlap_spanning_multiple_sections() { + let mut buf = new_buf(); + // three separate sections with gaps in between + buf.add(1000, b"AA", false).unwrap(); + buf.add(1004, b"BB", false).unwrap(); + buf.add(1008, b"CC", false).unwrap(); + assert_eq!(buf.sections().len(), 3); + + // a big segment overlapping all of them with differing content: the + // already received bytes are kept, only the gaps get filled + buf.add(1000, b"xxxxxxxxxxxx", false).unwrap(); + assert_eq!(buf.contiguous(), b"AAxxBBxxCCxx"); + assert_eq!(buf.sections().len(), 1); + assert_section_invariants(&buf); + } + + #[test] + fn skip_gap_recovers_from_permanently_lost_segment() { + let mut buf = new_buf(); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), false).unwrap(); + buf.consume(8); + + // the segment at 1008..1016 is lost & never re-transmitted + buf.add(1016, &sequence(16, 8), false).unwrap(); + assert_eq!(buf.contiguous(), &[]); + + // skipping the hole lets the stream continue + assert_eq!(buf.skip_gap(), 8); + assert_eq!(buf.base_offset(), 16); + assert_eq!(buf.base_sequence_number(), Some(1016)); + assert_eq!(buf.contiguous(), &sequence(16, 8)[..]); + assert_section_invariants(&buf); + + // no gap at the read cursor -> nothing to skip + assert_eq!(buf.skip_gap(), 0); + buf.consume(8); + // nothing buffered at all -> nothing to skip + assert_eq!(buf.skip_gap(), 0); + assert_eq!(buf.base_offset(), 24); + } + + #[test] + fn skip_gap_frees_the_max_window() { + // a permanent gap eventually fills up the whole window + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + buf.add(1000, &sequence(0, 4), false).unwrap(); + buf.consume(4); + + // gap at 1004..1008, followed by data filling the window + buf.add(1008, &sequence(8, 12), false).unwrap(); + assert_eq!(buf.contiguous(), &[]); + assert!(buf.add(1020, &sequence(20, 4), false).is_err()); + + // skipping the gap moves the window forward again + assert_eq!(buf.skip_gap(), 4); + buf.add(1020, &sequence(20, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(8, 16)[..]); + assert_section_invariants(&buf); + } + proptest! { /// Feed a reference byte stream split into random, re-ordered, /// duplicated and re-transmitted segments (with an ISN chosen anywhere diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs index c41a64a5..2d7d4d16 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs @@ -256,32 +256,18 @@ where }); } - if syn { - // the SYN flag consumes one sequence number, so the payload - // starts at seq + 1 - let base = seq.wrapping_add(1); - match self.active.entry(id) { - Entry::Occupied(entry) => { - let value = entry.into_mut(); - value.1 = timestamp; - - // Position of the data start indicated by the SYN - // relative to the tracked stream start. `<= 0` means the - // SYN belongs to the tracked stream (duplicated or - // retransmitted SYN, or the late SYN of a stream that - // had to anchor itself mid-stream) and the buffered - // state must be kept. `> 0` means a new connection - // re-using the same addresses & ports. - let rel = value.0.seq_stream_offset(base).unwrap_or(0); - if rel <= 0 { - value.0.mark_syn_observed(); - // TCP Fast Open: a SYN may already carry payload - // (and a FIN that has to be recorded) - if fin || false == payload.is_empty() { - value.0.add(base, payload, fin)?; - } + match self.active.entry(id) { + Entry::Occupied(entry) => { + let value = entry.into_mut(); + + // note: the timestamp is only updated after the fallible + // steps, so an error leaves the stream fully unchanged + match value.0.add_segment(seq, payload, syn, fin)? { + TcpSegmentOutcome::Continued => { + value.1 = timestamp; Ok(Stream(&mut value.0)) - } else { + } + TcpSegmentOutcome::NewConnection => { // reconnect: replace the stream with a fresh one let mut fresh = pop_free_buf( &mut self.finished_data_bufs, @@ -289,85 +275,58 @@ where self.default_max_capacity, self.default_max_sections, ); - fresh.reset(base); - let old = core::mem::replace(&mut value.0, fresh); - let has_leftover = false == old.contiguous().is_empty(); - if has_leftover { - self.pending_closed = Some(old); - } else { + // the fresh buffer has no base yet, so this only + // fails if the SYN payload exceeds the limits. The + // replacement did not happen yet, so the previous + // stream stays untouched & accessible in that case. + if let Err(err) = fresh.add_segment(seq, payload, syn, fin) { recycle_buf( &mut self.finished_data_bufs, &mut self.finished_section_bufs, - old, + fresh, ); + return Err(err); } - if fin || false == payload.is_empty() { - // on error the old stream stays drainable via - // the recycling stash, the fresh stream is kept - value.0.add(base, payload, fin)?; - } - if has_leftover { - // "value" can no longer be returned, but the - // leftover data of the replaced stream can - Ok(Closed(self.pending_closed.as_mut().unwrap())) - } else { - Ok(Stream(&mut value.0)) - } - } - } - Entry::Vacant(entry) => { - let mut buf = pop_free_buf( - &mut self.finished_data_bufs, - &mut self.finished_section_bufs, - self.default_max_capacity, - self.default_max_sections, - ); - buf.reset(base); - if fin || false == payload.is_empty() { - if let Err(err) = buf.add(base, payload, fin) { + + value.1 = timestamp; + let old = core::mem::replace(&mut value.0, fresh); + if old.contiguous().is_empty() { recycle_buf( &mut self.finished_data_bufs, &mut self.finished_section_bufs, - buf, + old, ); - return Err(err); + Ok(Stream(&mut value.0)) + } else { + // "value" can no longer be returned, but the + // leftover data of the replaced stream can + Ok(Closed(self.pending_closed.insert(old))) } } - Ok(Stream(&mut entry.insert((buf, timestamp)).0)) } } - } else { - // regular data / FIN / ACK segment - match self.active.entry(id) { - Entry::Occupied(entry) => { - let value = entry.into_mut(); - value.1 = timestamp; - value.0.add(seq, payload, fin)?; - Ok(Stream(&mut value.0)) + Entry::Vacant(entry) => { + // segments without payload, SYN & FIN carry no data for the + // reassembly -> don't create a stream for them (e.g. pure + // ACKs, port scans, keep alives of unknown streams) + if payload.is_empty() && false == syn && false == fin { + return Ok(Ignored); } - Entry::Vacant(entry) => { - // segments without payload & FIN carry no data for the - // reassembly -> don't create a stream for them (e.g. - // pure ACKs, port scans, keep alives of unknown streams) - if payload.is_empty() && false == fin { - return Ok(Ignored); - } - let mut buf = pop_free_buf( - &mut self.finished_data_bufs, - &mut self.finished_section_bufs, - self.default_max_capacity, - self.default_max_sections, - ); - match buf.add(seq, payload, fin) { - Ok(()) => Ok(Stream(&mut entry.insert((buf, timestamp)).0)), - Err(err) => { - recycle_buf( - &mut self.finished_data_bufs, - &mut self.finished_section_bufs, - buf, - ); - Err(err) - } + let mut buf = pop_free_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + self.default_max_capacity, + self.default_max_sections, + ); + match buf.add_segment(seq, payload, syn, fin) { + Ok(_) => Ok(Stream(&mut entry.insert((buf, timestamp)).0)), + Err(err) => { + recycle_buf( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + buf, + ); + Err(err) } } } @@ -748,7 +707,7 @@ mod test { assert_eq!( err, TcpReassembleError::SegmentBeyondMaxWindow { - end_offset: 16, + required_capacity: 16, max_capacity: 8 } ); @@ -769,7 +728,7 @@ mod test { assert_eq!( err, TcpReassembleError::SegmentBeyondMaxWindow { - end_offset: 20, + required_capacity: 20, max_capacity: 8 } ); @@ -867,6 +826,65 @@ mod test { assert_eq!(pool.active_streams(), 0); } + #[test] + fn reconnect_with_lower_isn_is_detected() { + // initial sequence numbers are random, so a reconnect is just as + // likely to pick a *lower* isn as a higher one + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let id = ipv4_id(0); + + pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) + .unwrap(); + pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + .unwrap(); + + // reconnect: the old stream is closed & stays drainable + let buf = closed( + pool.process_tcp(id.clone(), 499, &[], true, false, false, ()) + .unwrap(), + ); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); + + // the new stream is re-based and carries none of the old data + let buf = pool.stream_mut(&id).unwrap(); + assert_eq!(buf.base_sequence_number(), Some(500)); + assert!(buf.contiguous().is_empty()); + + let buf = stream( + pool.process_tcp(id.clone(), 500, &sequence(100, 4), false, false, false, ()) + .unwrap(), + ); + assert_eq!(buf.contiguous(), &sequence(100, 4)[..]); + assert_eq!(buf.sections().len(), 1); + } + + #[test] + fn free_lists_are_bounded() { + // evicting many streams must not make the pool hold on to one buffer + // per evicted stream forever + let mut pool = TcpStreamReassemblyPool::::new(); + for port in 0..200u16 { + pool.process_tcp(ipv4_id(port), 1000, &sequence(0, 64), false, false, false, 1) + .unwrap(); + } + assert_eq!(pool.active_streams(), 200); + + pool.retain(|_, _| false); + assert_eq!(pool.active_streams(), 0); + + // limit picked in phase 5 (max_pooled_bufs), 64 is an upper bound + assert!( + pool.finished_data_bufs.len() <= 64, + "unbounded data buf free list: {}", + pool.finished_data_bufs.len() + ); + assert!( + pool.finished_section_bufs.len() <= 64, + "unbounded section buf free list: {}", + pool.finished_section_bufs.len() + ); + } + fn build_ipv4_tcp_packet(seq: u32, syn: bool, fin: bool, rst: bool, payload: &[u8]) -> Vec { let mut tcp = TcpHeader::new(1234, 80, seq, 4096); tcp.syn = syn; From bb71089a342ad596fb9bd926bdd7473877295f81 Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Tue, 4 Aug 2026 15:01:48 +0200 Subject: [PATCH 4/8] Only hand out acknowledged data, track both connection directions together and bound the pool memory --- changelog.md | 9 +- etherparse/examples/tcp_reassembly.rs | 154 +- etherparse/src/tcp_reassembly/mod.rs | 32 +- .../src/tcp_reassembly/tcp_ack_policy.rs | 69 + .../src/tcp_reassembly/tcp_connection.rs | 200 +++ .../src/tcp_reassembly/tcp_connection_id.rs | 206 +++ .../src/tcp_reassembly/tcp_direction.rs | 72 + etherparse/src/tcp_reassembly/tcp_endpoint.rs | 106 ++ .../tcp_reassembly/tcp_reassemble_error.rs | 22 + .../src/tcp_reassembly/tcp_segment_info.rs | 177 +++ .../src/tcp_reassembly/tcp_stream_id.rs | 76 - .../src/tcp_reassembly/tcp_stream_ip_id.rs | 53 - .../tcp_stream_reassembly_buf.rs | 370 ++++- .../tcp_stream_reassembly_pool.rs | 1360 ++++++++++++----- 14 files changed, 2280 insertions(+), 626 deletions(-) create mode 100644 etherparse/src/tcp_reassembly/tcp_ack_policy.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_connection.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_connection_id.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_direction.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_endpoint.rs create mode 100644 etherparse/src/tcp_reassembly/tcp_segment_info.rs delete mode 100644 etherparse/src/tcp_reassembly/tcp_stream_id.rs delete mode 100644 etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs diff --git a/changelog.md b/changelog.md index 3dcb743c..e31f0867 100644 --- a/changelog.md +++ b/changelog.md @@ -7,9 +7,12 @@ * `TcpStreamReassemblyBuf::add_segment` additionally interprets the `SYN` flag (establishing the stream start, recognizing duplicated, re-transmitted & late SYNs and reporting the SYN of a new connection re-using the same ports via `TcpSegmentOutcome::NewConnection`), while `add` just takes the payload of a segment. * Bytes that are not part of the stream are dropped instead of being handed out: data before the read cursor, data behind a received `FIN` and data in front of a *known* stream start. Overlapping segments carrying differing content are resolved deterministically ("first writer wins") so a reassembly cannot be de-synchronized from the actual receiver by overlapping re-transmits. * `TcpStreamReassemblyBuf::skip_gap` gives up on segments that were lost and never re-transmitted (e.g. dropped by the capture) by advancing the read cursor to the next received data section, which would otherwise stall the stream forever. - * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many streams in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). It interprets the TCP control flags automatically (`SYN` establishes/replaces a stream — duplicated & late SYNs are recognized and keep the buffered state, `FIN` marks the stream end, `RST` closes the stream) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Stream` / `Closed`, where `Closed` still exposes never consumed leftover data of a stream ended by a RST or replaced by a new connection). Streams can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. - * Per-stream memory is bounded via a configurable `max_capacity` (buffered bytes ahead of the read cursor) and `max_sections` (tracked non-contiguous ranges), returning `TcpReassembleError` when a limit would be exceeded (the buffer is left unchanged in that case). - * Supporting types `TcpStreamId`, `TcpStreamIpId`, `TcpSegmentRange`, `TcpSegmentOutcome`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY` & `DEFAULT_MAX_TCP_STREAM_SECTIONS` constants, plus a `tcp_reassembly` example. + * By default (`TcpAckPolicy::Required`) only data that the receiver acknowledged is handed out by `contiguous`, so segments that never reached the receiver (lost or rejected after the point of capture) do not end up in the reconstructed stream. The acknowledgment numbers travel in the opposite direction of the data and are fed via `TcpStreamReassemblyBuf::add_ack` (stale & nonsensical acknowledgments are ignored). As a capture usually ends before the last data is acknowledged, the trailing unacknowledged bytes stay reachable via `contiguous_unacked` & `consume_unacked`, `ack_observed` identifies streams that never saw an acknowledgment (e.g. one-way captures) and `TcpAckPolicy::Ignore` disables the behavior entirely. + * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many connections in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). Both directions of a connection are tracked together as a `TcpConnection`, which is what allows the acknowledgment numbers of one direction to release the data of the other one. It interprets the TCP control flags automatically (`SYN` establishes/replaces a connection — duplicated & late SYNs are recognized and keep the buffered state, `ACK` releases the data of the reverse direction, `FIN` marks the stream end, `RST` ends the whole connection) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Segment` / `Closed`, where `Segment` exposes both the stream the payload was added to and the stream the segment acknowledged, and `Closed` still exposes never consumed leftover data). Connections can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. + * A `RST` carrying a sequence number outside of the tracked window is ignored, so a blindly injected `RST` cannot tear down a connection. + * Connections are identified by a `TcpConnectionId` (VLAN ids, the two `TcpEndpoint`s in a canonical order and a custom channel id) together with a `TcpDirection`, so both directions of a connection map to the same identifier. `TcpSegmentInfo::from_sliced_packet` extracts everything the reassembly needs from a parsed packet and can be modified before being passed to `process_tcp` (e.g. clearing the VLAN ids and moving them into the channel id if the two directions of a connection are tagged differently). + * Per-stream memory is bounded via a configurable `max_capacity` (buffered bytes ahead of the read cursor) and `max_sections` (tracked non-contiguous ranges), returning `TcpReassembleError` when a limit would be exceeded (the buffer is left unchanged in that case). The buffers kept around for re-use are bounded in count & size via `TcpStreamReassemblyPool::with_pool_limits` (so a burst of connections or a single big stream does not make the pool hold on to the memory forever) and the number of parallel connections can be limited via `TcpStreamReassemblyPool::with_max_connections` (`TcpReassembleError::TooManyConnections`). + * Supporting types `TcpConnection`, `TcpConnectionId`, `TcpDirection`, `TcpEndpoint`, `TcpSegmentInfo`, `TcpSegmentRange`, `TcpSegmentOutcome`, `TcpAckPolicy`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY`, `DEFAULT_MAX_TCP_STREAM_SECTIONS`, `DEFAULT_MAX_TCP_POOLED_BUFS` & `DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY` constants, plus a `tcp_reassembly` example. * Added zero-copy IGMP support (IGMPv1, IGMPv2 & IGMPv3): * `IgmpSlice`, an enum with one zero-copy variant per message type (`MembershipQuery`, `MembershipQueryWithSources`, `MembershipReportV1`, `MembershipReportV2`, `MembershipReportV3`, `LeaveGroup` & `Unknown`), plus common accessors (`header`, `payload`, `slice`, `checksum`, `is_checksum_valid`). diff --git a/etherparse/examples/tcp_reassembly.rs b/etherparse/examples/tcp_reassembly.rs index dda63bcb..1700c0ea 100644 --- a/etherparse/examples/tcp_reassembly.rs +++ b/etherparse/examples/tcp_reassembly.rs @@ -1,37 +1,68 @@ use etherparse::{tcp_reassembly::*, *}; -/// Small helper that builds an ethernet + ipv4 + tcp packet with the given -/// sequence number, flags and payload. -fn build_packet(seq: u32, syn: bool, fin: bool, payload: &[u8]) -> Vec { +const CLIENT_IP: [u8; 4] = [1, 2, 3, 4]; +const SERVER_IP: [u8; 4] = [2, 3, 4, 5]; +const CLIENT_PORT: u16 = 1234; +const SERVER_PORT: u16 = 80; + +/// Builds an ethernet + ipv4 + tcp packet of one of the two directions. +fn build_packet( + from_client: bool, + seq: u32, + ack: Option, + syn: bool, + fin: bool, + payload: &[u8], +) -> Vec { + let (source, destination, source_port, destination_port) = if from_client { + (CLIENT_IP, SERVER_IP, CLIENT_PORT, SERVER_PORT) + } else { + (SERVER_IP, CLIENT_IP, SERVER_PORT, CLIENT_PORT) + }; + let mut builder = PacketBuilder::ethernet2([1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]) - .ipv4([1, 2, 3, 4], [2, 3, 4, 5], 20) - .tcp(1234, 80, seq, 4096); + .ipv4(source, destination, 20) + .tcp(source_port, destination_port, seq, 4096); if syn { builder = builder.syn(); } if fin { builder = builder.fin(); } + if let Some(ack) = ack { + builder = builder.ack(ack); + } + let mut serialized = Vec::::with_capacity(builder.size(payload.len())); builder.write(&mut serialized, payload).unwrap(); serialized } fn main() { - // pool that manages the different TCP streams & re-uses the memory buffers + // Pool that manages the different TCP connections & re-uses the memory + // buffers. By default only data that the receiver acknowledged is handed + // out, which requires both directions of the connection to be present. let mut pool = TcpStreamReassemblyPool::<(), ()>::new(); - // The segments below arrive out of order (the middle segment is delayed) - // and one segment is duplicated - the reassembly handles all of that. + // A small exchange between a client (isn 1000) and a server (isn 5000). + // The segments arrive out of order (the middle one is delayed) and one is + // duplicated - the reassembly handles all of that. let packets = [ - build_packet(1000, true, false, &[]), // SYN (isn = 1000, data starts at 1001) - build_packet(1001, false, false, b"Hello, "), // first chunk (seq 1001..1008) - build_packet(1020, false, true, b"stream!"), // last chunk (seq 1020..1027, arrives early, FIN) - build_packet(1008, false, false, b"reassembled "), // middle chunk (seq 1008..1020, delayed) - build_packet(1008, false, false, b"reassembled "), // duplicate of the middle chunk (replay) + // handshake + build_packet(true, 1000, None, true, false, &[]), + build_packet(false, 5000, Some(1001), true, false, &[]), + // client sends its request in three chunks, the middle one is delayed + build_packet(true, 1001, Some(5001), false, false, b"Hello, "), + build_packet(true, 1020, Some(5001), false, true, b"stream!"), + build_packet(true, 1008, Some(5001), false, false, b"reassembled "), + // duplicate of the middle chunk (replay) + build_packet(true, 1008, Some(5001), false, false, b"reassembled "), + // the server acknowledges the first 19 bytes ... + build_packet(false, 5001, Some(1020), false, false, &[]), + // ... and then the rest + build_packet(false, 5001, Some(1027), false, false, &[]), ]; - let mut fin_announced = false; for packet in &packets { let sliced_packet = match SlicedPacket::from_ethernet(packet) { Ok(v) => v, @@ -42,42 +73,49 @@ fn main() { }; match pool.process_sliced_packet(&sliced_packet, (), ()) { - Ok(TcpReassemblyEvent::Stream(stream)) => { - // collect the in-order data that is available so far - let available = stream.contiguous(); - if false == available.is_empty() { - println!( - "in-order data available{}: {:?}", - if stream.syn_observed() { - "" - } else { - " (stream start not observed, prefix missing)" - }, - core::str::from_utf8(available).unwrap_or("") - ); + Ok(TcpReassemblyEvent::Segment { + sender, receiver, .. + }) => { + // A segment adds its payload to the "sender" stream, while its + // acknowledgment number can release data of the "receiver" + // stream, so both are worth draining. + for stream in [sender, receiver] { + let available = stream.contiguous(); + if false == available.is_empty() { + println!( + "acknowledged data{}: {:?}", + if stream.syn_observed() { + "" + } else { + " (stream start not observed, prefix missing)" + }, + core::str::from_utf8(available).unwrap_or("") + ); - // ... process the data here ... + // ... process the data here ... - // "clean" the processed bytes so the buffer memory is freed - let len = available.len(); - stream.consume(len); - } - - if stream.is_fin_reached() && false == fin_announced { - println!("stream finished (FIN reached)"); - fin_announced = true; + // "clean" the processed bytes so the memory is freed + let len = available.len(); + stream.consume(len); + } } } - Ok(TcpReassemblyEvent::Closed(stream)) => { - // a RST ended the stream (or a new connection replaced it): - // the not yet consumed data can still be drained here - println!( - "stream closed, leftover data: {:?}", - core::str::from_utf8(stream.contiguous()).unwrap_or("") - ); + Ok(TcpReassemblyEvent::Closed(connection)) => { + // a RST ended the connection (or a new connection replaced + // it): the not yet consumed data can still be drained here + for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + let leftover = connection.stream(direction).contiguous_unacked(); + if false == leftover.is_empty() { + println!( + "connection closed, leftover data: {:?}", + core::str::from_utf8(leftover).unwrap_or("") + ); + } + } } Ok(TcpReassemblyEvent::Ignored) => { - // not a TCP packet (or an empty segment of an unknown stream) + // not a TCP packet (or a segment of an unknown connection that + // carries nothing to reconstruct) } Err(err) => { println!("Error reassembling TCP stream: {err}"); @@ -85,15 +123,29 @@ fn main() { } } - // at the end of the capture: drain whatever is left in the still - // tracked streams - for (_id, stream, _timestamp) in pool.iter_mut() { - let leftover = stream.contiguous(); - if false == leftover.is_empty() { + // At the end of a capture: drain whatever is left. Note that the last data + // of a capture is usually never acknowledged (the capture ends before the + // ACK arrives), so `contiguous_unacked` has to be used here. + for (id, connection, _timestamp) in pool.iter_mut() { + if false == connection.is_bidirectional() { + // only one direction was seen, so nothing could be acknowledged println!( - "leftover data at end of capture: {:?}", - core::str::from_utf8(leftover).unwrap_or("") + "warning: only one direction captured for {:?} <-> {:?}", + id.first(), + id.second() ); } + for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + let stream = connection.stream_mut(direction); + let leftover = stream.contiguous_unacked(); + if false == leftover.is_empty() { + println!( + "unacknowledged data at end of capture: {:?}", + core::str::from_utf8(leftover).unwrap_or("") + ); + let len = leftover.len(); + stream.consume_unacked(len); + } + } } } diff --git a/etherparse/src/tcp_reassembly/mod.rs b/etherparse/src/tcp_reassembly/mod.rs index f2092bc4..37887960 100644 --- a/etherparse/src/tcp_reassembly/mod.rs +++ b/etherparse/src/tcp_reassembly/mod.rs @@ -1,3 +1,6 @@ +mod tcp_ack_policy; +pub use tcp_ack_policy::*; + mod tcp_reassemble_error; pub use tcp_reassemble_error::*; @@ -7,11 +10,20 @@ pub use tcp_segment_outcome::*; mod tcp_segment_range; pub use tcp_segment_range::*; -mod tcp_stream_id; -pub use tcp_stream_id::*; +mod tcp_connection; +pub use tcp_connection::*; + +mod tcp_connection_id; +pub use tcp_connection_id::*; + +mod tcp_direction; +pub use tcp_direction::*; -mod tcp_stream_ip_id; -pub use tcp_stream_ip_id::*; +mod tcp_endpoint; +pub use tcp_endpoint::*; + +mod tcp_segment_info; +pub use tcp_segment_info::*; mod tcp_stream_reassembly_buf; pub use tcp_stream_reassembly_buf::*; @@ -29,3 +41,15 @@ pub const DEFAULT_MAX_TCP_STREAM_CAPACITY: usize = 1 << 20; /// are tracked per TCP stream (used by [`TcpStreamReassemblyBuf::new`] and /// [`TcpStreamReassemblyPool::new`]). pub const DEFAULT_MAX_TCP_STREAM_SECTIONS: usize = 1024; + +/// Default maximum number of buffers a [`TcpStreamReassemblyPool`] keeps +/// around for re-use after the connections they belonged to ended. +pub const DEFAULT_MAX_TCP_POOLED_BUFS: usize = 32; + +/// Default maximum capacity (in bytes) a data buffer may have to be kept for +/// re-use by a [`TcpStreamReassemblyPool`]. +/// +/// Buffers of streams that grew beyond this are shrunk before being pooled, +/// so a single burst does not make the pool hold on to the memory for the +/// rest of its lifetime. +pub const DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY: usize = 64 * 1024; diff --git a/etherparse/src/tcp_reassembly/tcp_ack_policy.rs b/etherparse/src/tcp_reassembly/tcp_ack_policy.rs new file mode 100644 index 00000000..37c8b37b --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_ack_policy.rs @@ -0,0 +1,69 @@ +/// Decides if data has to be acknowledged by the receiver before it is handed +/// out by [`crate::tcp_reassembly::TcpStreamReassemblyBuf::contiguous`]. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TcpAckPolicy { + /// Only hand out data that the receiver acknowledged (default). + /// + /// Segments that are lost or rejected after the point of capture (out of + /// window, bad checksum, ...) never reach the receiver, so without this + /// the reconstructed stream can contain bytes the receiver never + /// processed. + /// + /// Note that the acknowledgments travel in the **opposite** direction of + /// the data, so they have to be fed via + /// [`crate::tcp_reassembly::TcpStreamReassemblyBuf::add_ack`] from the + /// segments of the reverse direction. If a capture does not contain the + /// reverse direction no data is ever handed out by `contiguous` (check + /// [`crate::tcp_reassembly::TcpStreamReassemblyBuf::ack_observed`] to + /// detect this) and + /// [`crate::tcp_reassembly::TcpStreamReassemblyBuf::contiguous_unacked`] + /// has to be used instead. + /// + /// # This is not a security boundary + /// + /// Requiring acknowledgments closes the gap between what a capture sees + /// and what the receiver actually accepted and forces an attacker to + /// forge both directions instead of one. It does **not** protect against + /// someone who can inject packets into the capture, as forged + /// acknowledgments are indistinguishable from real ones. + #[default] + Required, + + /// Hand out all in-order data, regardless of acknowledgments. + /// + /// Use this for captures that only contain one direction of the + /// connections (one-way taps or mirrors) or if the reconstruction should + /// follow the sender instead of the receiver. + Ignore, +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + + #[test] + fn debug_clone_eq_hash_ord_default() { + let value = TcpAckPolicy::Required; + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + assert_eq!(value, TcpAckPolicy::default()); + assert_ne!(value, TcpAckPolicy::Ignore); + assert_eq!(value.cmp(&value), core::cmp::Ordering::Equal); + assert_eq!(value.partial_cmp(&value), Some(core::cmp::Ordering::Equal)); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_connection.rs b/etherparse/src/tcp_reassembly/tcp_connection.rs new file mode 100644 index 00000000..9fb03466 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_connection.rs @@ -0,0 +1,200 @@ +use crate::tcp_reassembly::*; + +/// The reconstructed payload byte streams of both directions of a TCP +/// connection. +/// +/// The direction of a stream is relative to the endpoint order of the +/// [`TcpConnectionId`] it is stored under (see [`TcpDirection`]). +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct TcpConnection { + /// Stream carrying the data from [`TcpConnectionId::first`] to + /// [`TcpConnectionId::second`]. + first_to_second: TcpStreamReassemblyBuf, + + /// Stream carrying the data from [`TcpConnectionId::second`] to + /// [`TcpConnectionId::first`]. + second_to_first: TcpStreamReassemblyBuf, +} + +impl TcpConnection { + /// Creates a connection from the two (already configured) direction + /// buffers. + #[inline] + pub fn new( + first_to_second: TcpStreamReassemblyBuf, + second_to_first: TcpStreamReassemblyBuf, + ) -> TcpConnection { + TcpConnection { + first_to_second, + second_to_first, + } + } + + /// Stream of the given direction. + #[inline] + pub fn stream(&self, direction: TcpDirection) -> &TcpStreamReassemblyBuf { + match direction { + TcpDirection::FirstToSecond => &self.first_to_second, + TcpDirection::SecondToFirst => &self.second_to_first, + } + } + + /// Mutable access to the stream of the given direction. + #[inline] + pub fn stream_mut(&mut self, direction: TcpDirection) -> &mut TcpStreamReassemblyBuf { + match direction { + TcpDirection::FirstToSecond => &mut self.first_to_second, + TcpDirection::SecondToFirst => &mut self.second_to_first, + } + } + + /// Mutable access to both streams at once as + /// `(direction, direction.reverse())`. + /// + /// Useful as a segment adds its payload to one direction while its + /// acknowledgment number releases data of the other one. + #[inline] + pub fn streams_mut( + &mut self, + direction: TcpDirection, + ) -> (&mut TcpStreamReassemblyBuf, &mut TcpStreamReassemblyBuf) { + match direction { + TcpDirection::FirstToSecond => (&mut self.first_to_second, &mut self.second_to_first), + TcpDirection::SecondToFirst => (&mut self.second_to_first, &mut self.first_to_second), + } + } + + /// True if at least one segment was observed in the given direction. + #[inline] + pub fn is_direction_observed(&self, direction: TcpDirection) -> bool { + self.stream(direction).base_sequence_number().is_some() + } + + /// True if segments were observed in **both** directions of the + /// connection. + /// + /// Under [`TcpAckPolicy::Required`] no data is handed out for a + /// connection that only ever sees one direction (there are no + /// acknowledgments). This differentiates a genuinely one-way capture from + /// the case where the two directions were not matched up, e.g. because + /// they carry different VLAN ids (see [`TcpConnectionId`]). + #[inline] + pub fn is_bidirectional(&self) -> bool { + self.is_direction_observed(TcpDirection::FirstToSecond) + && self.is_direction_observed(TcpDirection::SecondToFirst) + } + + /// True if either direction still holds in-order data that was not + /// consumed yet (ignoring the [`TcpAckPolicy`]). + #[inline] + pub fn has_leftover_data(&self) -> bool { + false == self.first_to_second.contiguous_unacked().is_empty() + || false == self.second_to_first.contiguous_unacked().is_empty() + } + + /// Consume the connection and return the two direction buffers for + /// re-use. + #[inline] + pub fn take_bufs(self) -> (TcpStreamReassemblyBuf, TcpStreamReassemblyBuf) { + (self.first_to_second, self.second_to_first) + } +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::{format, vec::Vec}; + + fn new_conn() -> TcpConnection { + TcpConnection::new( + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 1 << 20), + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 1 << 20), + ) + } + + #[test] + fn debug_clone_eq_hash() { + let value = new_conn(); + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } + + #[test] + fn stream_access_by_direction() { + let mut conn = new_conn(); + conn.stream_mut(TcpDirection::FirstToSecond) + .add(1000, &[1, 2, 3], false) + .unwrap(); + + assert_eq!( + conn.stream(TcpDirection::FirstToSecond) + .base_sequence_number(), + Some(1000) + ); + assert_eq!( + conn.stream(TcpDirection::SecondToFirst) + .base_sequence_number(), + None + ); + + // streams_mut returns (sender, receiver) for the given direction + let (sender, receiver) = conn.streams_mut(TcpDirection::SecondToFirst); + assert_eq!(sender.base_sequence_number(), None); + assert_eq!(receiver.base_sequence_number(), Some(1000)); + } + + #[test] + fn direction_observation() { + let mut conn = new_conn(); + assert_eq!(false, conn.is_bidirectional()); + assert_eq!( + false, + conn.is_direction_observed(TcpDirection::FirstToSecond) + ); + + conn.stream_mut(TcpDirection::FirstToSecond) + .add(1000, &[1, 2, 3], false) + .unwrap(); + assert!(conn.is_direction_observed(TcpDirection::FirstToSecond)); + assert_eq!(false, conn.is_bidirectional()); + + // even an empty segment (e.g. a pure ACK) anchors the direction + conn.stream_mut(TcpDirection::SecondToFirst) + .add(500, &[], false) + .unwrap(); + assert!(conn.is_bidirectional()); + } + + #[test] + fn leftover_data_and_take_bufs() { + let mut conn = new_conn(); + assert_eq!(false, conn.has_leftover_data()); + + conn.stream_mut(TcpDirection::SecondToFirst) + .add(1000, &[1, 2, 3], false) + .unwrap(); + // not acknowledged, but still "leftover" (unacked data counts) + assert!(conn.has_leftover_data()); + + conn.stream_mut(TcpDirection::SecondToFirst).consume_unacked(3); + assert_eq!(false, conn.has_leftover_data()); + + let (a, b) = conn.take_bufs(); + assert_eq!(a.base_sequence_number(), None); + assert_eq!(b.base_sequence_number(), Some(1003)); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_connection_id.rs b/etherparse/src/tcp_reassembly/tcp_connection_id.rs new file mode 100644 index 00000000..927837e2 --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_connection_id.rs @@ -0,0 +1,206 @@ +use crate::{tcp_reassembly::*, *}; +use arrayvec::ArrayVec; + +/// Values identifying a TCP connection (both directions). +/// +/// The two endpoints are stored in a canonical order so that both directions +/// of a connection map to the same identifier (see +/// [`TcpConnectionId::new`], which also reports the +/// [`TcpDirection`] a segment was travelling in). +/// +/// # VLAN ids & the custom channel id +/// +/// By default the VLAN ids of the packets are part of the identity. Note that +/// this requires both directions of a connection to carry the *same* VLAN +/// ids, which is not the case for every capture setup (e.g. asymmetric +/// routing or a capture point that sees both sides of a router). If the two +/// directions are tagged differently they end up as two separate connections, +/// each containing only one direction, which under +/// [`TcpAckPolicy::Required`] means no data is handed out at all (see +/// [`TcpConnection::is_bidirectional`]). +/// +/// To handle this, build the id from a [`TcpSegmentInfo`] with the VLAN ids +/// cleared and move whatever should differentiate the connections into the +/// `channel_id` instead: +/// +/// ```no_run +/// # use etherparse::{tcp_reassembly::*, *}; +/// # let slice: SlicedPacket = unimplemented!(); +/// # let mut pool = TcpStreamReassemblyPool::<(), ()>::new(); +/// if let Some(mut info) = TcpSegmentInfo::from_sliced_packet(&slice, ()) { +/// info.vlan_ids.clear(); +/// pool.process_tcp(info, ()).unwrap(); +/// } +/// ``` +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct TcpConnectionId { + /// VLAN ids of the original packets. + vlan_ids: ArrayVec, + + /// Endpoint that sorts first (kept in a canonical order so that both + /// directions produce the same id). + first: TcpEndpoint, + + /// Endpoint that sorts second. + second: TcpEndpoint, + + /// Custom user defined channel identifier (can be used to differentiate + /// packet sources if the normal ethernet packet identifiers are not + /// enough). + channel_id: CustomChannelId, +} + +impl TcpConnectionId { + /// Creates an identifier from the source & destination of a segment and + /// returns the [`TcpDirection`] `source -> destination` corresponds to. + /// + /// The endpoints are re-ordered into a canonical order, so both + /// directions of a connection result in the same identifier. + pub fn new( + source: TcpEndpoint, + destination: TcpEndpoint, + vlan_ids: ArrayVec, + channel_id: CustomChannelId, + ) -> (TcpConnectionId, TcpDirection) { + if source <= destination { + ( + TcpConnectionId { + vlan_ids, + first: source, + second: destination, + channel_id, + }, + TcpDirection::FirstToSecond, + ) + } else { + ( + TcpConnectionId { + vlan_ids, + first: destination, + second: source, + channel_id, + }, + TcpDirection::SecondToFirst, + ) + } + } + + /// VLAN ids of the original packets. + #[inline] + pub fn vlan_ids(&self) -> &ArrayVec { + &self.vlan_ids + } + + /// Endpoint that sorts first. + #[inline] + pub fn first(&self) -> &TcpEndpoint { + &self.first + } + + /// Endpoint that sorts second. + #[inline] + pub fn second(&self) -> &TcpEndpoint { + &self.second + } + + /// Custom user defined channel identifier. + #[inline] + pub fn channel_id(&self) -> &CustomChannelId { + &self.channel_id + } + + /// Source endpoint of segments travelling in the given direction. + #[inline] + pub fn source(&self, direction: TcpDirection) -> &TcpEndpoint { + match direction { + TcpDirection::FirstToSecond => &self.first, + TcpDirection::SecondToFirst => &self.second, + } + } + + /// Destination endpoint of segments travelling in the given direction. + #[inline] + pub fn destination(&self, direction: TcpDirection) -> &TcpEndpoint { + match direction { + TcpDirection::FirstToSecond => &self.second, + TcpDirection::SecondToFirst => &self.first, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + + fn endpoints() -> (TcpEndpoint, TcpEndpoint) { + ( + TcpEndpoint::from_ipv4([1, 2, 3, 4], 1234), + TcpEndpoint::from_ipv4([5, 6, 7, 8], 80), + ) + } + + #[test] + fn debug_clone_eq_hash() { + let (a, b) = endpoints(); + let (value, _) = TcpConnectionId::new(a, b, Default::default(), 7u16); + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + assert_ne!(value, TcpConnectionId::new(a, b, Default::default(), 8u16).0); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } + + #[test] + fn both_directions_share_the_id() { + let (a, b) = endpoints(); + let (id_ab, dir_ab) = TcpConnectionId::new(a, b, Default::default(), ()); + let (id_ba, dir_ba) = TcpConnectionId::new(b, a, Default::default(), ()); + + // same connection, opposite directions + assert_eq!(id_ab, id_ba); + assert_eq!(dir_ab.reverse(), dir_ba); + + // the canonical order puts the smaller endpoint first + assert_eq!(*id_ab.first(), a); + assert_eq!(*id_ab.second(), b); + assert_eq!(dir_ab, TcpDirection::FirstToSecond); + assert_eq!(dir_ba, TcpDirection::SecondToFirst); + } + + #[test] + fn source_and_destination() { + let (a, b) = endpoints(); + let (id, dir) = TcpConnectionId::new(a, b, Default::default(), ()); + assert_eq!(*id.source(dir), a); + assert_eq!(*id.destination(dir), b); + assert_eq!(*id.source(dir.reverse()), b); + assert_eq!(*id.destination(dir.reverse()), a); + } + + #[test] + fn accessors() { + let (a, b) = endpoints(); + let mut vlan_ids = ArrayVec::::new_const(); + vlan_ids.push(VlanId::try_new(12).unwrap()); + let (id, _) = TcpConnectionId::new(a, b, vlan_ids.clone(), 42u16); + assert_eq!(*id.vlan_ids(), vlan_ids); + assert_eq!(*id.channel_id(), 42); + + // differing vlan ids result in different connections + let (other, _) = TcpConnectionId::new(a, b, Default::default(), 42u16); + assert_ne!(id, other); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_direction.rs b/etherparse/src/tcp_reassembly/tcp_direction.rs new file mode 100644 index 00000000..4232483a --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_direction.rs @@ -0,0 +1,72 @@ +/// Direction a TCP segment is travelling in, relative to the endpoint order +/// of a [`crate::tcp_reassembly::TcpConnectionId`]. +/// +/// A TCP connection consists of two independent payload byte streams, one per +/// direction. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TcpDirection { + /// From [`crate::tcp_reassembly::TcpConnectionId::first`] to + /// [`crate::tcp_reassembly::TcpConnectionId::second`]. + FirstToSecond, + + /// From [`crate::tcp_reassembly::TcpConnectionId::second`] to + /// [`crate::tcp_reassembly::TcpConnectionId::first`]. + SecondToFirst, +} + +impl TcpDirection { + /// Returns the opposite direction. + #[inline] + pub const fn reverse(&self) -> TcpDirection { + match self { + TcpDirection::FirstToSecond => TcpDirection::SecondToFirst, + TcpDirection::SecondToFirst => TcpDirection::FirstToSecond, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + + #[test] + fn debug_clone_eq_hash_ord() { + let value = TcpDirection::FirstToSecond; + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + assert_ne!(value, TcpDirection::SecondToFirst); + assert_eq!(value.cmp(&value), core::cmp::Ordering::Equal); + assert_eq!(value.partial_cmp(&value), Some(core::cmp::Ordering::Equal)); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } + + #[test] + fn reverse() { + assert_eq!( + TcpDirection::FirstToSecond.reverse(), + TcpDirection::SecondToFirst + ); + assert_eq!( + TcpDirection::SecondToFirst.reverse(), + TcpDirection::FirstToSecond + ); + assert_eq!( + TcpDirection::FirstToSecond.reverse().reverse(), + TcpDirection::FirstToSecond + ); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_endpoint.rs b/etherparse/src/tcp_reassembly/tcp_endpoint.rs new file mode 100644 index 00000000..a9fcd9fc --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_endpoint.rs @@ -0,0 +1,106 @@ +/// One end of a TCP connection (IP address & port). +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct TcpEndpoint { + /// IP address of the endpoint. + pub ip: core::net::IpAddr, + + /// TCP port of the endpoint. + pub port: u16, +} + +impl TcpEndpoint { + /// Creates a new endpoint. + #[inline] + pub const fn new(ip: core::net::IpAddr, port: u16) -> TcpEndpoint { + TcpEndpoint { ip, port } + } + + /// Creates an endpoint from IPv4 address bytes & a port. + #[inline] + pub const fn from_ipv4(ip: [u8; 4], port: u16) -> TcpEndpoint { + TcpEndpoint { + ip: core::net::IpAddr::V4(core::net::Ipv4Addr::new(ip[0], ip[1], ip[2], ip[3])), + port, + } + } + + /// Creates an endpoint from IPv6 address bytes & a port. + #[inline] + pub const fn from_ipv6(ip: [u8; 16], port: u16) -> TcpEndpoint { + TcpEndpoint { + ip: core::net::IpAddr::V6(core::net::Ipv6Addr::new( + u16::from_be_bytes([ip[0], ip[1]]), + u16::from_be_bytes([ip[2], ip[3]]), + u16::from_be_bytes([ip[4], ip[5]]), + u16::from_be_bytes([ip[6], ip[7]]), + u16::from_be_bytes([ip[8], ip[9]]), + u16::from_be_bytes([ip[10], ip[11]]), + u16::from_be_bytes([ip[12], ip[13]]), + u16::from_be_bytes([ip[14], ip[15]]), + )), + port, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::format; + use core::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + #[test] + fn debug_clone_eq_hash_ord() { + let value = TcpEndpoint::from_ipv4([1, 2, 3, 4], 80); + let _ = format!("{:?}", value); + assert_eq!(value, value.clone()); + assert_ne!(value, TcpEndpoint::from_ipv4([1, 2, 3, 4], 81)); + assert_eq!(value.cmp(&value), core::cmp::Ordering::Equal); + assert_eq!(value.partial_cmp(&value), Some(core::cmp::Ordering::Equal)); + + use core::hash::{Hash, Hasher}; + use std::collections::hash_map::DefaultHasher; + let h1 = { + let mut h = DefaultHasher::new(); + value.hash(&mut h); + h.finish() + }; + let h2 = { + let mut h = DefaultHasher::new(); + value.clone().hash(&mut h); + h.finish() + }; + assert_eq!(h1, h2); + } + + #[test] + fn constructors() { + assert_eq!( + TcpEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 80), + TcpEndpoint::from_ipv4([1, 2, 3, 4], 80) + ); + assert_eq!( + TcpEndpoint::from_ipv4([1, 2, 3, 4], 80).ip, + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)) + ); + + let v6 = [ + 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]; + assert_eq!( + TcpEndpoint::from_ipv6(v6, 443).ip, + IpAddr::V6(Ipv6Addr::from(v6)) + ); + assert_eq!(TcpEndpoint::from_ipv6(v6, 443).port, 443); + } + + #[test] + fn ordering_is_by_ip_then_port() { + let a = TcpEndpoint::from_ipv4([1, 2, 3, 4], 80); + let b = TcpEndpoint::from_ipv4([1, 2, 3, 5], 1); + let c = TcpEndpoint::from_ipv4([1, 2, 3, 4], 81); + assert!(a < b); + assert!(a < c); + assert!(c < b); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs index c67eeac1..7afabac4 100644 --- a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs +++ b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs @@ -47,6 +47,18 @@ pub enum TcpReassembleError { /// can be tracked at the same time. max_sections: usize, }, + + /// Error if a segment would require tracking more connections than the + /// [`crate::tcp_reassembly::TcpStreamReassemblyPool`] allows. + /// + /// Evict connections that are no longer of interest (e.g. via + /// [`crate::tcp_reassembly::TcpStreamReassemblyPool::retain`]) to make + /// room for new ones. + TooManyConnections { + /// Maximum number of connections that can be tracked at the same + /// time. + max_connections: usize, + }, } impl core::fmt::Display for TcpReassembleError { @@ -56,6 +68,7 @@ impl core::fmt::Display for TcpReassembleError { SegmentBeyondMaxWindow { required_capacity, max_capacity } => write!(f, "Received a TCP segment that would require buffering {required_capacity} bytes ahead of the read cursor which exceeds the maximum buffer capacity of {max_capacity} bytes."), AllocationFailure { len } => write!(f, "Failed to allocate {len} bytes of memory to reconstruct the TCP stream."), TooManySections { max_sections } => write!(f, "Received a TCP segment that would require tracking more than the maximum of {max_sections} separate data sections."), + TooManyConnections { max_connections } => write!(f, "Received a TCP segment of a new connection which would require tracking more than the maximum of {max_connections} connections."), } } } @@ -115,6 +128,10 @@ mod tests { TooManySections { max_sections: 1024 }, "Received a TCP segment that would require tracking more than the maximum of 1024 separate data sections.", ), + ( + TooManyConnections { max_connections: 512 }, + "Received a TCP segment of a new connection which would require tracking more than the maximum of 512 connections.", + ), ]; for test in tests { assert_eq!(format!("{}", test.0), test.1); @@ -132,5 +149,10 @@ mod tests { .source() .is_none()); assert!(TooManySections { max_sections: 0 }.source().is_none()); + assert!(TooManyConnections { + max_connections: 0 + } + .source() + .is_none()); } } diff --git a/etherparse/src/tcp_reassembly/tcp_segment_info.rs b/etherparse/src/tcp_reassembly/tcp_segment_info.rs new file mode 100644 index 00000000..41866b9d --- /dev/null +++ b/etherparse/src/tcp_reassembly/tcp_segment_info.rs @@ -0,0 +1,177 @@ +use crate::{tcp_reassembly::*, *}; +use arrayvec::ArrayVec; + +/// The values of a received TCP segment that are relevant for the reassembly. +/// +/// [`TcpSegmentInfo::from_sliced_packet`] extracts them from a parsed packet. +/// Modifying the result before passing it to +/// [`TcpStreamReassemblyPool::process_tcp`] allows customizing what +/// differentiates connections (e.g. clearing [`TcpSegmentInfo::vlan_ids`] and +/// moving them into [`TcpSegmentInfo::channel_id`], see +/// [`TcpConnectionId`]). +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct TcpSegmentInfo<'a, CustomChannelId = ()> { + /// Source of the segment (IP address & TCP port). + pub source: TcpEndpoint, + + /// Destination of the segment (IP address & TCP port). + pub destination: TcpEndpoint, + + /// VLAN ids of the packet (part of the connection identity, clear to + /// ignore them). + pub vlan_ids: ArrayVec, + + /// Custom user defined channel identifier (part of the connection + /// identity). + pub channel_id: CustomChannelId, + + /// Sequence number of the segment. + pub sequence_number: u32, + + /// Acknowledgment number of the segment or `None` if the `ACK` flag was + /// not set. + /// + /// Note that this acknowledges data of the **reverse** direction. + pub acknowledgment_number: Option, + + /// Payload of the segment. + pub payload: &'a [u8], + + /// `SYN` flag of the segment. + pub syn: bool, + + /// `FIN` flag of the segment. + pub fin: bool, + + /// `RST` flag of the segment. + pub rst: bool, +} + +impl<'a, CustomChannelId> TcpSegmentInfo<'a, CustomChannelId> { + /// Extracts the values relevant for the reassembly from a sliced packet. + /// + /// Returns `None` if the packet does not contain a TCP segment with an IP + /// header (e.g. a non TCP packet or an IP fragment, as the TCP layer of a + /// fragmented packet is not decoded). + pub fn from_sliced_packet( + slice: &'a SlicedPacket, + channel_id: CustomChannelId, + ) -> Option> { + use core::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { + return None; + }; + + let (source_ip, destination_ip) = match &slice.net { + Some(NetSlice::Ipv4(v4)) => ( + IpAddr::V4(Ipv4Addr::from(v4.header().source())), + IpAddr::V4(Ipv4Addr::from(v4.header().destination())), + ), + Some(NetSlice::Ipv6(v6)) => ( + IpAddr::V6(Ipv6Addr::from(v6.header().source())), + IpAddr::V6(Ipv6Addr::from(v6.header().destination())), + ), + Some(NetSlice::Arp(_)) | None => return None, + }; + + Some(TcpSegmentInfo { + source: TcpEndpoint::new(source_ip, tcp.source_port()), + destination: TcpEndpoint::new(destination_ip, tcp.destination_port()), + vlan_ids: slice.vlan_ids(), + channel_id, + sequence_number: tcp.sequence_number(), + acknowledgment_number: if tcp.ack() { + Some(tcp.acknowledgment_number()) + } else { + None + }, + payload: tcp.payload(), + syn: tcp.syn(), + fin: tcp.fin(), + rst: tcp.rst(), + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use alloc::{format, vec::Vec}; + + fn build_packet(ack: Option, payload: &[u8]) -> Vec { + let mut tcp = TcpHeader::new(1234, 80, 1000, 4096); + tcp.syn = true; + tcp.fin = true; + tcp.rst = true; + if let Some(ack) = ack { + tcp.ack = true; + tcp.acknowledgment_number = ack; + } + let tcp_bytes = tcp.to_bytes(); + + let mut ipv4 = Ipv4Header { + protocol: IpNumber::TCP, + source: [1, 2, 3, 4], + destination: [5, 6, 7, 8], + total_len: (Ipv4Header::MIN_LEN + tcp_bytes.len() + payload.len()) as u16, + time_to_live: 2, + ..Default::default() + }; + ipv4.header_checksum = ipv4.calc_header_checksum(); + + let mut buf = Vec::new(); + buf.extend_from_slice( + &Ethernet2Header { + source: [0; 6], + destination: [0; 6], + ether_type: EtherType::IPV4, + } + .to_bytes(), + ); + buf.extend_from_slice(&ipv4.to_bytes()); + buf.extend_from_slice(&tcp_bytes); + buf.extend_from_slice(payload); + buf + } + + #[test] + fn from_sliced_packet_ipv4() { + let data = build_packet(Some(555), &[1, 2, 3]); + let slice = SlicedPacket::from_ethernet(&data).unwrap(); + let info = TcpSegmentInfo::from_sliced_packet(&slice, 7u16).unwrap(); + + let _ = format!("{:?}", info); + assert_eq!(info.source, TcpEndpoint::from_ipv4([1, 2, 3, 4], 1234)); + assert_eq!(info.destination, TcpEndpoint::from_ipv4([5, 6, 7, 8], 80)); + assert!(info.vlan_ids.is_empty()); + assert_eq!(info.channel_id, 7); + assert_eq!(info.sequence_number, 1000); + assert_eq!(info.acknowledgment_number, Some(555)); + assert_eq!(info.payload, &[1, 2, 3]); + assert!(info.syn); + assert!(info.fin); + assert!(info.rst); + assert_eq!(info, info.clone()); + } + + #[test] + fn from_sliced_packet_without_ack_flag() { + let data = build_packet(None, &[]); + let slice = SlicedPacket::from_ethernet(&data).unwrap(); + let info = TcpSegmentInfo::from_sliced_packet(&slice, ()).unwrap(); + assert_eq!(info.acknowledgment_number, None); + } + + #[test] + fn from_sliced_packet_non_tcp() { + // packet without any content + let empty = SlicedPacket { + link: None, + link_exts: Default::default(), + net: None, + transport: None, + }; + assert!(TcpSegmentInfo::from_sliced_packet(&empty, ()).is_none()); + } +} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_id.rs b/etherparse/src/tcp_reassembly/tcp_stream_id.rs deleted file mode 100644 index d48ed0a3..00000000 --- a/etherparse/src/tcp_reassembly/tcp_stream_id.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::{tcp_reassembly::*, *}; -use arrayvec::ArrayVec; - -/// Values identifying a single **direction** of a TCP stream. -/// -/// A full TCP connection consists of two [`TcpStreamId`]s (one for each -/// direction) as the source & destination addresses and ports are swapped -/// between the two directions. -/// -/// The identifier can be extended with a custom "channel id" to further -/// differentiate streams if the addresses & ports alone are not enough -/// (e.g. when capturing from multiple interfaces). -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct TcpStreamId { - /// VLAN id's of the original packets. - pub vlan_ids: ArrayVec, - - /// IP source & destination address. - pub ip: TcpStreamIpId, - - /// TCP source port. - pub source_port: u16, - - /// TCP destination port. - pub destination_port: u16, - - /// Custom user defined channel identifier (can be used to differentiate - /// packet sources if the normal ethernet packet identifiers are not - /// enough). - pub channel_id: CustomChannelId, -} - -#[cfg(test)] -mod test { - use super::*; - use alloc::format; - - fn example() -> TcpStreamId { - TcpStreamId { - vlan_ids: Default::default(), - ip: TcpStreamIpId::Ipv4 { - source: [1, 2, 3, 4], - destination: [5, 6, 7, 8], - }, - source_port: 1234, - destination_port: 80, - channel_id: 7, - } - } - - #[test] - fn debug_clone_eq_hash() { - let value = example(); - let _ = format!("{:?}", value); - assert_eq!(value, value.clone()); - assert_ne!(value, { - let mut other = example(); - other.source_port = 4321; - other - }); - - use core::hash::{Hash, Hasher}; - use std::collections::hash_map::DefaultHasher; - let h1 = { - let mut h = DefaultHasher::new(); - value.hash(&mut h); - h.finish() - }; - let h2 = { - let mut h = DefaultHasher::new(); - value.clone().hash(&mut h); - h.finish() - }; - assert_eq!(h1, h2); - } -} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs b/etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs deleted file mode 100644 index b08521a5..00000000 --- a/etherparse/src/tcp_reassembly/tcp_stream_ip_id.rs +++ /dev/null @@ -1,53 +0,0 @@ -/// IPv4 & IPv6 specific source & destination addresses identifying a TCP stream. -#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] -pub enum TcpStreamIpId { - /// IPv4 source & destination address. - Ipv4 { - source: [u8; 4], - destination: [u8; 4], - }, - /// IPv6 source & destination address. - Ipv6 { - source: [u8; 16], - destination: [u8; 16], - }, -} - -#[cfg(test)] -mod test { - use super::*; - use alloc::format; - - #[test] - fn debug_clone_eq_hash_ord() { - let value = TcpStreamIpId::Ipv4 { - source: [1, 2, 3, 4], - destination: [5, 6, 7, 8], - }; - let _ = format!("{:?}", value); - assert_eq!(value, value.clone()); - assert_eq!(value.cmp(&value), core::cmp::Ordering::Equal); - assert_eq!(value.partial_cmp(&value), Some(core::cmp::Ordering::Equal)); - assert_ne!( - value, - TcpStreamIpId::Ipv6 { - source: [0; 16], - destination: [0; 16], - } - ); - - use core::hash::{Hash, Hasher}; - use std::collections::hash_map::DefaultHasher; - let h1 = { - let mut h = DefaultHasher::new(); - value.hash(&mut h); - h.finish() - }; - let h2 = { - let mut h = DefaultHasher::new(); - value.clone().hash(&mut h); - h.finish() - }; - assert_eq!(h1, h2); - } -} diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs index 0a276a99..e797f9c0 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs @@ -26,11 +26,30 @@ const COMPACT_THRESHOLD: usize = 4096; /// * [`TcpStreamReassemblyBuf::add_segment`] feeds a received segment /// including the interpretation of its `SYN` flag (use /// [`TcpStreamReassemblyBuf::add`] to feed just the payload). +/// * [`TcpStreamReassemblyBuf::add_ack`] feeds the acknowledgment numbers +/// observed in the **reverse** direction of the connection. /// * [`TcpStreamReassemblyBuf::contiguous`] returns the in-order bytes that are /// available starting at the current read cursor. /// * [`TcpStreamReassemblyBuf::consume`] advances the read cursor and frees the /// processed bytes so the buffer memory can be re-used. /// +/// # Only acknowledged data is handed out +/// +/// By default ([`TcpAckPolicy::Required`]) only data that the receiver +/// acknowledged is returned by [`TcpStreamReassemblyBuf::contiguous`], so +/// segments that never reached the receiver do not end up in the +/// reconstructed stream. This requires feeding the acknowledgment numbers of +/// the reverse direction via [`TcpStreamReassemblyBuf::add_ack`]. +/// +/// As a capture usually ends before the last data is acknowledged (and may +/// not contain the reverse direction at all), the trailing unacknowledged +/// bytes are still reachable via +/// [`TcpStreamReassemblyBuf::contiguous_unacked`] & +/// [`TcpStreamReassemblyBuf::consume_unacked`]. Set +/// [`TcpAckPolicy::Ignore`] via +/// [`TcpStreamReassemblyBuf::with_ack_policy`] to disable the behavior +/// entirely. +/// /// # Streams without an observed connection setup /// /// If the start of a stream is unknown (no SYN was observed, e.g. when a @@ -72,6 +91,20 @@ pub struct TcpStreamReassemblyBuf { /// segment with the FIN flag was received). fin_offset: Option, + /// Absolute stream offset up to which the receiver acknowledged the data + /// (exclusive). + /// + /// Monotonically increasing, only meaningful if `ack_observed` is true. + ack_offset: u64, + + /// True once an acknowledgment for this direction was observed (via + /// [`TcpStreamReassemblyBuf::add_ack`]). + ack_observed: bool, + + /// Decides if data has to be acknowledged before it is handed out by + /// [`TcpStreamReassemblyBuf::contiguous`]. + ack_policy: TcpAckPolicy, + /// True if the stream start is known (established via [`Self::reset`], /// e.g. from an observed SYN), false if the buffer had to anchor itself /// on the first added segment. @@ -105,6 +138,9 @@ impl TcpStreamReassemblyBuf { data, sections, fin_offset: None, + ack_offset: 0, + ack_observed: false, + ack_policy: TcpAckPolicy::default(), syn_observed: false, max_capacity, max_sections: DEFAULT_MAX_TCP_STREAM_SECTIONS, @@ -119,6 +155,14 @@ impl TcpStreamReassemblyBuf { self } + /// Sets if data has to be acknowledged by the receiver before + /// [`TcpStreamReassemblyBuf::contiguous`] hands it out (defaults to + /// [`TcpAckPolicy::Required`]). + pub fn with_ack_policy(mut self, ack_policy: TcpAckPolicy) -> TcpStreamReassemblyBuf { + self.ack_policy = ack_policy; + self + } + /// Sequence number that is currently mapped to the read cursor (start of /// the still buffered data), or `None` if no data was added yet. #[inline] @@ -170,6 +214,35 @@ impl TcpStreamReassemblyBuf { self.syn_observed } + /// Absolute stream offset up to which the receiver acknowledged the data + /// (exclusive), or `None` if no acknowledgment was observed yet. + #[inline] + pub fn ack_offset(&self) -> Option { + if self.ack_observed { + Some(self.ack_offset) + } else { + None + } + } + + /// True once an acknowledgment for this stream direction was observed. + /// + /// Under [`TcpAckPolicy::Required`] a stream that never sees an + /// acknowledgment never hands out data via + /// [`TcpStreamReassemblyBuf::contiguous`]. This flag allows detecting + /// that case (e.g. a capture only containing one direction, or a stream + /// whose two directions did not get matched up). + #[inline] + pub fn ack_observed(&self) -> bool { + self.ack_observed + } + + /// Policy deciding if data has to be acknowledged before it is handed out. + #[inline] + pub fn ack_policy(&self) -> TcpAckPolicy { + self.ack_policy + } + /// Maximum number of bytes that may be buffered ahead of the read cursor. #[inline] pub fn max_capacity(&self) -> usize { @@ -196,9 +269,84 @@ impl TcpStreamReassemblyBuf { self.base_offset = 0; self.head = 0; self.fin_offset = None; + self.ack_offset = 0; + self.ack_observed = false; self.syn_observed = true; } + /// Record an acknowledgment number observed in the **reverse** direction + /// of the connection. + /// + /// Acknowledgments travel in the opposite direction of the data they + /// acknowledge, so the value has to be taken from the segments flowing + /// back to the sender of this stream (it is expressed in this stream's + /// sequence number space). Only segments with the `ACK` flag set carry a + /// valid acknowledgment number. + /// + /// Under [`TcpAckPolicy::Required`] this is what releases data for + /// [`TcpStreamReassemblyBuf::contiguous`]. + /// + /// Acknowledgments that are re-ordered/stale (referencing data before the + /// read cursor) or that reference data far beyond anything that was + /// received are ignored. + pub fn add_ack(&mut self, ack_seq: u32) { + // even an unusable ack proves that the reverse direction is present + self.ack_observed = true; + + let Some(base_seq) = self.base_seq else { + // without an established base the ack cannot be mapped + return; + }; + + let abs = self.seq_to_abs_offset(base_seq, ack_seq); + if abs <= self.base_offset as i128 { + // stale (also catches acks that the serial number arithmetic + // mapped far into the past) + return; + } + // Guard against nonsense acks (e.g. a wrapped ack that got mapped far + // into the future) releasing data that was never acknowledged. + if abs > self.plausible_offset_limit() as i128 { + return; + } + + self.ack_offset = core::cmp::max(self.ack_offset, abs as u64); + } + + /// Absolute stream offset one past the last received byte. + #[inline] + fn highest_received_offset(&self) -> u64 { + self.sections + .last() + .map(|s| s.end) + .unwrap_or(self.base_offset) + } + + /// Highest absolute stream offset that a received segment may plausibly + /// reference. + /// + /// Referencing data ahead of what was received is normal if the capture + /// missed segments, hence the generous limit. + #[inline] + fn plausible_offset_limit(&self) -> u64 { + self.highest_received_offset() + .saturating_add(self.max_capacity as u64) + } + + /// Returns `true` if `seq` references a plausible position of this stream: + /// at or after the read cursor and not far beyond the received data. + /// + /// Allows rejecting blindly injected segments, e.g. a `RST` carrying an + /// out of window sequence number. Returns `true` if no segment was + /// received for this stream yet (nothing to judge against). + pub fn is_seq_in_window(&self, seq: u32) -> bool { + let Some(base_seq) = self.base_seq else { + return true; + }; + let abs = self.seq_to_abs_offset(base_seq, seq); + abs >= self.base_offset as i128 && abs <= self.plausible_offset_limit() as i128 + } + /// Re-anchors the buffer to a now known stream start (from a late SYN) /// while keeping the already buffered data. /// @@ -622,6 +770,12 @@ impl TcpStreamReassemblyBuf { if let Some(fin_offset) = &mut self.fin_offset { *fin_offset += shift as u64; } + // the acknowledged data is now `shift` bytes further into the stream + // (the newly exposed prefix sits in front of it, so it was + // acknowledged as well). Only meaningful once an ack was observed. + if self.ack_observed { + self.ack_offset += shift as u64; + } } /// Insert a filled range into the sorted section list (merging it with @@ -652,8 +806,9 @@ impl TcpStreamReassemblyBuf { } } - /// Length of the in-order data available starting at the read cursor. - fn contiguous_len(&self) -> usize { + /// Length of the in-order data received at the read cursor (ignoring the + /// [`TcpAckPolicy`]). + fn in_order_len(&self) -> usize { let len = match self.sections.first() { Some(s) if s.start == self.base_offset => (s.end - self.base_offset) as usize, _ => 0, @@ -661,31 +816,74 @@ impl TcpStreamReassemblyBuf { // never hand out data behind the end of the stream (a FIN can be // received after data behind it was already buffered) match self.fin_offset { - Some(fin_offset) => core::cmp::min( - len, - fin_offset.saturating_sub(self.base_offset) as usize, - ), + Some(fin_offset) => { + core::cmp::min(len, fin_offset.saturating_sub(self.base_offset) as usize) + } None => len, } } + /// Length of the in-order data available at the read cursor after applying + /// the [`TcpAckPolicy`]. + fn contiguous_len(&self) -> usize { + match self.ack_policy { + TcpAckPolicy::Ignore => self.in_order_len(), + TcpAckPolicy::Required => core::cmp::min( + self.in_order_len(), + self.ack_offset.saturating_sub(self.base_offset) as usize, + ), + } + } + /// Returns the in-order bytes that are available starting at the read /// cursor. /// /// Returns an empty slice if the byte at the read cursor has not been - /// received yet (a gap at the front of the stream). + /// received yet (a gap at the front of the stream, see + /// [`TcpStreamReassemblyBuf::skip_gap`]) or, under the default + /// [`TcpAckPolicy::Required`], if the receiver did not acknowledge it yet + /// (see [`TcpStreamReassemblyBuf::add_ack`] & + /// [`TcpStreamReassemblyBuf::contiguous_unacked`]). #[inline] pub fn contiguous(&self) -> &[u8] { &self.data[self.head..self.head + self.contiguous_len()] } + /// Returns the in-order bytes at the read cursor **ignoring** the + /// [`TcpAckPolicy`]. + /// + /// The end of a capture usually cuts off the acknowledgments of the last + /// received data, so under [`TcpAckPolicy::Required`] a trailing part of + /// each stream stays unacknowledged. This is the escape hatch to still + /// get at it (together with + /// [`TcpStreamReassemblyBuf::consume_unacked`]), e.g. when draining all + /// streams at the end of a capture or for captures that contain no + /// acknowledgments at all (see + /// [`TcpStreamReassemblyBuf::ack_observed`]). + #[inline] + pub fn contiguous_unacked(&self) -> &[u8] { + &self.data[self.head..self.head + self.in_order_len()] + } + /// Advance the read cursor by `len` bytes and free the consumed data for /// re-use. /// - /// `len` is clamped to the currently available contiguous data so it is not - /// possible to consume into a not yet received gap. + /// `len` is clamped to the data currently returned by + /// [`TcpStreamReassemblyBuf::contiguous`], so it is not possible to + /// consume into a not yet received gap or past the acknowledged data. pub fn consume(&mut self, len: usize) { - let len = core::cmp::min(len, self.contiguous_len()); + self.consume_clamped(core::cmp::min(len, self.contiguous_len())); + } + + /// Advance the read cursor by `len` bytes **ignoring** the + /// [`TcpAckPolicy`] (clamped to + /// [`TcpStreamReassemblyBuf::contiguous_unacked`]). + pub fn consume_unacked(&mut self, len: usize) { + self.consume_clamped(core::cmp::min(len, self.in_order_len())); + } + + /// Advance the read cursor by an already clamped `len`. + fn consume_clamped(&mut self, len: usize) { if len == 0 { return; } @@ -755,9 +953,14 @@ impl TcpStreamReassemblyBuf { /// Returns `true` once all bytes up to the FIN have been received and are /// available as contiguous data. + /// + /// This is about *reception* and ignores the [`TcpAckPolicy`]: under + /// [`TcpAckPolicy::Required`] the last bytes of the stream may be + /// complete here while [`TcpStreamReassemblyBuf::contiguous`] still holds + /// them back because their acknowledgment was not captured. pub fn is_fin_reached(&self) -> bool { match self.fin_offset { - Some(fin) => self.base_offset + self.contiguous_len() as u64 >= fin, + Some(fin) => self.base_offset + self.in_order_len() as u64 >= fin, None => false, } } @@ -777,8 +980,16 @@ mod test { const MAX: usize = 1 << 20; + /// Buffer with the given capacity that hands out data without requiring + /// acknowledgments (the tests below target the reassembly itself, the ack + /// gating has its own tests). + fn new_buf_capacity(max_capacity: usize) -> TcpStreamReassemblyBuf { + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), max_capacity) + .with_ack_policy(TcpAckPolicy::Ignore) + } + fn new_buf() -> TcpStreamReassemblyBuf { - TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX) + new_buf_capacity(MAX) } /// Returns a u8 vec counting up from "start" (truncating to u8). @@ -834,6 +1045,9 @@ mod test { assert!(buf.sections().is_empty()); assert_eq!(buf.fin_offset(), None); assert_eq!(false, buf.syn_observed()); + assert_eq!(false, buf.ack_observed()); + assert_eq!(buf.ack_offset(), None); + assert_eq!(buf.ack_policy(), TcpAckPolicy::Required); assert_eq!(buf.max_capacity(), 4096); assert_eq!(buf.max_sections(), DEFAULT_MAX_TCP_STREAM_SECTIONS); let buf = buf.with_max_sections(3); @@ -925,7 +1139,7 @@ mod test { #[test] fn beyond_max_window() { - let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + let mut buf = new_buf_capacity(16); buf.add(0, &sequence(0, 4), false).unwrap(); // just inside the window (ends at 16) buf.add(12, &sequence(12, 4), false).unwrap(); @@ -942,7 +1156,7 @@ mod test { #[test] fn error_leaves_state_unchanged() { - let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + let mut buf = new_buf_capacity(16); buf.add(0, &sequence(0, 4), false).unwrap(); // segment (with FIN) beyond the window -> error & no state change let err = buf.add(20, &sequence(20, 8), true).unwrap_err(); @@ -1153,7 +1367,7 @@ mod test { #[test] fn rebase_backwards_bounds() { // a segment that would force a prepend beyond max_capacity is rejected - let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + let mut buf = new_buf_capacity(16); buf.add(1000, &sequence(0, 4), false).unwrap(); // seq 980 is 20 bytes behind the anchor -> prepend of 20 + 4 buffered > 16 let err = buf.add(980, &sequence(0, 4), false).unwrap_err(); @@ -1200,7 +1414,7 @@ mod test { #[test] fn too_many_sections() { let mut buf = - TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 1 << 16).with_max_sections(2); + new_buf_capacity(1 << 16).with_max_sections(2); buf.add(0, &sequence(0, 1), false).unwrap(); buf.add(10, &sequence(10, 1), false).unwrap(); assert_eq!(buf.sections().len(), 2); @@ -1290,6 +1504,126 @@ mod test { assert_section_invariants(&buf); } + #[test] + fn ack_gating_is_the_default() { + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX); + assert_eq!(buf.ack_policy(), TcpAckPolicy::Required); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), false).unwrap(); + + // nothing acknowledged yet -> nothing handed out + assert_eq!(false, buf.ack_observed()); + assert_eq!(buf.ack_offset(), None); + assert_eq!(buf.contiguous(), &[]); + // ... while the data itself is available via the escape hatch + assert_eq!(buf.contiguous_unacked(), &sequence(0, 8)[..]); + + // the receiver acknowledges the first 4 bytes + buf.add_ack(1004); + assert!(buf.ack_observed()); + assert_eq!(buf.ack_offset(), Some(4)); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + + // consume is clamped to the acknowledged data + buf.consume(8); + assert_eq!(buf.base_offset(), 4); + assert_eq!(buf.contiguous(), &[]); + assert_eq!(buf.contiguous_unacked(), &sequence(4, 4)[..]); + + // acknowledging the rest releases it + buf.add_ack(1008); + assert_eq!(buf.ack_offset(), Some(8)); + assert_eq!(buf.contiguous(), &sequence(4, 4)[..]); + } + + #[test] + fn ack_unacked_escape_hatch() { + // e.g. at the end of a capture: the last data is never acknowledged + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), true).unwrap(); + + // the FIN is "reached" even though nothing was acknowledged + assert!(buf.is_fin_reached()); + assert_eq!(buf.contiguous(), &[]); + + assert_eq!(buf.contiguous_unacked(), &sequence(0, 8)[..]); + buf.consume_unacked(8); + assert_eq!(buf.base_offset(), 8); + assert!(buf.contiguous_unacked().is_empty()); + } + + #[test] + fn ack_policy_ignore() { + let mut buf = new_buf(); + assert_eq!(buf.ack_policy(), TcpAckPolicy::Ignore); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), false).unwrap(); + assert_eq!(false, buf.ack_observed()); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); + } + + #[test] + fn ack_stale_and_nonsense_are_ignored() { + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 64); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), false).unwrap(); + buf.add_ack(1008); + assert_eq!(buf.ack_offset(), Some(8)); + + // a re-ordered / older ack does not move the offset backwards + buf.add_ack(1004); + assert_eq!(buf.ack_offset(), Some(8)); + + // an ack far beyond anything that was received is ignored + buf.add_ack(1000u32.wrapping_add(10_000)); + assert_eq!(buf.ack_offset(), Some(8)); + + // an ack that the serial number arithmetic maps far into the past + buf.add_ack(1000u32.wrapping_sub(1_000_000)); + assert_eq!(buf.ack_offset(), Some(8)); + + // an ack without an established base only records the observation + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 64); + buf.add_ack(1234); + assert!(buf.ack_observed()); + assert_eq!(buf.ack_offset(), Some(0)); + } + + #[test] + fn ack_offset_shifts_on_reanchor() { + // a backwards re-anchor has to move the ack offset with the data + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX); + buf.add(1000, &sequence(10, 4), false).unwrap(); + buf.add_ack(1004); + assert_eq!(buf.ack_offset(), Some(4)); + assert_eq!(buf.contiguous(), &sequence(10, 4)[..]); + + // earlier data re-anchors the buffer by 10 bytes, the acknowledged + // data moves with it (the prefix is in front of the ack, so it is + // acknowledged as well) + buf.add(990, &sequence(0, 10), false).unwrap(); + assert_eq!(buf.ack_offset(), Some(14)); + assert_eq!(buf.contiguous(), &sequence(0, 14)[..]); + } + + #[test] + fn ack_state_cleared_by_reset() { + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), false).unwrap(); + buf.add_ack(1008); + assert_eq!(buf.ack_offset(), Some(8)); + + buf.reset(5000); + assert_eq!(false, buf.ack_observed()); + assert_eq!(buf.ack_offset(), None); + buf.add(5000, &sequence(0, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &[]); + buf.add_ack(5004); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + } + #[test] fn overlap_spanning_multiple_sections() { let mut buf = new_buf(); @@ -1336,7 +1670,7 @@ mod test { #[test] fn skip_gap_frees_the_max_window() { // a permanent gap eventually fills up the whole window - let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 16); + let mut buf = new_buf_capacity(16); buf.add(1000, &sequence(0, 4), false).unwrap(); buf.consume(4); @@ -1396,7 +1730,7 @@ mod test { to_send.swap(i, j); } - let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 1 << 20); + let mut buf = new_buf_capacity(1 << 20); // model a known stream start (as if the ISN was learned from a SYN): // offset 0 of the reference maps to sequence number `isn`. buf.reset(isn); diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs index 2d7d4d16..8c3a1bab 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs @@ -5,52 +5,84 @@ use std::vec::Vec; /// Result of processing a packet with a [`TcpStreamReassemblyPool`]. #[derive(Debug)] pub enum TcpReassemblyEvent<'a> { - /// The packet did not affect any stream (not a TCP segment, a RST for an - /// unknown stream or an empty segment of an unknown stream, e.g. a pure - /// ACK). + /// The packet did not affect any connection (not a TCP segment, or a + /// segment of an unknown connection that carries nothing to reconstruct, + /// e.g. a pure ACK or an out of window RST). Ignored, - /// The segment belongs to this (potentially newly created) stream. + /// The segment was added to this (potentially newly created) connection. + Segment { + /// Direction the segment was travelling in. + direction: TcpDirection, + + /// Stream the payload of the segment was added to. + /// + /// Read the available in-order data via + /// [`TcpStreamReassemblyBuf::contiguous`] and free it via + /// [`TcpStreamReassemblyBuf::consume`]. + sender: &'a mut TcpStreamReassemblyBuf, + + /// Stream of the opposite direction. + /// + /// If the segment carried an acknowledgment number the data available + /// in this stream may have grown (see [`TcpAckPolicy::Required`]), so + /// it is worth draining as well. + receiver: &'a mut TcpStreamReassemblyBuf, + }, + + /// The connection was ended by a RST or replaced by a SYN with a new + /// initial sequence number. /// - /// Read the available in-order data via - /// [`TcpStreamReassemblyBuf::contiguous`] and free it via - /// [`TcpStreamReassemblyBuf::consume`]. - Stream(&'a mut TcpStreamReassemblyBuf), - - /// A stream was ended by a RST or replaced by a SYN with a new initial - /// sequence number. - /// - /// In-order data that was never consumed can still be read from the - /// returned buffer. The buffer is automatically recycled on the next - /// `process_*` call. + /// In-order data that was never consumed can still be read from both + /// directions of the returned connection (use + /// [`TcpStreamReassemblyBuf::contiguous_unacked`], as the closing segment + /// is usually not acknowledged anymore). The buffers are automatically + /// recycled on the next `process_*` call. /// - /// In the "replaced by a SYN" case the newly created stream can be - /// accessed via [`TcpStreamReassemblyPool::stream_mut`]. - Closed(&'a mut TcpStreamReassemblyBuf), + /// In the "replaced by a SYN" case the newly created connection can be + /// accessed via [`TcpStreamReassemblyPool::connection_mut`]. + Closed(&'a mut TcpConnection), } /// Pool to reassemble the payload byte streams of multiple TCP connections in /// parallel (re-uses buffers to minimize allocations). /// -/// Streams are differentiated by their VLAN ids, IP source & destination -/// address and TCP source & destination port. A custom "channel id" can be -/// added to further differentiate streams (e.g. when capturing from multiple -/// interfaces). Note that each *direction* of a connection is a separate stream -/// (the source & destination are swapped between the two directions). +/// Both directions of a connection are tracked together (see +/// [`TcpConnectionId`] & [`TcpConnection`]), which is what allows the +/// acknowledgment numbers of one direction to release the data of the other +/// one. +/// +/// # Only acknowledged data is handed out +/// +/// By default ([`TcpAckPolicy::Required`]) [`TcpStreamReassemblyBuf::contiguous`] +/// only returns data that the receiver acknowledged. As a capture usually ends +/// before the last data is acknowledged, drain the remaining bytes via +/// [`TcpStreamReassemblyBuf::contiguous_unacked`] & +/// [`TcpStreamReassemblyBuf::consume_unacked`] at the end of a capture. +/// +/// This requires both directions of a connection to be present in the capture +/// and to be identified as the same connection. Use +/// [`TcpConnection::is_bidirectional`] to detect connections where that is not +/// the case, or set [`TcpAckPolicy::Ignore`] via +/// [`TcpStreamReassemblyPool::with_ack_policy`] for captures that only contain +/// one direction. /// /// # Interpretation of the TCP flags /// /// * `SYN` establishes the stream start. Duplicated/retransmitted SYNs and a /// late SYN of an already tracked stream are recognized and do **not** drop /// already buffered data. A SYN with a new initial sequence number replaces -/// the tracked stream (see [`TcpReassemblyEvent::Closed`]). -/// * `FIN` marks the end of the stream (see -/// [`TcpStreamReassemblyBuf::is_fin_reached`]). Note that a stream is *not* -/// automatically removed from the pool when the FIN is reached (use -/// [`TcpStreamReassemblyPool::end_stream`] or +/// the tracked connection (see [`TcpReassemblyEvent::Closed`]). +/// * `ACK` releases the data of the **reverse** direction. +/// * `FIN` marks the end of a stream (see +/// [`TcpStreamReassemblyBuf::is_fin_reached`]). Note that a connection is +/// *not* automatically removed from the pool when the FIN is reached (use +/// [`TcpStreamReassemblyPool::end_connection`] or /// [`TcpStreamReassemblyPool::retain`]). -/// * `RST` ends the stream & recycles its buffers (see -/// [`TcpReassemblyEvent::Closed`]). +/// * `RST` ends the connection (both directions) & recycles its buffers (see +/// [`TcpReassemblyEvent::Closed`]). RSTs carrying a sequence number outside +/// of the tracked window are ignored so a blindly injected RST cannot tear +/// down a connection. /// /// Streams for which no SYN was observed (e.g. when the capture starts in the /// middle of a connection) are anchored on their first segment and @@ -64,23 +96,37 @@ pub enum TcpReassemblyEvent<'a> { /// fragmented packets first (e.g. via [`crate::defrag::IpDefragPool`]) and /// feed the result via [`TcpStreamReassemblyPool::process_tcp`]. /// -/// # This implementation is NOT safe against "Out of Memory" attacks +/// # TCP checksums are not verified /// -/// While each individual stream is bounded (`max_capacity` bytes of buffered -/// data & `max_sections` tracked ranges), the number of parallel streams is -/// not. If you use the [`TcpStreamReassemblyPool`] in an untrusted environment -/// an attacker could cause an "out of memory error" by opening up many -/// parallel connections. Use [`TcpStreamReassemblyPool::retain`] (or a custom -/// `channel_id` limit) to evict stale streams. +/// The reassembly does not validate the TCP checksum of the segments it is +/// fed, so corrupted segments end up in the reconstructed stream. Compare +/// [`crate::TcpSlice::checksum`] against +/// [`crate::TcpSlice::calc_checksum_ipv4`] / +/// [`crate::TcpSlice::calc_checksum_ipv6`] beforehand if that matters. +/// +/// # Memory usage +/// +/// Each individual stream is bounded by `max_capacity` (buffered bytes) & +/// `max_sections` (tracked ranges), and the buffers that are kept around for +/// re-use after a connection ended are bounded by +/// [`TcpStreamReassemblyPool::with_pool_limits`]. +/// +/// The number of parallel connections is **unlimited by default**, so in an +/// untrusted environment an attacker could cause an "out of memory error" by +/// opening up many parallel connections. Set +/// [`TcpStreamReassemblyPool::with_max_connections`] to bound it (new +/// connections are then rejected with +/// [`TcpReassembleError::TooManyConnections`]) and evict stale connections +/// via [`TcpStreamReassemblyPool::retain`]. #[derive(Debug, Clone)] pub struct TcpStreamReassemblyPool { - /// Currently reconstructing TCP streams. - active: HashMap, (TcpStreamReassemblyBuf, Timestamp)>, + /// Currently reconstructing TCP connections. + active: HashMap, (TcpConnection, Timestamp)>, - /// Stream that was closed by the last `process_*` call (RST or replaced - /// via a SYN), kept around so the caller can still drain the leftover - /// data. Recycled at the start of the next `process_*` call. - pending_closed: Option, + /// Connection that was closed by the last `process_*` call (RST or + /// replaced via a SYN), kept around so the caller can still drain the + /// leftover data. Recycled at the start of the next `process_*` call. + pending_closed: Option, /// Data buffers that can be re-used. finished_data_bufs: Vec>, @@ -93,6 +139,19 @@ pub struct TcpStreamReassemblyPool { /// Maximum number of separate (non-contiguous) data sections per stream. default_max_sections: usize, + + /// Policy deciding if data has to be acknowledged before it is handed out. + default_ack_policy: TcpAckPolicy, + + /// Maximum number of buffers kept in the free lists for re-use. + max_pooled_bufs: usize, + + /// Maximum capacity (in bytes) a data buffer may have to be pooled. + max_pooled_buf_capacity: usize, + + /// Maximum number of connections tracked at the same time (`None` for + /// unlimited). + max_connections: Option, } /// Takes a buffer from the free lists (or allocates a new one). @@ -101,6 +160,7 @@ fn pop_free_buf( free_sections: &mut Vec>, max_capacity: usize, max_sections: usize, + ack_policy: TcpAckPolicy, ) -> TcpStreamReassemblyBuf { TcpStreamReassemblyBuf::new( free_data.pop().unwrap_or_default(), @@ -108,17 +168,87 @@ fn pop_free_buf( max_capacity, ) .with_max_sections(max_sections) + .with_ack_policy(ack_policy) } /// Returns the allocations of the given buffer to the free lists. +/// +/// Buffers beyond `max_pooled_bufs` are dropped and buffers that grew beyond +/// `max_pooled_buf_capacity` are shrunk, so a burst of connections (or a +/// single big stream) does not make the pool hold on to the memory for the +/// rest of its lifetime. fn recycle_buf( free_data: &mut Vec>, free_sections: &mut Vec>, + max_pooled_bufs: usize, + max_pooled_buf_capacity: usize, buf: TcpStreamReassemblyBuf, ) { - let (data, sections) = buf.take_bufs(); - free_data.push(data); - free_sections.push(sections); + let (mut data, mut sections) = buf.take_bufs(); + if free_data.len() < max_pooled_bufs { + // clear first, as `shrink_to` never shrinks below the length + data.clear(); + data.shrink_to(max_pooled_buf_capacity); + free_data.push(data); + } + if free_sections.len() < max_pooled_bufs { + // the number of sections is already bounded by `max_sections` per + // stream, so only the count of pooled buffers has to be limited + sections.clear(); + free_sections.push(sections); + } +} + +/// Takes the two buffers of a connection from the free lists. +fn pop_free_connection( + free_data: &mut Vec>, + free_sections: &mut Vec>, + max_capacity: usize, + max_sections: usize, + ack_policy: TcpAckPolicy, +) -> TcpConnection { + TcpConnection::new( + pop_free_buf( + free_data, + free_sections, + max_capacity, + max_sections, + ack_policy, + ), + pop_free_buf( + free_data, + free_sections, + max_capacity, + max_sections, + ack_policy, + ), + ) +} + +/// Returns the allocations of both directions of a connection to the free +/// lists. +fn recycle_connection( + free_data: &mut Vec>, + free_sections: &mut Vec>, + max_pooled_bufs: usize, + max_pooled_buf_capacity: usize, + connection: TcpConnection, +) { + let (first_to_second, second_to_first) = connection.take_bufs(); + recycle_buf( + free_data, + free_sections, + max_pooled_bufs, + max_pooled_buf_capacity, + first_to_second, + ); + recycle_buf( + free_data, + free_sections, + max_pooled_bufs, + max_pooled_buf_capacity, + second_to_first, + ); } impl TcpStreamReassemblyPool @@ -150,18 +280,75 @@ where finished_section_bufs: Vec::new(), default_max_capacity: max_capacity, default_max_sections: max_sections, + default_ack_policy: TcpAckPolicy::default(), + max_pooled_bufs: DEFAULT_MAX_TCP_POOLED_BUFS, + max_pooled_buf_capacity: DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY, + max_connections: None, } } + /// Sets if data has to be acknowledged by the receiver before it is + /// handed out by [`TcpStreamReassemblyBuf::contiguous`] (defaults to + /// [`TcpAckPolicy::Required`]). + pub fn with_ack_policy( + mut self, + ack_policy: TcpAckPolicy, + ) -> TcpStreamReassemblyPool { + self.default_ack_policy = ack_policy; + self + } + + /// Sets how many buffers are kept around for re-use after the connections + /// they belonged to ended and how big a single pooled buffer may be + /// (defaults to [`DEFAULT_MAX_TCP_POOLED_BUFS`] & + /// [`DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY`]). + pub fn with_pool_limits( + mut self, + max_pooled_bufs: usize, + max_pooled_buf_capacity: usize, + ) -> TcpStreamReassemblyPool { + self.max_pooled_bufs = max_pooled_bufs; + self.max_pooled_buf_capacity = max_pooled_buf_capacity; + self + } + + /// Limits the number of connections that are tracked at the same time + /// (unlimited by default). + /// + /// Segments starting a new connection while the limit is reached are + /// rejected with [`TcpReassembleError::TooManyConnections`]. Evict + /// connections that are no longer of interest via + /// [`TcpStreamReassemblyPool::retain`] or + /// [`TcpStreamReassemblyPool::end_connection`] to make room. + pub fn with_max_connections( + mut self, + max_connections: usize, + ) -> TcpStreamReassemblyPool { + self.max_connections = Some(max_connections); + self + } + + /// Maximum number of connections tracked at the same time (`None` if + /// unlimited). + #[inline] + pub fn max_connections(&self) -> Option { + self.max_connections + } + /// Process a TCP segment contained in a [`SlicedPacket`]. /// + /// The VLAN ids of the packet are part of the connection identity. Use + /// [`TcpSegmentInfo::from_sliced_packet`] together with + /// [`TcpStreamReassemblyPool::process_tcp`] to change that (see + /// [`TcpConnectionId`]). + /// /// Returns: /// * `Ok(TcpReassemblyEvent::Ignored)` if the packet did not affect any - /// stream (e.g. not a TCP segment). - /// * `Ok(TcpReassemblyEvent::Stream(..))` giving access to the affected - /// stream. - /// * `Ok(TcpReassemblyEvent::Closed(..))` if a stream was ended by a RST - /// or replaced by a new connection (leftover data can be drained). + /// connection (e.g. not a TCP segment). + /// * `Ok(TcpReassemblyEvent::Segment{..})` giving access to both + /// directions of the affected connection. + /// * `Ok(TcpReassemblyEvent::Closed(..))` if a connection was ended by a + /// RST or replaced by a new connection (leftover data can be drained). /// * `Err` if the segment could not be added (see [`TcpReassembleError`]). pub fn process_sliced_packet( &mut self, @@ -169,229 +356,298 @@ where timestamp: Timestamp, channel_id: CustomChannelId, ) -> Result, TcpReassembleError> { - // only TCP segments are relevant - let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { - return Ok(TcpReassemblyEvent::Ignored); - }; - - // extract the source & destination addresses - let ip = match &slice.net { - Some(NetSlice::Ipv4(v4)) => TcpStreamIpId::Ipv4 { - source: v4.header().source(), - destination: v4.header().destination(), - }, - Some(NetSlice::Ipv6(v6)) => TcpStreamIpId::Ipv6 { - source: v6.header().source(), - destination: v6.header().destination(), - }, - Some(NetSlice::Arp(_)) | None => { - return Ok(TcpReassemblyEvent::Ignored); + match TcpSegmentInfo::from_sliced_packet(slice, channel_id) { + Some(segment) => self.process(segment, timestamp), + None => { + self.recycle_pending_closed(); + Ok(TcpReassemblyEvent::Ignored) } - }; - - let id = TcpStreamId { - vlan_ids: slice.vlan_ids(), - ip, - source_port: tcp.source_port(), - destination_port: tcp.destination_port(), - channel_id, - }; - - self.process( - id, - tcp.sequence_number(), - tcp.payload(), - tcp.syn(), - tcp.fin(), - tcp.rst(), - timestamp, - ) + } } - /// Process an already parsed TCP segment (lower level entry point that does - /// not require a [`SlicedPacket`]). - #[allow(clippy::too_many_arguments)] + /// Process an already parsed TCP segment (lower level entry point that + /// does not require a [`SlicedPacket`] and allows customizing what + /// differentiates connections). pub fn process_tcp( &mut self, - id: TcpStreamId, - sequence_number: u32, - payload: &[u8], - syn: bool, - fin: bool, - rst: bool, + segment: TcpSegmentInfo<'_, CustomChannelId>, timestamp: Timestamp, ) -> Result, TcpReassembleError> { - self.process(id, sequence_number, payload, syn, fin, rst, timestamp) + self.process(segment, timestamp) + } + + /// Recycles the buffers of the connection closed by the previous + /// `process_*` call (the caller had the chance to drain it until now). + fn recycle_pending_closed(&mut self) { + if let Some(closed) = self.pending_closed.take() { + recycle_connection( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + self.max_pooled_bufs, + self.max_pooled_buf_capacity, + closed, + ); + } } - #[allow(clippy::too_many_arguments)] fn process( &mut self, - id: TcpStreamId, - seq: u32, - payload: &[u8], - syn: bool, - fin: bool, - rst: bool, + segment: TcpSegmentInfo<'_, CustomChannelId>, timestamp: Timestamp, ) -> Result, TcpReassembleError> { use std::collections::hash_map::Entry; use TcpReassemblyEvent::*; - // recycle the buffer of the stream that was closed by the previous - // call (the caller had the chance to drain it until now) - if let Some(closed) = self.pending_closed.take() { - recycle_buf( - &mut self.finished_data_bufs, - &mut self.finished_section_bufs, - closed, - ); - } + self.recycle_pending_closed(); - // a RST tears the stream down + let TcpSegmentInfo { + source, + destination, + vlan_ids, + channel_id, + sequence_number: seq, + acknowledgment_number: ack, + payload, + syn, + fin, + rst, + } = segment; + + let (id, direction) = TcpConnectionId::new(source, destination, vlan_ids, channel_id); + + // a RST tears down the whole connection (both directions) if rst { + // Only accept a RST that is plausible for the stream it was sent + // in, so a blindly injected out of window RST cannot end the + // connection. + let accepted = match self.active.get(&id) { + Some((connection, _)) => connection.stream(direction).is_seq_in_window(seq), + None => return Ok(Ignored), + }; + if false == accepted { + return Ok(Ignored); + } return Ok(match self.active.remove(&id) { - Some((buf, _)) => Closed(self.pending_closed.insert(buf)), + Some((connection, _)) => Closed(self.pending_closed.insert(connection)), None => Ignored, }); } + // captured before the entry below borrows `self.active` + let active_len = self.active.len(); + match self.active.entry(id) { Entry::Occupied(entry) => { let value = entry.into_mut(); // note: the timestamp is only updated after the fallible - // steps, so an error leaves the stream fully unchanged - match value.0.add_segment(seq, payload, syn, fin)? { + // steps, so an error leaves the connection fully unchanged + let outcome = value + .0 + .stream_mut(direction) + .add_segment(seq, payload, syn, fin)?; + + match outcome { TcpSegmentOutcome::Continued => { + if let Some(ack) = ack { + value.0.stream_mut(direction.reverse()).add_ack(ack); + } value.1 = timestamp; - Ok(Stream(&mut value.0)) + let (sender, receiver) = value.0.streams_mut(direction); + Ok(Segment { + direction, + sender, + receiver, + }) } TcpSegmentOutcome::NewConnection => { - // reconnect: replace the stream with a fresh one - let mut fresh = pop_free_buf( + // reconnect: replace both directions with fresh streams + let mut fresh = pop_free_connection( &mut self.finished_data_bufs, &mut self.finished_section_bufs, self.default_max_capacity, self.default_max_sections, + self.default_ack_policy, ); - // the fresh buffer has no base yet, so this only + // the fresh streams have no base yet, so this only // fails if the SYN payload exceeds the limits. The // replacement did not happen yet, so the previous - // stream stays untouched & accessible in that case. - if let Err(err) = fresh.add_segment(seq, payload, syn, fin) { - recycle_buf( + // connection stays untouched & accessible then. + if let Err(err) = fresh + .stream_mut(direction) + .add_segment(seq, payload, syn, fin) + { + recycle_connection( &mut self.finished_data_bufs, &mut self.finished_section_bufs, + self.max_pooled_bufs, + self.max_pooled_buf_capacity, fresh, ); return Err(err); } + if let Some(ack) = ack { + fresh.stream_mut(direction.reverse()).add_ack(ack); + } value.1 = timestamp; let old = core::mem::replace(&mut value.0, fresh); - if old.contiguous().is_empty() { - recycle_buf( + if old.has_leftover_data() { + // "value" can no longer be returned, but the + // leftover data of the replaced connection can + Ok(Closed(self.pending_closed.insert(old))) + } else { + recycle_connection( &mut self.finished_data_bufs, &mut self.finished_section_bufs, + self.max_pooled_bufs, + self.max_pooled_buf_capacity, old, ); - Ok(Stream(&mut value.0)) - } else { - // "value" can no longer be returned, but the - // leftover data of the replaced stream can - Ok(Closed(self.pending_closed.insert(old))) + let (sender, receiver) = value.0.streams_mut(direction); + Ok(Segment { + direction, + sender, + receiver, + }) } } } } Entry::Vacant(entry) => { // segments without payload, SYN & FIN carry no data for the - // reassembly -> don't create a stream for them (e.g. pure - // ACKs, port scans, keep alives of unknown streams) + // reassembly -> don't create a connection for them (e.g. pure + // ACKs, port scans, keep alives of unknown connections) if payload.is_empty() && false == syn && false == fin { return Ok(Ignored); } - let mut buf = pop_free_buf( + // keep the number of tracked connections bounded + if let Some(max_connections) = self.max_connections { + if active_len >= max_connections { + return Err(TcpReassembleError::TooManyConnections { max_connections }); + } + } + let mut connection = pop_free_connection( &mut self.finished_data_bufs, &mut self.finished_section_bufs, self.default_max_capacity, self.default_max_sections, + self.default_ack_policy, ); - match buf.add_segment(seq, payload, syn, fin) { - Ok(_) => Ok(Stream(&mut entry.insert((buf, timestamp)).0)), - Err(err) => { - recycle_buf( - &mut self.finished_data_bufs, - &mut self.finished_section_bufs, - buf, - ); - Err(err) - } + if let Err(err) = connection + .stream_mut(direction) + .add_segment(seq, payload, syn, fin) + { + recycle_connection( + &mut self.finished_data_bufs, + &mut self.finished_section_bufs, + self.max_pooled_bufs, + self.max_pooled_buf_capacity, + connection, + ); + return Err(err); + } + if let Some(ack) = ack { + connection.stream_mut(direction.reverse()).add_ack(ack); } + let value = entry.insert((connection, timestamp)); + let (sender, receiver) = value.0.streams_mut(direction); + Ok(Segment { + direction, + sender, + receiver, + }) } } } - /// Direct mutable access to an active stream (e.g. to read & consume data - /// outside of a `process_*` call). + /// Direct access to an active connection. + pub fn connection( + &self, + id: &TcpConnectionId, + ) -> Option<&TcpConnection> { + self.active.get(id).map(|(connection, _)| connection) + } + + /// Direct mutable access to an active connection (e.g. to read & consume + /// data outside of a `process_*` call). + pub fn connection_mut( + &mut self, + id: &TcpConnectionId, + ) -> Option<&mut TcpConnection> { + self.active.get_mut(id).map(|(connection, _)| connection) + } + + /// Direct mutable access to a single direction of an active connection. pub fn stream_mut( &mut self, - id: &TcpStreamId, + id: &TcpConnectionId, + direction: TcpDirection, ) -> Option<&mut TcpStreamReassemblyBuf> { - self.active.get_mut(id).map(|(buf, _)| buf) + self.active + .get_mut(id) + .map(|(connection, _)| connection.stream_mut(direction)) } - /// Explicitly end a stream and recycle its buffers. - pub fn end_stream(&mut self, id: &TcpStreamId) { - if let Some((buf, _)) = self.active.remove(id) { - recycle_buf( + /// Explicitly end a connection and recycle its buffers. + pub fn end_connection(&mut self, id: &TcpConnectionId) { + if let Some((connection, _)) = self.active.remove(id) { + recycle_connection( &mut self.finished_data_bufs, &mut self.finished_section_bufs, - buf, + self.max_pooled_bufs, + self.max_pooled_buf_capacity, + connection, ); } } - /// Number of currently active streams. + /// Number of currently active connections. #[inline] - pub fn active_streams(&self) -> usize { + pub fn active_connections(&self) -> usize { self.active.len() } - /// Iterates over all active streams (e.g. to drain the remaining data of - /// all streams at the end of a capture). + /// Iterates over all active connections (e.g. to drain the remaining data + /// of all streams at the end of a capture). pub fn iter_mut( &mut self, ) -> impl Iterator< Item = ( - &TcpStreamId, - &mut TcpStreamReassemblyBuf, + &TcpConnectionId, + &mut TcpConnection, &Timestamp, ), > { self.active.iter_mut().map(|(id, v)| (id, &mut v.0, &v.1)) } - /// Retains only the streams specified by the predicate and recycles the - /// buffers of the evicted ones (e.g. to remove streams that have not - /// received data for a while based on the `Timestamp`). + /// Retains only the connections specified by the predicate and recycles + /// the buffers of the evicted ones (e.g. to remove connections that have + /// not received data for a while based on the `Timestamp`). pub fn retain(&mut self, mut f: F) where - F: FnMut(&TcpStreamId, &Timestamp) -> bool, + F: FnMut(&TcpConnectionId, &Timestamp) -> bool, { let finished_data_bufs = &mut self.finished_data_bufs; let finished_section_bufs = &mut self.finished_section_bufs; + let max_pooled_bufs = self.max_pooled_bufs; + let max_pooled_buf_capacity = self.max_pooled_buf_capacity; self.active.retain(|id, value| { if f(id, &value.1) { true } else { - recycle_buf( + recycle_connection( finished_data_bufs, finished_section_bufs, + max_pooled_bufs, + max_pooled_buf_capacity, core::mem::replace( &mut value.0, - TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 0), + TcpConnection::new( + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 0), + TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 0), + ), ), ); false @@ -415,35 +671,72 @@ mod test { use arrayvec::ArrayVec; use std::vec::Vec; - fn ipv4_id(channel_id: u16) -> TcpStreamId { - TcpStreamId { - vlan_ids: ArrayVec::new_const(), - ip: TcpStreamIpId::Ipv4 { - source: [1, 2, 3, 4], - destination: [5, 6, 7, 8], - }, - source_port: 1234, - destination_port: 80, + fn endpoint_a() -> TcpEndpoint { + TcpEndpoint::from_ipv4([1, 2, 3, 4], 1234) + } + + fn endpoint_b() -> TcpEndpoint { + TcpEndpoint::from_ipv4([5, 6, 7, 8], 80) + } + + /// Direction of segments travelling from a to b (see `canonical_direction`). + const A_TO_B: TcpDirection = TcpDirection::FirstToSecond; + + /// Segment travelling from a to b (`reverse == false`) or from b to a. + fn segment(reverse: bool, seq: u32, payload: &[u8], channel_id: u16) -> TcpSegmentInfo<'_, u16> { + let (source, destination) = if reverse { + (endpoint_b(), endpoint_a()) + } else { + (endpoint_a(), endpoint_b()) + }; + TcpSegmentInfo { + source, + destination, + vlan_ids: Default::default(), channel_id, + sequence_number: seq, + acknowledgment_number: None, + payload, + syn: false, + fin: false, + rst: false, } } + fn conn_id(channel_id: u16) -> TcpConnectionId { + TcpConnectionId::new(endpoint_a(), endpoint_b(), Default::default(), channel_id).0 + } + fn sequence(start: usize, len: usize) -> Vec { (start..start + len).map(|i| (i & 0xff) as u8).collect() } - /// Unwraps a [`TcpReassemblyEvent::Stream`]. - fn stream(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { + /// Pool that hands out data without requiring acknowledgments (for the + /// tests targeting the flag handling & lifecycle). + fn new_pool() -> TcpStreamReassemblyPool { + TcpStreamReassemblyPool::new().with_ack_policy(TcpAckPolicy::Ignore) + } + + /// Unwraps the stream the payload was added to. + fn sender(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { + match ev { + TcpReassemblyEvent::Segment { sender, .. } => sender, + other => panic!("expected TcpReassemblyEvent::Segment, got {other:?}"), + } + } + + /// Unwraps the stream of the opposite direction. + fn receiver(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { match ev { - TcpReassemblyEvent::Stream(buf) => buf, - other => panic!("expected TcpReassemblyEvent::Stream, got {other:?}"), + TcpReassemblyEvent::Segment { receiver, .. } => receiver, + other => panic!("expected TcpReassemblyEvent::Segment, got {other:?}"), } } /// Unwraps a [`TcpReassemblyEvent::Closed`]. - fn closed(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { + fn closed(ev: TcpReassemblyEvent<'_>) -> &mut TcpConnection { match ev { - TcpReassemblyEvent::Closed(buf) => buf, + TcpReassemblyEvent::Closed(connection) => connection, other => panic!("expected TcpReassemblyEvent::Closed, got {other:?}"), } } @@ -455,15 +748,22 @@ mod test { #[test] fn new_default() { let pool = TcpStreamReassemblyPool::<(), ()>::new(); - assert_eq!(pool.active_streams(), 0); + assert_eq!(pool.active_connections(), 0); + // acknowledgments are required by default + assert_eq!(pool.default_ack_policy, TcpAckPolicy::Required); + let pool: TcpStreamReassemblyPool = Default::default(); - assert_eq!(pool.active_streams(), 0); + assert_eq!(pool.active_connections(), 0); + let pool = TcpStreamReassemblyPool::<(), ()>::with_max_capacity(16); assert_eq!(pool.default_max_capacity, 16); assert_eq!(pool.default_max_sections, DEFAULT_MAX_TCP_STREAM_SECTIONS); - let pool = TcpStreamReassemblyPool::<(), ()>::with_limits(16, 4); + + let pool = TcpStreamReassemblyPool::<(), ()>::with_limits(16, 4) + .with_ack_policy(TcpAckPolicy::Ignore); assert_eq!(pool.default_max_capacity, 16); assert_eq!(pool.default_max_sections, 4); + assert_eq!(pool.default_ack_policy, TcpAckPolicy::Ignore); } #[test] @@ -473,236 +773,342 @@ mod test { } #[test] - fn basic_syn_data_flow() { - let mut pool = TcpStreamReassemblyPool::::new(); - let id = ipv4_id(0); + fn canonical_direction() { + // both directions map to the same connection id + let (id_ab, dir_ab) = + TcpConnectionId::new(endpoint_a(), endpoint_b(), Default::default(), 0u16); + let (id_ba, dir_ba) = + TcpConnectionId::new(endpoint_b(), endpoint_a(), Default::default(), 0u16); + assert_eq!(id_ab, id_ba); + assert_eq!(dir_ab, A_TO_B); + assert_eq!(dir_ba, A_TO_B.reverse()); + } - // SYN (isn = 999, so data starts at 1000) - let buf = stream( - pool.process_tcp(id.clone(), 999, &[], true, false, false, 1) + #[test] + fn ack_releases_peer_data() { + // acknowledgments are required by default + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + + // a sends data, nothing acknowledged yet + let buf = sender( + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(), ); + assert_eq!(buf.contiguous(), &[]); + assert_eq!(buf.contiguous_unacked(), &sequence(0, 8)[..]); + assert_eq!(false, buf.ack_observed()); + + // b acknowledges the first 4 bytes with a pure ACK: the data of the + // *opposite* direction becomes available + let mut ack = segment(true, 5000, &[], 0); + ack.acknowledgment_number = Some(1004); + let buf = receiver(pool.process_tcp(ack, ()).unwrap()); + assert!(buf.ack_observed()); + assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); + buf.consume(4); + + // acknowledging the rest releases it + let mut ack = segment(true, 5000, &[], 0); + ack.acknowledgment_number = Some(1008); + let buf = receiver(pool.process_tcp(ack, ()).unwrap()); + assert_eq!(buf.contiguous(), &sequence(4, 4)[..]); + } + + #[test] + fn ack_carried_by_a_data_segment() { + // a data segment acknowledges the reverse direction at the same time + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) + .unwrap(); + + let payload = sequence(100, 4); + let mut seg = segment(true, 5000, &payload, 0); + seg.acknowledgment_number = Some(1008); + let ev = pool.process_tcp(seg, ()).unwrap(); + match ev { + TcpReassemblyEvent::Segment { + direction, + sender, + receiver, + } => { + assert_eq!(direction, A_TO_B.reverse()); + // the payload of this segment is not acknowledged yet + assert_eq!(sender.contiguous(), &[]); + assert_eq!(sender.contiguous_unacked(), &sequence(100, 4)[..]); + // ... while it released the data of the other direction + assert_eq!(receiver.contiguous(), &sequence(0, 8)[..]); + } + other => panic!("expected Segment, got {other:?}"), + } + } + + #[test] + fn basic_syn_data_flow() { + let mut pool = TcpStreamReassemblyPool::::new(); + + // SYN from a (isn 999, so data starts at 1000) + let mut syn = segment(false, 999, &[], 0); + syn.syn = true; + let buf = sender(pool.process_tcp(syn, 1).unwrap()); assert_eq!(buf.base_sequence_number(), Some(1000)); assert!(buf.syn_observed()); - assert_eq!(pool.active_streams(), 1); + assert_eq!(pool.active_connections(), 1); - // data - let buf = stream( - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, 2) - .unwrap(), - ); + // SYN-ACK from b + let mut syn_ack = segment(true, 4999, &[], 0); + syn_ack.syn = true; + syn_ack.acknowledgment_number = Some(1000); + let buf = sender(pool.process_tcp(syn_ack, 2).unwrap()); + assert_eq!(buf.base_sequence_number(), Some(5000)); + + // data from a, acknowledged by b + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), 3) + .unwrap(); + let mut ack = segment(true, 5000, &[], 0); + ack.acknowledgment_number = Some(1008); + let buf = receiver(pool.process_tcp(ack, 4).unwrap()); assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); buf.consume(8); - // more data + FIN - let buf = stream( - pool.process_tcp(id.clone(), 1008, &sequence(8, 4), false, true, false, 3) - .unwrap(), - ); - assert_eq!(buf.contiguous(), &sequence(8, 4)[..]); + // more data + FIN from a + let payload = sequence(8, 4); + let mut fin = segment(false, 1008, &payload, 0); + fin.fin = true; + let buf = sender(pool.process_tcp(fin, 5).unwrap()); assert!(buf.is_fin_reached()); + // ... but it was not acknowledged, so only reachable unacked + assert_eq!(buf.contiguous(), &[]); + assert_eq!(buf.contiguous_unacked(), &sequence(8, 4)[..]); } #[test] fn lazy_init_without_syn() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); - // first ever segment is data (no SYN captured) - let buf = stream( - pool.process_tcp(id.clone(), 5000, &sequence(0, 4), false, false, false, ()) + let mut pool = new_pool::<()>(); + let buf = sender( + pool.process_tcp(segment(false, 5000, &sequence(0, 4), 0), ()) .unwrap(), ); assert_eq!(buf.base_sequence_number(), Some(5000)); assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); - // identifiable as "stream start not observed" assert_eq!(false, buf.syn_observed()); } #[test] - fn pure_ack_of_unknown_stream_is_ignored() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - // empty segment without SYN/FIN/RST of an unknown stream -> no stream - assert_ignored( - pool.process_tcp(ipv4_id(0), 5000, &[], false, false, false, ()) - .unwrap(), - ); - assert_eq!(pool.active_streams(), 0); + fn pure_ack_of_unknown_connection_is_ignored() { + let mut pool = new_pool::<()>(); + let mut ack = segment(false, 5000, &[], 0); + ack.acknowledgment_number = Some(1); + assert_ignored(pool.process_tcp(ack, ()).unwrap()); + assert_eq!(pool.active_connections(), 0); } #[test] - fn duplicated_syn_keeps_stream_state() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); - - // SYN + data, partially consumed - pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) - .unwrap(); - let buf = stream( - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + fn duplicated_syn_keeps_connection_state() { + let mut pool = new_pool::<()>(); + + let mut syn = segment(false, 999, &[], 0); + syn.syn = true; + pool.process_tcp(syn, ()).unwrap(); + let buf = sender( + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(), ); buf.consume(4); // a duplicate of the SYN arrives late -> state must be kept - let buf = stream( - pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) - .unwrap(), - ); + let mut syn = segment(false, 999, &[], 0); + syn.syn = true; + let buf = sender(pool.process_tcp(syn, ()).unwrap()); assert_eq!(buf.base_offset(), 4); assert_eq!(buf.contiguous(), &sequence(4, 4)[..]); - - // the stream continues seamlessly - let buf = stream( - pool.process_tcp(id.clone(), 1008, &sequence(8, 4), false, false, false, ()) - .unwrap(), - ); - assert_eq!(buf.contiguous(), &sequence(4, 8)[..]); } #[test] fn late_syn_marks_stream_start_observed() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); + let mut pool = new_pool::<()>(); - // stream anchored mid-stream (data before the SYN was seen) - let buf = stream( - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + // connection anchored mid-stream (data before the SYN was seen) + let buf = sender( + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(), ); assert_eq!(false, buf.syn_observed()); - // the SYN arrives re-ordered (isn = 999 -> data starts at 1000) - let buf = stream( - pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) - .unwrap(), - ); + // the SYN arrives re-ordered (isn 999 -> data starts at 1000) + let mut syn = segment(false, 999, &[], 0); + syn.syn = true; + let buf = sender(pool.process_tcp(syn, ()).unwrap()); assert!(buf.syn_observed()); - // buffered data was kept assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); } #[test] fn syn_with_fin_and_payload() { // TCP Fast Open SYN with payload & FIN in one segment - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let buf = stream( - pool.process_tcp(ipv4_id(0), 999, &sequence(0, 4), true, true, false, ()) - .unwrap(), - ); + let mut pool = new_pool::<()>(); + let payload = sequence(0, 4); + let mut seg = segment(false, 999, &payload, 0); + seg.syn = true; + seg.fin = true; + let buf = sender(pool.process_tcp(seg, ()).unwrap()); assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); assert!(buf.is_fin_reached()); // SYN+FIN without payload must record the FIN as well - let buf = stream( - pool.process_tcp(ipv4_id(1), 42, &[], true, true, false, ()) - .unwrap(), - ); + let mut seg = segment(false, 42, &[], 1); + seg.syn = true; + seg.fin = true; + let buf = sender(pool.process_tcp(seg, ()).unwrap()); assert_eq!(buf.fin_offset(), Some(0)); assert!(buf.is_fin_reached()); } #[test] - fn rst_closes_stream_and_recycles_on_next_call() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + fn rst_closes_both_directions_and_recycles_on_next_call() { + let mut pool = new_pool::<()>(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(); - assert_eq!(pool.active_streams(), 1); + pool.process_tcp(segment(true, 5000, &sequence(8, 4), 0), ()) + .unwrap(); + assert_eq!(pool.active_connections(), 1); assert_eq!(pool.finished_data_bufs.len(), 0); - // RST closes the stream, the not yet consumed data stays drainable - let buf = closed( - pool.process_tcp(id.clone(), 1008, &[], false, false, true, ()) - .unwrap(), + // the RST ends the whole connection, both directions stay drainable + let mut rst = segment(false, 1008, &[], 0); + rst.rst = true; + let connection = closed(pool.process_tcp(rst, ()).unwrap()); + assert_eq!( + connection.stream(A_TO_B).contiguous_unacked(), + &sequence(0, 8)[..] ); - assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); - assert_eq!(pool.active_streams(), 0); + assert_eq!( + connection.stream(A_TO_B.reverse()).contiguous_unacked(), + &sequence(8, 4)[..] + ); + assert_eq!(pool.active_connections(), 0); // not yet recycled (still drainable) assert_eq!(pool.finished_data_bufs.len(), 0); - // a RST for an unknown stream is ignored & the closed buffer of the - // previous call is recycled - assert_ignored( - pool.process_tcp(ipv4_id(1), 1, &[], false, false, true, ()) - .unwrap(), - ); - assert_eq!(pool.finished_data_bufs.len(), 1); - assert_eq!(pool.finished_section_bufs.len(), 1); + // a RST for an unknown connection is ignored & the closed connection + // of the previous call is recycled (two buffers, one per direction) + let mut rst = segment(false, 1, &[], 1); + rst.rst = true; + assert_ignored(pool.process_tcp(rst, ()).unwrap()); + assert_eq!(pool.finished_data_bufs.len(), 2); + assert_eq!(pool.finished_section_bufs.len(), 2); + } - // buffers get re-used for the next stream - pool.process_tcp(ipv4_id(1), 1, &sequence(0, 4), false, false, false, ()) + #[test] + fn rst_outside_of_the_window_is_ignored() { + let mut pool = new_pool::<()>(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(); - assert_eq!(pool.finished_data_bufs.len(), 0); - assert_eq!(pool.finished_section_bufs.len(), 0); + + // blindly injected RST with a sequence number far outside the window + let mut rst = segment(false, 0x4000_0000, &[], 0); + rst.rst = true; + assert_ignored(pool.process_tcp(rst, ()).unwrap()); + assert_eq!(pool.active_connections(), 1); + + // a stale RST (behind the read cursor) is ignored as well + let mut rst = segment(false, 900, &[], 0); + rst.rst = true; + assert_ignored(pool.process_tcp(rst, ()).unwrap()); + assert_eq!(pool.active_connections(), 1); + + // ... while a plausible one ends the connection + let mut rst = segment(false, 1008, &[], 0); + rst.rst = true; + let _ = closed(pool.process_tcp(rst, ()).unwrap()); + assert_eq!(pool.active_connections(), 0); } #[test] fn reconnect_via_syn() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + let mut pool = new_pool::<()>(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(); - // reconnect: new SYN with a fresh ISN replaces the stream; the old - // stream data stays drainable via the "Closed" event - let buf = closed( - pool.process_tcp(id.clone(), 20000, &[], true, false, false, ()) - .unwrap(), + // reconnect: a new SYN with a fresh ISN replaces the connection, the + // old data stays drainable via the "Closed" event + let mut syn = segment(false, 20000, &[], 0); + syn.syn = true; + let connection = closed(pool.process_tcp(syn, ()).unwrap()); + assert_eq!( + connection.stream(A_TO_B).contiguous_unacked(), + &sequence(0, 8)[..] ); - assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); - assert_eq!(pool.active_streams(), 1); + assert_eq!(pool.active_connections(), 1); - // the new stream is registered & re-based - let buf = pool.stream_mut(&id).unwrap(); + // the new connection is registered & re-based + let buf = pool.stream_mut(&conn_id(0), A_TO_B).unwrap(); assert_eq!(buf.base_sequence_number(), Some(20001)); assert!(buf.contiguous().is_empty()); - let buf = stream( - pool.process_tcp( - id.clone(), - 20001, - &sequence(100, 4), - false, - false, - false, - (), - ) - .unwrap(), + let buf = sender( + pool.process_tcp(segment(false, 20001, &sequence(100, 4), 0), ()) + .unwrap(), ); assert_eq!(buf.contiguous(), &sequence(100, 4)[..]); } #[test] - fn reconnect_via_syn_without_leftover() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); + fn reconnect_with_lower_isn_is_detected() { + // initial sequence numbers are random, so a reconnect is just as + // likely to pick a *lower* isn as a higher one + let mut pool = new_pool::<()>(); + + let mut syn = segment(false, 999, &[], 0); + syn.syn = true; + pool.process_tcp(syn, ()).unwrap(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) + .unwrap(); - // stream with fully consumed data - let buf = stream( - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + let mut syn = segment(false, 499, &[], 0); + syn.syn = true; + let connection = closed(pool.process_tcp(syn, ()).unwrap()); + assert_eq!( + connection.stream(A_TO_B).contiguous_unacked(), + &sequence(0, 8)[..] + ); + + // the new connection carries none of the old data + let buf = pool.stream_mut(&conn_id(0), A_TO_B).unwrap(); + assert_eq!(buf.base_sequence_number(), Some(500)); + assert!(buf.contiguous().is_empty()); + + let buf = sender( + pool.process_tcp(segment(false, 500, &sequence(100, 4), 0), ()) .unwrap(), ); - buf.consume(8); + assert_eq!(buf.contiguous(), &sequence(100, 4)[..]); + assert_eq!(buf.sections().len(), 1); + } - // reconnect with a new ISN -> nothing left to drain, so the new - // stream is returned directly & the old buffer is recycled - let buf = stream( - pool.process_tcp(id.clone(), 20000, &[], true, false, false, ()) + #[test] + fn reconnect_via_syn_without_leftover() { + let mut pool = new_pool::<()>(); + let buf = sender( + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(), ); + buf.consume(8); + + // nothing left to drain -> the new connection is returned directly & + // the old buffers are recycled + let mut syn = segment(false, 20000, &[], 0); + syn.syn = true; + let buf = sender(pool.process_tcp(syn, ()).unwrap()); assert_eq!(buf.base_sequence_number(), Some(20001)); - assert_eq!(pool.active_streams(), 1); - assert_eq!(pool.finished_data_bufs.len(), 1); + assert_eq!(pool.active_connections(), 1); + assert_eq!(pool.finished_data_bufs.len(), 2); } #[test] - fn error_on_fresh_stream_recycles_buf() { + fn error_on_fresh_connection_recycles_bufs() { let mut pool = TcpStreamReassemblyPool::<(), u16>::with_max_capacity(8); - let id = ipv4_id(0); - // first segment already exceeds the window -> error, no stream created, - // buffers recycled let err = pool - .process_tcp(id, 0, &sequence(0, 16), false, false, false, ()) + .process_tcp(segment(false, 0, &sequence(0, 16), 0), ()) .unwrap_err(); assert_eq!( err, @@ -711,19 +1117,20 @@ mod test { max_capacity: 8 } ); - assert_eq!(pool.active_streams(), 0); - assert_eq!(pool.finished_data_bufs.len(), 1); - assert_eq!(pool.finished_section_bufs.len(), 1); + assert_eq!(pool.active_connections(), 0); + // both direction buffers were returned + assert_eq!(pool.finished_data_bufs.len(), 2); + assert_eq!(pool.finished_section_bufs.len(), 2); } #[test] - fn error_on_existing_stream_keeps_it() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::with_max_capacity(8); - let id = ipv4_id(0); - pool.process_tcp(id.clone(), 0, &sequence(0, 4), false, false, false, ()) + fn error_on_existing_connection_keeps_it() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::with_max_capacity(8) + .with_ack_policy(TcpAckPolicy::Ignore); + pool.process_tcp(segment(false, 0, &sequence(0, 4), 0), ()) .unwrap(); let err = pool - .process_tcp(id.clone(), 4, &sequence(4, 16), false, false, false, ()) + .process_tcp(segment(false, 4, &sequence(4, 16), 0), ()) .unwrap_err(); assert_eq!( err, @@ -732,39 +1139,105 @@ mod test { max_capacity: 8 } ); - // the stream is retained with its previously received data - assert_eq!(pool.active_streams(), 1); - let buf = pool.stream_mut(&id).unwrap(); + // the connection is retained with its previously received data + assert_eq!(pool.active_connections(), 1); + let buf = pool.stream_mut(&conn_id(0), A_TO_B).unwrap(); assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); } #[test] - fn stream_mut_and_end_stream() { - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); - assert!(pool.stream_mut(&id).is_none()); - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + fn connection_access_and_end_connection() { + let mut pool = new_pool::<()>(); + assert!(pool.connection(&conn_id(0)).is_none()); + assert!(pool.connection_mut(&conn_id(0)).is_none()); + assert!(pool.stream_mut(&conn_id(0), A_TO_B).is_none()); + + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) .unwrap(); - assert!(pool.stream_mut(&id).is_some()); - pool.end_stream(&id); - assert_eq!(pool.active_streams(), 0); - assert_eq!(pool.finished_data_bufs.len(), 1); + assert!(pool.connection(&conn_id(0)).is_some()); + assert!(pool.connection_mut(&conn_id(0)).is_some()); + assert_eq!( + pool.stream_mut(&conn_id(0), A_TO_B).unwrap().contiguous(), + &sequence(0, 8)[..] + ); + + pool.end_connection(&conn_id(0)); + assert_eq!(pool.active_connections(), 0); + assert_eq!(pool.finished_data_bufs.len(), 2); + } + + #[test] + fn is_bidirectional_detects_unpaired_directions() { + let mut pool = new_pool::<()>(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), ()) + .unwrap(); + assert_eq!( + false, + pool.connection(&conn_id(0)).unwrap().is_bidirectional() + ); + + // even a pure ACK of the reverse direction anchors it + let mut ack = segment(true, 5000, &[], 0); + ack.acknowledgment_number = Some(1008); + pool.process_tcp(ack, ()).unwrap(); + assert!(pool.connection(&conn_id(0)).unwrap().is_bidirectional()); + } + + #[test] + fn vlan_ids_are_part_of_the_identity() { + let mut vlan_a = ArrayVec::::new_const(); + vlan_a.push(VlanId::try_new(10).unwrap()); + let mut vlan_b = ArrayVec::::new_const(); + vlan_b.push(VlanId::try_new(20).unwrap()); + + let payload_a = sequence(0, 8); + let payload_b = sequence(8, 4); + + // the two directions are tagged differently -> two separate + // connections, each containing only one direction + let mut pool = new_pool::<()>(); + let mut seg = segment(false, 1000, &payload_a, 0); + seg.vlan_ids = vlan_a.clone(); + pool.process_tcp(seg, ()).unwrap(); + let mut seg = segment(true, 5000, &payload_b, 0); + seg.vlan_ids = vlan_b.clone(); + pool.process_tcp(seg, ()).unwrap(); + + assert_eq!(pool.active_connections(), 2); + for (_id, connection, _) in pool.iter_mut() { + assert_eq!(false, connection.is_bidirectional()); + } + + // clearing the vlan ids (e.g. after moving them into the channel id) + // matches the two directions up again + let mut pool = new_pool::<()>(); + let mut seg = segment(false, 1000, &payload_a, 0); + seg.vlan_ids.clear(); + pool.process_tcp(seg, ()).unwrap(); + let mut seg = segment(true, 5000, &payload_b, 0); + seg.vlan_ids.clear(); + pool.process_tcp(seg, ()).unwrap(); + + assert_eq!(pool.active_connections(), 1); + assert!(pool.connection(&conn_id(0)).unwrap().is_bidirectional()); } #[test] fn iter_mut_drains_leftovers() { - let mut pool = TcpStreamReassemblyPool::::new(); - pool.process_tcp(ipv4_id(0), 1000, &sequence(0, 8), false, false, false, 1) + let mut pool = new_pool::(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), 1) .unwrap(); - pool.process_tcp(ipv4_id(1), 2000, &sequence(8, 4), false, false, false, 2) + pool.process_tcp(segment(false, 2000, &sequence(8, 4), 1), 2) .unwrap(); - // e.g. at the end of a capture: collect the data of all streams + // e.g. at the end of a capture: collect the data of all connections let mut collected = Vec::new(); - for (id, buf, timestamp) in pool.iter_mut() { - let len = buf.contiguous().len(); - collected.push((id.channel_id, buf.contiguous().to_vec(), *timestamp)); - buf.consume(len); + for (id, connection, timestamp) in pool.iter_mut() { + let channel_id = *id.channel_id(); + let buf = connection.stream_mut(A_TO_B); + let len = buf.contiguous_unacked().len(); + collected.push((channel_id, buf.contiguous_unacked().to_vec(), *timestamp)); + buf.consume_unacked(len); } collected.sort(); assert_eq!( @@ -775,114 +1248,159 @@ mod test { #[test] fn retain_evicts_and_recycles() { - let mut pool = TcpStreamReassemblyPool::::new(); - pool.process_tcp(ipv4_id(0), 1000, &sequence(0, 8), false, false, false, 1) + let mut pool = new_pool::(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), 1) .unwrap(); - pool.process_tcp(ipv4_id(1), 1000, &sequence(0, 8), false, false, false, 2) + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 1), 2) .unwrap(); - assert_eq!(pool.active_streams(), 2); + assert_eq!(pool.active_connections(), 2); // no-op retain pool.retain(|_, ts| *ts > 0); - assert_eq!(pool.active_streams(), 2); + assert_eq!(pool.active_connections(), 2); - // evict timestamp 1 (the stream id is passed to the predicate too) + // evict timestamp 1 (the connection id is passed to the predicate too) pool.retain(|id, ts| { - assert_eq!(id.destination_port, 80); + assert_eq!(id.second().port, 80); *ts > 1 }); - assert_eq!(pool.active_streams(), 1); - assert_eq!(pool.finished_data_bufs.len(), 1); - assert_eq!(pool.finished_section_bufs.len(), 1); - assert!(pool.stream_mut(&ipv4_id(1)).is_some()); + assert_eq!(pool.active_connections(), 1); + assert_eq!(pool.finished_data_bufs.len(), 2); + assert_eq!(pool.finished_section_bufs.len(), 2); + assert!(pool.connection(&conn_id(1)).is_some()); } #[test] - fn non_tcp_and_process_sliced_packet() { - let mut pool = TcpStreamReassemblyPool::<(), ()>::new(); + fn free_lists_are_bounded() { + // evicting many connections must not make the pool hold on to the + // buffers of every one of them forever + let payload = sequence(0, 64); + let mut pool = new_pool::(); + for channel_id in 0..200u16 { + pool.process_tcp(segment(false, 1000, &payload, channel_id), 1) + .unwrap(); + } + assert_eq!(pool.active_connections(), 200); - // empty sliced packet -> Ignored - let empty = SlicedPacket { - link: None, - link_exts: ArrayVec::new_const(), - net: None, - transport: None, - }; - assert_ignored(pool.process_sliced_packet(&empty, (), ()).unwrap()); + pool.retain(|_, _| false); + assert_eq!(pool.active_connections(), 0); - // build a real ethernet/ipv4/tcp packet and feed it - let payload = sequence(0, 8); - let pdata = build_ipv4_tcp_packet(1000, false, false, false, &payload); - let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); - let buf = stream(pool.process_sliced_packet(&slice, (), ()).unwrap()); - assert_eq!(buf.contiguous(), &payload[..]); - assert_eq!(pool.active_streams(), 1); + assert_eq!(pool.finished_data_bufs.len(), DEFAULT_MAX_TCP_POOLED_BUFS); + assert_eq!( + pool.finished_section_bufs.len(), + DEFAULT_MAX_TCP_POOLED_BUFS + ); - // RST via a sliced packet closes the stream - let pdata = build_ipv4_tcp_packet(1008, false, false, true, &[]); - let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); - let buf = closed(pool.process_sliced_packet(&slice, (), ()).unwrap()); - assert_eq!(buf.contiguous(), &payload[..]); - assert_eq!(pool.active_streams(), 0); + // the pooled buffers are still re-used + pool.process_tcp(segment(false, 1000, &payload, 0), 1) + .unwrap(); + assert_eq!( + pool.finished_data_bufs.len(), + DEFAULT_MAX_TCP_POOLED_BUFS - 2 + ); } #[test] - fn reconnect_with_lower_isn_is_detected() { - // initial sequence numbers are random, so a reconnect is just as - // likely to pick a *lower* isn as a higher one - let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); - let id = ipv4_id(0); + fn oversized_bufs_are_shrunk_before_pooling() { + let mut pool = TcpStreamReassemblyPool::<(), u16>::new() + .with_ack_policy(TcpAckPolicy::Ignore) + .with_pool_limits(8, 1024); + + // a stream that buffers far more than the pooled buffer limit + let payload = sequence(0, 16 * 1024); + pool.process_tcp(segment(false, 1000, &payload, 0), ()) + .unwrap(); + pool.end_connection(&conn_id(0)); + + // the memory of the burst is released instead of being held forever + assert_eq!(pool.finished_data_bufs.len(), 2); + for buf in &pool.finished_data_bufs { + assert!( + buf.capacity() <= 1024, + "oversized buffer kept: {}", + buf.capacity() + ); + } + } + + #[test] + fn max_connections_limit() { + let payload = sequence(0, 4); + let mut pool = new_pool::<()>().with_max_connections(2); + assert_eq!(pool.max_connections(), Some(2)); - pool.process_tcp(id.clone(), 999, &[], true, false, false, ()) + pool.process_tcp(segment(false, 1000, &payload, 0), ()) .unwrap(); - pool.process_tcp(id.clone(), 1000, &sequence(0, 8), false, false, false, ()) + pool.process_tcp(segment(false, 1000, &payload, 1), ()) .unwrap(); + assert_eq!(pool.active_connections(), 2); - // reconnect: the old stream is closed & stays drainable - let buf = closed( - pool.process_tcp(id.clone(), 499, &[], true, false, false, ()) + // a third connection is rejected ... + let err = pool + .process_tcp(segment(false, 1000, &payload, 2), ()) + .unwrap_err(); + assert_eq!( + err, + TcpReassembleError::TooManyConnections { max_connections: 2 } + ); + assert_eq!(pool.active_connections(), 2); + + // ... while the already tracked ones keep working + let buf = sender( + pool.process_tcp(segment(false, 1004, &sequence(4, 4), 0), ()) .unwrap(), ); assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); - // the new stream is re-based and carries none of the old data - let buf = pool.stream_mut(&id).unwrap(); - assert_eq!(buf.base_sequence_number(), Some(500)); - assert!(buf.contiguous().is_empty()); + // evicting makes room again + pool.retain(|id, _| *id.channel_id() != 0); + assert_eq!(pool.active_connections(), 1); + pool.process_tcp(segment(false, 1000, &payload, 2), ()) + .unwrap(); + assert_eq!(pool.active_connections(), 2); + } - let buf = stream( - pool.process_tcp(id.clone(), 500, &sequence(100, 4), false, false, false, ()) - .unwrap(), + #[test] + fn unlimited_connections_by_default() { + let pool = TcpStreamReassemblyPool::<(), ()>::new(); + assert_eq!(pool.max_connections(), None); + assert_eq!(pool.max_pooled_bufs, DEFAULT_MAX_TCP_POOLED_BUFS); + assert_eq!( + pool.max_pooled_buf_capacity, + DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY ); - assert_eq!(buf.contiguous(), &sequence(100, 4)[..]); - assert_eq!(buf.sections().len(), 1); } #[test] - fn free_lists_are_bounded() { - // evicting many streams must not make the pool hold on to one buffer - // per evicted stream forever - let mut pool = TcpStreamReassemblyPool::::new(); - for port in 0..200u16 { - pool.process_tcp(ipv4_id(port), 1000, &sequence(0, 64), false, false, false, 1) - .unwrap(); - } - assert_eq!(pool.active_streams(), 200); + fn non_tcp_and_process_sliced_packet() { + let mut pool = TcpStreamReassemblyPool::<(), ()>::new().with_ack_policy(TcpAckPolicy::Ignore); - pool.retain(|_, _| false); - assert_eq!(pool.active_streams(), 0); + // empty sliced packet -> Ignored + let empty = SlicedPacket { + link: None, + link_exts: ArrayVec::new_const(), + net: None, + transport: None, + }; + assert_ignored(pool.process_sliced_packet(&empty, (), ()).unwrap()); - // limit picked in phase 5 (max_pooled_bufs), 64 is an upper bound - assert!( - pool.finished_data_bufs.len() <= 64, - "unbounded data buf free list: {}", - pool.finished_data_bufs.len() - ); - assert!( - pool.finished_section_bufs.len() <= 64, - "unbounded section buf free list: {}", - pool.finished_section_bufs.len() + // build a real ethernet/ipv4/tcp packet and feed it + let payload = sequence(0, 8); + let pdata = build_ipv4_tcp_packet(1000, false, false, false, &payload); + let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); + let buf = sender(pool.process_sliced_packet(&slice, (), ()).unwrap()); + assert_eq!(buf.contiguous(), &payload[..]); + assert_eq!(pool.active_connections(), 1); + + // RST via a sliced packet closes the connection + let pdata = build_ipv4_tcp_packet(1008, false, false, true, &[]); + let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); + let connection = closed(pool.process_sliced_packet(&slice, (), ()).unwrap()); + assert_eq!( + connection.stream(A_TO_B).contiguous_unacked(), + &payload[..] ); + assert_eq!(pool.active_connections(), 0); } fn build_ipv4_tcp_packet(seq: u32, syn: bool, fin: bool, rst: bool, payload: &[u8]) -> Vec { From e30fdc3c49180797c04335a8b75e9e2fb6672dbe Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Tue, 4 Aug 2026 15:38:37 +0200 Subject: [PATCH 5/8] Polish the TCP reassembly API & add property tests --- changelog.md | 4 +- etherparse/examples/tcp_reassembly.rs | 52 +-- .../tcp_stream_reassembly_pool.txt | 7 + etherparse/src/tcp_reassembly/mod.rs | 4 +- .../src/tcp_reassembly/tcp_connection.rs | 17 + .../src/tcp_reassembly/tcp_segment_info.rs | 52 +++ ...p_segment_range.rs => tcp_stream_range.rs} | 18 +- .../tcp_stream_reassembly_buf.rs | 332 +++++++++++++++--- .../tcp_stream_reassembly_pool.rs | 289 ++++++++++++++- 9 files changed, 689 insertions(+), 86 deletions(-) create mode 100644 etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_pool.txt rename etherparse/src/tcp_reassembly/{tcp_segment_range.rs => tcp_stream_range.rs} (89%) diff --git a/changelog.md b/changelog.md index e31f0867..31f598f3 100644 --- a/changelog.md +++ b/changelog.md @@ -7,12 +7,14 @@ * `TcpStreamReassemblyBuf::add_segment` additionally interprets the `SYN` flag (establishing the stream start, recognizing duplicated, re-transmitted & late SYNs and reporting the SYN of a new connection re-using the same ports via `TcpSegmentOutcome::NewConnection`), while `add` just takes the payload of a segment. * Bytes that are not part of the stream are dropped instead of being handed out: data before the read cursor, data behind a received `FIN` and data in front of a *known* stream start. Overlapping segments carrying differing content are resolved deterministically ("first writer wins") so a reassembly cannot be de-synchronized from the actual receiver by overlapping re-transmits. * `TcpStreamReassemblyBuf::skip_gap` gives up on segments that were lost and never re-transmitted (e.g. dropped by the capture) by advancing the read cursor to the next received data section, which would otherwise stall the stream forever. + * `TcpStreamReassemblyBuf::drain` (and `drain_unacked`) hand the available data to a closure and consume the number of bytes it reports as processed, so a parser that can only handle complete messages can keep the remainder buffered. The raw buffer behind the reconstruction (including the zero filled gaps of not yet received ranges) stays accessible via `raw_buffer` together with `sections`. * By default (`TcpAckPolicy::Required`) only data that the receiver acknowledged is handed out by `contiguous`, so segments that never reached the receiver (lost or rejected after the point of capture) do not end up in the reconstructed stream. The acknowledgment numbers travel in the opposite direction of the data and are fed via `TcpStreamReassemblyBuf::add_ack` (stale & nonsensical acknowledgments are ignored). As a capture usually ends before the last data is acknowledged, the trailing unacknowledged bytes stay reachable via `contiguous_unacked` & `consume_unacked`, `ack_observed` identifies streams that never saw an acknowledgment (e.g. one-way captures) and `TcpAckPolicy::Ignore` disables the behavior entirely. * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many connections in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). Both directions of a connection are tracked together as a `TcpConnection`, which is what allows the acknowledgment numbers of one direction to release the data of the other one. It interprets the TCP control flags automatically (`SYN` establishes/replaces a connection — duplicated & late SYNs are recognized and keep the buffered state, `ACK` releases the data of the reverse direction, `FIN` marks the stream end, `RST` ends the whole connection) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Segment` / `Closed`, where `Segment` exposes both the stream the payload was added to and the stream the segment acknowledged, and `Closed` still exposes never consumed leftover data). Connections can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. * A `RST` carrying a sequence number outside of the tracked window is ignored, so a blindly injected `RST` cannot tear down a connection. + * `TcpConnection::is_closed` reports connections whose both directions received everything up to their `FIN` and `TcpConnection::is_bidirectional` identifies connections for which only one direction was observed (which under `TcpAckPolicy::Required` never hand out data). Packets parsed without length & consistency checks can be fed via `TcpStreamReassemblyPool::process_lax_sliced_packet` / `TcpSegmentInfo::from_lax_sliced_packet`. * Connections are identified by a `TcpConnectionId` (VLAN ids, the two `TcpEndpoint`s in a canonical order and a custom channel id) together with a `TcpDirection`, so both directions of a connection map to the same identifier. `TcpSegmentInfo::from_sliced_packet` extracts everything the reassembly needs from a parsed packet and can be modified before being passed to `process_tcp` (e.g. clearing the VLAN ids and moving them into the channel id if the two directions of a connection are tagged differently). * Per-stream memory is bounded via a configurable `max_capacity` (buffered bytes ahead of the read cursor) and `max_sections` (tracked non-contiguous ranges), returning `TcpReassembleError` when a limit would be exceeded (the buffer is left unchanged in that case). The buffers kept around for re-use are bounded in count & size via `TcpStreamReassemblyPool::with_pool_limits` (so a burst of connections or a single big stream does not make the pool hold on to the memory forever) and the number of parallel connections can be limited via `TcpStreamReassemblyPool::with_max_connections` (`TcpReassembleError::TooManyConnections`). - * Supporting types `TcpConnection`, `TcpConnectionId`, `TcpDirection`, `TcpEndpoint`, `TcpSegmentInfo`, `TcpSegmentRange`, `TcpSegmentOutcome`, `TcpAckPolicy`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY`, `DEFAULT_MAX_TCP_STREAM_SECTIONS`, `DEFAULT_MAX_TCP_POOLED_BUFS` & `DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY` constants, plus a `tcp_reassembly` example. + * Supporting types `TcpConnection`, `TcpConnectionId`, `TcpDirection`, `TcpEndpoint`, `TcpSegmentInfo`, `TcpStreamRange`, `TcpSegmentOutcome`, `TcpAckPolicy`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY`, `DEFAULT_MAX_TCP_STREAM_SECTIONS`, `DEFAULT_MAX_TCP_POOLED_BUFS` & `DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY` constants, plus a `tcp_reassembly` example. * Added zero-copy IGMP support (IGMPv1, IGMPv2 & IGMPv3): * `IgmpSlice`, an enum with one zero-copy variant per message type (`MembershipQuery`, `MembershipQueryWithSources`, `MembershipReportV1`, `MembershipReportV2`, `MembershipReportV3`, `LeaveGroup` & `Unknown`), plus common accessors (`header`, `payload`, `slice`, `checksum`, `is_checksum_valid`). diff --git a/etherparse/examples/tcp_reassembly.rs b/etherparse/examples/tcp_reassembly.rs index 1700c0ea..21d492bc 100644 --- a/etherparse/examples/tcp_reassembly.rs +++ b/etherparse/examples/tcp_reassembly.rs @@ -80,24 +80,27 @@ fn main() { // acknowledgment number can release data of the "receiver" // stream, so both are worth draining. for stream in [sender, receiver] { - let available = stream.contiguous(); - if false == available.is_empty() { - println!( - "acknowledged data{}: {:?}", - if stream.syn_observed() { - "" - } else { - " (stream start not observed, prefix missing)" - }, - core::str::from_utf8(available).unwrap_or("") - ); + let syn_observed = stream.syn_observed(); + // `drain` hands over the available data and consumes + // whatever the closure reports as processed (returning + // less keeps the rest buffered) + stream.drain(|available| { + if false == available.is_empty() { + println!( + "acknowledged data{}: {:?}", + if syn_observed { + "" + } else { + " (stream start not observed, prefix missing)" + }, + core::str::from_utf8(available).unwrap_or("") + ); + } // ... process the data here ... - // "clean" the processed bytes so the memory is freed - let len = available.len(); - stream.consume(len); - } + available.len() + }); } } Ok(TcpReassemblyEvent::Closed(connection)) => { @@ -136,16 +139,15 @@ fn main() { ); } for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { - let stream = connection.stream_mut(direction); - let leftover = stream.contiguous_unacked(); - if false == leftover.is_empty() { - println!( - "unacknowledged data at end of capture: {:?}", - core::str::from_utf8(leftover).unwrap_or("") - ); - let len = leftover.len(); - stream.consume_unacked(len); - } + connection.stream_mut(direction).drain_unacked(|leftover| { + if false == leftover.is_empty() { + println!( + "unacknowledged data at end of capture: {:?}", + core::str::from_utf8(leftover).unwrap_or("") + ); + } + leftover.len() + }); } } } diff --git a/etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_pool.txt b/etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_pool.txt new file mode 100644 index 00000000..8fc52fa8 --- /dev/null +++ b/etherparse/proptest-regressions/tcp_reassembly/tcp_stream_reassembly_pool.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6006427d34f40c39daf055aea9b44309400f5a2a0444ff1cd6b7a12ae2582b49 # shrinks to first = [0], second = [0], first_isn = 2709001195, second_isn = 561517547 diff --git a/etherparse/src/tcp_reassembly/mod.rs b/etherparse/src/tcp_reassembly/mod.rs index 37887960..189f645a 100644 --- a/etherparse/src/tcp_reassembly/mod.rs +++ b/etherparse/src/tcp_reassembly/mod.rs @@ -7,8 +7,8 @@ pub use tcp_reassemble_error::*; mod tcp_segment_outcome; pub use tcp_segment_outcome::*; -mod tcp_segment_range; -pub use tcp_segment_range::*; +mod tcp_stream_range; +pub use tcp_stream_range::*; mod tcp_connection; pub use tcp_connection::*; diff --git a/etherparse/src/tcp_reassembly/tcp_connection.rs b/etherparse/src/tcp_reassembly/tcp_connection.rs index 9fb03466..71c5fcf6 100644 --- a/etherparse/src/tcp_reassembly/tcp_connection.rs +++ b/etherparse/src/tcp_reassembly/tcp_connection.rs @@ -84,6 +84,23 @@ impl TcpConnection { && self.is_direction_observed(TcpDirection::SecondToFirst) } + /// True once **both** directions received all data up to their `FIN`, + /// i.e. the connection was closed gracefully and nothing is missing. + /// + /// A connection is not removed from a + /// [`TcpStreamReassemblyPool`] automatically when this becomes true (the + /// data still has to be drained). Use it to decide when to call + /// [`TcpStreamReassemblyPool::end_connection`] or as part of a + /// [`TcpStreamReassemblyPool::retain`] predicate. + /// + /// Note that this is about *reception* and ignores the + /// [`TcpAckPolicy`], see + /// [`TcpStreamReassemblyBuf::is_fin_reached`]. + #[inline] + pub fn is_closed(&self) -> bool { + self.first_to_second.is_fin_reached() && self.second_to_first.is_fin_reached() + } + /// True if either direction still holds in-order data that was not /// consumed yet (ignoring the [`TcpAckPolicy`]). #[inline] diff --git a/etherparse/src/tcp_reassembly/tcp_segment_info.rs b/etherparse/src/tcp_reassembly/tcp_segment_info.rs index 41866b9d..bf4f1dc4 100644 --- a/etherparse/src/tcp_reassembly/tcp_segment_info.rs +++ b/etherparse/src/tcp_reassembly/tcp_segment_info.rs @@ -92,6 +92,58 @@ impl<'a, CustomChannelId> TcpSegmentInfo<'a, CustomChannelId> { rst: tcp.rst(), }) } + + /// Extracts the values relevant for the reassembly from a laxly sliced + /// packet. + /// + /// Returns `None` if the packet does not contain a TCP segment with an IP + /// header. + /// + /// Note that a [`LaxSlicedPacket`] may have stopped parsing because of an + /// error (see [`LaxSlicedPacket::stop_err`]) and that its lengths are not + /// validated, so the payload of a truncated packet is shorter than the + /// sender intended. Feeding such a segment inserts the truncated data at + /// its sequence number, which leaves a gap that is only filled if the + /// data arrives again. + pub fn from_lax_sliced_packet( + slice: &'a LaxSlicedPacket, + channel_id: CustomChannelId, + ) -> Option> { + use core::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { + return None; + }; + + let (source_ip, destination_ip) = match &slice.net { + Some(LaxNetSlice::Ipv4(v4)) => ( + IpAddr::V4(Ipv4Addr::from(v4.header().source())), + IpAddr::V4(Ipv4Addr::from(v4.header().destination())), + ), + Some(LaxNetSlice::Ipv6(v6)) => ( + IpAddr::V6(Ipv6Addr::from(v6.header().source())), + IpAddr::V6(Ipv6Addr::from(v6.header().destination())), + ), + Some(LaxNetSlice::Arp(_)) | None => return None, + }; + + Some(TcpSegmentInfo { + source: TcpEndpoint::new(source_ip, tcp.source_port()), + destination: TcpEndpoint::new(destination_ip, tcp.destination_port()), + vlan_ids: slice.vlan_ids(), + channel_id, + sequence_number: tcp.sequence_number(), + acknowledgment_number: if tcp.ack() { + Some(tcp.acknowledgment_number()) + } else { + None + }, + payload: tcp.payload(), + syn: tcp.syn(), + fin: tcp.fin(), + rst: tcp.rst(), + }) + } } #[cfg(test)] diff --git a/etherparse/src/tcp_reassembly/tcp_segment_range.rs b/etherparse/src/tcp_reassembly/tcp_stream_range.rs similarity index 89% rename from etherparse/src/tcp_reassembly/tcp_segment_range.rs rename to etherparse/src/tcp_reassembly/tcp_stream_range.rs index 270343c7..50f1c586 100644 --- a/etherparse/src/tcp_reassembly/tcp_segment_range.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_range.rs @@ -4,27 +4,27 @@ /// [`crate::tcp_reassembly::TcpStreamReassemblyBuf`] (a 64 bit, non-wrapping /// position within the stream), not raw 32 bit TCP sequence numbers. #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Ord, PartialOrd)] -pub struct TcpSegmentRange { +pub struct TcpStreamRange { /// Absolute offset of the first byte of the section. pub start: u64, /// Absolute offset one past the last byte of the section (offset + length). pub end: u64, } -impl TcpSegmentRange { +impl TcpStreamRange { /// Return if the value is contained within the section. fn is_value_connected(&self, value: u64) -> bool { self.start <= value && self.end >= value } /// Combine both sections if they overlap or are directly adjacent. - pub fn merge(&self, other: TcpSegmentRange) -> Option { + pub fn merge(&self, other: TcpStreamRange) -> Option { if self.is_value_connected(other.start) || self.is_value_connected(other.end) || other.is_value_connected(self.start) || other.is_value_connected(self.end) { - Some(TcpSegmentRange { + Some(TcpStreamRange { start: core::cmp::min(self.start, other.start), end: core::cmp::max(self.end, other.end), }) @@ -41,7 +41,7 @@ mod test { #[test] fn debug_clone_eq() { - let section = TcpSegmentRange { start: 1, end: 2 }; + let section = TcpStreamRange { start: 1, end: 2 }; let _ = format!("{:?}", section); assert_eq!(section, section.clone()); assert_eq!(section.cmp(§ion), core::cmp::Ordering::Equal); @@ -67,7 +67,7 @@ mod test { #[test] fn is_value_connected() { - let s = TcpSegmentRange { start: 5, end: 9 }; + let s = TcpStreamRange { start: 5, end: 9 }; assert_eq!(false, s.is_value_connected(3)); assert_eq!(false, s.is_value_connected(4)); assert!(s.is_value_connected(5)); @@ -99,15 +99,15 @@ mod test { ), ]; for t in tests { - let a = TcpSegmentRange { + let a = TcpStreamRange { start: t.0 .0, end: t.0 .1, }; - let b = TcpSegmentRange { + let b = TcpStreamRange { start: t.1 .0, end: t.1 .1, }; - let expected = t.2.map(|v| TcpSegmentRange { + let expected = t.2.map(|v| TcpStreamRange { start: v.0, end: v.1, }); diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs index e797f9c0..8eff07db 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs @@ -85,7 +85,7 @@ pub struct TcpStreamReassemblyBuf { /// /// Sorted by start offset, the ranges are non-overlapping and /// non-adjacent (they get merged). - sections: Vec, + sections: Vec, /// Absolute stream offset one past the last byte of the stream (set once a /// segment with the FIN flag was received). @@ -126,7 +126,7 @@ impl TcpStreamReassemblyBuf { /// [`TcpStreamReassemblyBuf::with_max_sections`]). pub fn new( mut data: Vec, - mut sections: Vec, + mut sections: Vec, max_capacity: usize, ) -> TcpStreamReassemblyBuf { data.clear(); @@ -177,16 +177,26 @@ impl TcpStreamReassemblyBuf { self.base_offset } - /// Buffered bytes starting at the read cursor (including not yet - /// contiguous, zeroed gaps). + /// Raw buffer starting at the read cursor. + /// + /// **This is not the reconstructed stream.** Byte ranges that were not + /// received yet are present as zeroes, so reading this without consulting + /// [`TcpStreamReassemblyBuf::sections`] silently mixes fabricated zero + /// bytes into the data. Use [`TcpStreamReassemblyBuf::contiguous`] to get + /// the reconstructed stream. + /// + /// It is exposed to allow reading data that was received out of order: + /// the byte at absolute stream offset `o` of a received section is at + /// `raw_buffer()[o - base_offset()]`. #[inline] - pub fn data(&self) -> &[u8] { + pub fn raw_buffer(&self) -> &[u8] { &self.data[self.head..] } - /// Filled ranges (in absolute stream offsets), sorted by start offset. + /// Received ranges (in absolute stream offsets), sorted by start offset, + /// non-overlapping & non-adjacent. #[inline] - pub fn sections(&self) -> &[TcpSegmentRange] { + pub fn sections(&self) -> &[TcpStreamRange] { &self.sections } @@ -739,7 +749,7 @@ impl TcpStreamReassemblyBuf { self.data[data_start + from..data_end].copy_from_slice(&p[from..]); } - self.insert_section(TcpSegmentRange { start, end }); + self.insert_section(TcpStreamRange { start, end }); } Ok(()) @@ -780,7 +790,7 @@ impl TcpStreamReassemblyBuf { /// Insert a filled range into the sorted section list (merging it with /// overlapping or directly adjacent sections). - fn insert_section(&mut self, mut range: TcpSegmentRange) { + fn insert_section(&mut self, mut range: TcpStreamRange) { // sections connected to the new range (sections are sorted by start // & disjoint, so they are also sorted by end) let lo = self.sections.partition_point(|s| s.end < range.start); @@ -882,6 +892,48 @@ impl TcpStreamReassemblyBuf { self.consume_clamped(core::cmp::min(len, self.in_order_len())); } + /// Hands the available in-order data to `f` and consumes the number of + /// bytes it reports as processed. + /// + /// Convenience for the "read [`TcpStreamReassemblyBuf::contiguous`], then + /// [`TcpStreamReassemblyBuf::consume`] what was processed" cycle, which + /// cannot be written as a single expression because the data is borrowed + /// from the buffer. Returning less than `bytes.len()` keeps the remainder + /// buffered, which is what a parser that can only handle complete + /// messages wants: + /// + /// ``` + /// # use etherparse::tcp_reassembly::*; + /// # let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 4096) + /// # .with_ack_policy(TcpAckPolicy::Ignore); + /// # buf.add(1000, b"ab\ncd", false).unwrap(); + /// // only consume up to the last complete line + /// let consumed = buf.drain(|bytes| match bytes.iter().rposition(|b| *b == b'\n') { + /// Some(end) => { + /// // ... process &bytes[..end + 1] ... + /// end + 1 + /// } + /// None => 0, + /// }); + /// assert_eq!(consumed, 3); + /// ``` + /// + /// Returns the number of consumed bytes (clamped to what was available). + pub fn drain usize>(&mut self, f: F) -> usize { + let len = core::cmp::min(f(self.contiguous()), self.contiguous_len()); + self.consume_clamped(len); + len + } + + /// Like [`TcpStreamReassemblyBuf::drain`] but **ignoring** the + /// [`TcpAckPolicy`] (operates on + /// [`TcpStreamReassemblyBuf::contiguous_unacked`]). + pub fn drain_unacked usize>(&mut self, f: F) -> usize { + let len = core::cmp::min(f(self.contiguous_unacked()), self.in_order_len()); + self.consume_clamped(len); + len + } + /// Advance the read cursor by an already clamped `len`. fn consume_clamped(&mut self, len: usize) { if len == 0 { @@ -967,7 +1019,7 @@ impl TcpStreamReassemblyBuf { /// Consume the buffer and return the underlying buffers for re-use. #[inline] - pub fn take_bufs(self) -> (Vec, Vec) { + pub fn take_bufs(self) -> (Vec, Vec) { (self.data, self.sections) } } @@ -1036,12 +1088,12 @@ mod test { fn new_clears_bufs() { let buf = TcpStreamReassemblyBuf::new( vec![1, 2, 3], - vec![TcpSegmentRange { start: 0, end: 3 }], + vec![TcpStreamRange { start: 0, end: 3 }], 4096, ); assert_eq!(buf.base_sequence_number(), None); assert_eq!(buf.base_offset(), 0); - assert!(buf.data().is_empty()); + assert!(buf.raw_buffer().is_empty()); assert!(buf.sections().is_empty()); assert_eq!(buf.fin_offset(), None); assert_eq!(false, buf.syn_observed()); @@ -1345,7 +1397,7 @@ mod test { buf.add(0, &[0xAA; 4], false).unwrap(); buf.add(8, &[0xBB; 4], false).unwrap(); // the gap (offset 4..8) is currently zeroed in the raw buffer - assert_eq!(&buf.data()[4..8], &[0, 0, 0, 0]); + assert_eq!(&buf.raw_buffer()[4..8], &[0, 0, 0, 0]); buf.add(4, &[0xCC; 4], false).unwrap(); let mut expected = Vec::new(); expected.extend_from_slice(&[0xAA; 4]); @@ -1443,7 +1495,7 @@ mod test { buf.consume(2 * COMPACT_THRESHOLD); let consumed = 100 + 2 * COMPACT_THRESHOLD; assert_eq!(buf.contiguous(), &payload[consumed..]); - assert_eq!(buf.data().len(), payload.len() - consumed); + assert_eq!(buf.raw_buffer().len(), payload.len() - consumed); // adding & consuming afterwards still works let extra = sequence(payload.len(), 32); @@ -1504,6 +1556,66 @@ mod test { assert_section_invariants(&buf); } + #[test] + fn drain_consumes_what_was_processed() { + let mut buf = new_buf(); + buf.add(1000, &sequence(0, 8), false).unwrap(); + + // consuming only a part keeps the rest buffered + let consumed = buf.drain(|bytes| { + assert_eq!(bytes, &sequence(0, 8)[..]); + 3 + }); + assert_eq!(consumed, 3); + assert_eq!(buf.base_offset(), 3); + assert_eq!(buf.contiguous(), &sequence(3, 5)[..]); + + // consuming nothing is fine + assert_eq!(buf.drain(|_| 0), 0); + assert_eq!(buf.base_offset(), 3); + + // over-reporting is clamped to what was available + assert_eq!(buf.drain(|_| 1000), 5); + assert_eq!(buf.base_offset(), 8); + assert!(buf.contiguous().is_empty()); + } + + #[test] + fn drain_respects_the_ack_policy() { + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX); + buf.reset(1000); + buf.add(1000, &sequence(0, 8), false).unwrap(); + + // nothing acknowledged -> `drain` sees nothing + assert_eq!(buf.drain(|bytes| bytes.len()), 0); + assert_eq!(buf.base_offset(), 0); + + // ... while `drain_unacked` sees everything + assert_eq!(buf.drain_unacked(|bytes| bytes.len()), 8); + assert_eq!(buf.base_offset(), 8); + } + + #[test] + fn raw_buffer_exposes_out_of_order_data() { + let mut buf = new_buf(); + buf.add(1000, &[0xAA; 4], false).unwrap(); + // out of order segment behind a gap + buf.add(1008, &[0xBB; 4], false).unwrap(); + + // the gap is zero filled in the raw buffer & not part of `contiguous` + assert_eq!(buf.contiguous(), &[0xAA; 4]); + assert_eq!(&buf.raw_buffer()[4..8], &[0, 0, 0, 0]); + + // the received section can be located via `sections` & `base_offset` + let section = buf.sections()[1]; + assert_eq!((section.start, section.end), (8, 12)); + let base = buf.base_offset(); + assert_eq!( + &buf.raw_buffer()[(section.start - base) as usize..(section.end - base) as usize], + &[0xBB; 4] + ); + } + #[test] fn ack_gating_is_the_default() { let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), MAX); @@ -1699,36 +1811,8 @@ mod test { ) { use std::vec::Vec; - // simple deterministic xorshift RNG so no extra dependency is needed - let mut state = seed | 1; - let mut next = || { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state - }; - - // split the reference into contiguous segments - let mut segments: Vec<(usize, usize)> = Vec::new(); // (offset, len) - let mut pos = 0usize; - while pos < reference.len() { - let remaining = reference.len() - pos; - let len = 1 + (next() as usize % remaining.min(40)); - segments.push((pos, len)); - pos += len; - } - - // duplicate some segments and shuffle the send order - let mut to_send = segments.clone(); - for seg in &segments { - if next() & 1 == 0 { - to_send.push(*seg); - } - } - for i in (1..to_send.len()).rev() { - let j = next() as usize % (i + 1); - to_send.swap(i, j); - } + let mut next = xorshift(seed); + let to_send = split_and_shuffle(&reference, &mut next, true); let mut buf = new_buf_capacity(1 << 20); // model a known stream start (as if the ISN was learned from a SYN): @@ -1766,5 +1850,165 @@ mod test { prop_assert!(buf.is_fin_reached()); } } + + /// Same as `reassemble_random`, but without a known stream start: the + /// buffer has to anchor itself on whichever segment arrives first and + /// re-anchor backwards for every earlier one. + /// + /// Nothing is consumed while the segments arrive, as consuming would + /// (correctly) drop the data in front of the read cursor. + #[test] + fn reassemble_random_mid_stream_anchor( + reference in proptest::collection::vec(any::(), 1..600usize), + isn in any::(), + seed in any::(), + ) { + let mut next = xorshift(seed); + let to_send = split_and_shuffle(&reference, &mut next, true); + + let mut buf = new_buf_capacity(1 << 20); + for (offset, len) in to_send { + let seq = isn.wrapping_add(offset as u32); + let is_last = offset + len == reference.len(); + buf.add(seq, &reference[offset..offset + len], is_last).unwrap(); + + for w in buf.sections().windows(2) { + prop_assert!(w[0].end < w[1].start); + } + } + + // the stream start was never announced, but the re-anchoring kept + // every byte + prop_assert_eq!(false, buf.syn_observed()); + prop_assert_eq!(buf.contiguous(), &reference[..]); + prop_assert!(buf.is_fin_reached()); + } + + /// Feeds acknowledgments interleaved with the segments and checks that + /// nothing beyond the acknowledged offset is ever handed out. + #[test] + fn reassemble_random_with_acks( + reference in proptest::collection::vec(any::(), 1..600usize), + isn in any::(), + seed in any::(), + ) { + let mut next = xorshift(seed); + let to_send = split_and_shuffle(&reference, &mut next, true); + + let mut buf = TcpStreamReassemblyBuf::new(Vec::new(), Vec::new(), 1 << 20); + buf.reset(isn); + let mut collected: Vec = Vec::new(); + + for (offset, len) in to_send { + let seq = isn.wrapping_add(offset as u32); + let is_last = offset + len == reference.len(); + buf.add(seq, &reference[offset..offset + len], is_last).unwrap(); + + // acknowledge a random position of the stream + if next() & 1 == 0 { + let acked = next() as usize % (reference.len() + 1); + buf.add_ack(isn.wrapping_add(acked as u32)); + } + + // never hand out more than what was acknowledged + match buf.ack_offset() { + Some(ack_offset) => prop_assert!( + buf.base_offset() + buf.contiguous().len() as u64 <= ack_offset + ), + None => prop_assert!(buf.contiguous().is_empty()), + } + + if next() & 3 == 0 { + let avail = buf.contiguous().len(); + let take = if avail == 0 { 0 } else { next() as usize % (avail + 1) }; + collected.extend_from_slice(&buf.contiguous()[..take]); + buf.consume(take); + } + } + + // acknowledging everything releases the rest + buf.add_ack(isn.wrapping_add(reference.len() as u32)); + collected.extend_from_slice(buf.contiguous()); + let avail = buf.contiguous().len(); + buf.consume(avail); + + prop_assert_eq!(&collected, &reference); + prop_assert!(buf.is_fin_reached()); + } + + /// Re-transmits carrying *different* content must never change bytes + /// that were already received ("first writer wins"). + #[test] + fn corrupted_retransmits_do_not_change_received_data( + reference in proptest::collection::vec(any::(), 1..600usize), + isn in any::(), + seed in any::(), + ) { + let mut next = xorshift(seed); + let segments = split_and_shuffle(&reference, &mut next, false); + + let mut buf = new_buf_capacity(1 << 20); + buf.reset(isn); + + // the genuine data first (shuffled, but no duplicates) ... + for (offset, len) in &segments { + let seq = isn.wrapping_add(*offset as u32); + buf.add(seq, &reference[*offset..*offset + *len], false).unwrap(); + } + + // ... then re-transmits of the same ranges with corrupted content + for (offset, len) in &segments { + let corrupted: Vec = reference[*offset..*offset + *len] + .iter() + .map(|b| !*b) + .collect(); + let seq = isn.wrapping_add(*offset as u32); + buf.add(seq, &corrupted, false).unwrap(); + } + + prop_assert_eq!(buf.contiguous(), &reference[..]); + } + } + + /// Simple deterministic xorshift RNG so no extra dependency is needed. + fn xorshift(seed: u64) -> impl FnMut() -> u64 { + let mut state = seed | 1; + move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + } + } + + /// Splits `reference` into contiguous `(offset, len)` segments and + /// shuffles the send order (optionally duplicating some of them). + fn split_and_shuffle( + reference: &[u8], + next: &mut impl FnMut() -> u64, + duplicate: bool, + ) -> Vec<(usize, usize)> { + let mut segments: Vec<(usize, usize)> = Vec::new(); + let mut pos = 0usize; + while pos < reference.len() { + let remaining = reference.len() - pos; + let len = 1 + (next() as usize % remaining.min(40)); + segments.push((pos, len)); + pos += len; + } + + let mut to_send = segments.clone(); + if duplicate { + for seg in &segments { + if next() & 1 == 0 { + to_send.push(*seg); + } + } + } + for i in (1..to_send.len()).rev() { + let j = next() as usize % (i + 1); + to_send.swap(i, j); + } + to_send } } diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs index 8c3a1bab..4ac0b80f 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs @@ -132,7 +132,7 @@ pub struct TcpStreamReassemblyPool { finished_data_bufs: Vec>, /// Section buffers that can be re-used. - finished_section_bufs: Vec>, + finished_section_bufs: Vec>, /// Maximum number of bytes buffered ahead of the read cursor per stream. default_max_capacity: usize, @@ -157,7 +157,7 @@ pub struct TcpStreamReassemblyPool { /// Takes a buffer from the free lists (or allocates a new one). fn pop_free_buf( free_data: &mut Vec>, - free_sections: &mut Vec>, + free_sections: &mut Vec>, max_capacity: usize, max_sections: usize, ack_policy: TcpAckPolicy, @@ -179,7 +179,7 @@ fn pop_free_buf( /// rest of its lifetime. fn recycle_buf( free_data: &mut Vec>, - free_sections: &mut Vec>, + free_sections: &mut Vec>, max_pooled_bufs: usize, max_pooled_buf_capacity: usize, buf: TcpStreamReassemblyBuf, @@ -202,7 +202,7 @@ fn recycle_buf( /// Takes the two buffers of a connection from the free lists. fn pop_free_connection( free_data: &mut Vec>, - free_sections: &mut Vec>, + free_sections: &mut Vec>, max_capacity: usize, max_sections: usize, ack_policy: TcpAckPolicy, @@ -229,7 +229,7 @@ fn pop_free_connection( /// lists. fn recycle_connection( free_data: &mut Vec>, - free_sections: &mut Vec>, + free_sections: &mut Vec>, max_pooled_bufs: usize, max_pooled_buf_capacity: usize, connection: TcpConnection, @@ -365,6 +365,27 @@ where } } + /// Process a TCP segment contained in a [`LaxSlicedPacket`]. + /// + /// Behaves like [`TcpStreamReassemblyPool::process_sliced_packet`], but + /// accepts packets that were parsed without the length & consistency + /// checks. See [`TcpSegmentInfo::from_lax_sliced_packet`] for what that + /// implies for the reconstructed stream. + pub fn process_lax_sliced_packet( + &mut self, + slice: &LaxSlicedPacket, + timestamp: Timestamp, + channel_id: CustomChannelId, + ) -> Result, TcpReassembleError> { + match TcpSegmentInfo::from_lax_sliced_packet(slice, channel_id) { + Some(segment) => self.process(segment, timestamp), + None => { + self.recycle_pending_closed(); + Ok(TcpReassemblyEvent::Ignored) + } + } + } + /// Process an already parsed TCP segment (lower level entry point that /// does not require a [`SlicedPacket`] and allows customizing what /// differentiates connections). @@ -669,6 +690,8 @@ where mod test { use super::*; use arrayvec::ArrayVec; + use proptest::prelude::*; + use std::format; use std::vec::Vec; fn endpoint_a() -> TcpEndpoint { @@ -1371,6 +1394,51 @@ mod test { ); } + #[test] + fn is_closed_after_both_fins() { + let mut pool = new_pool::<()>(); + + let payload = sequence(0, 4); + let mut fin = segment(false, 1000, &payload, 0); + fin.fin = true; + pool.process_tcp(fin, ()).unwrap(); + // only one direction is done so far + assert_eq!(false, pool.connection(&conn_id(0)).unwrap().is_closed()); + + let mut fin = segment(true, 5000, &payload, 0); + fin.fin = true; + pool.process_tcp(fin, ()).unwrap(); + assert!(pool.connection(&conn_id(0)).unwrap().is_closed()); + + // the connection is not evicted automatically, the data is still there + assert_eq!(pool.active_connections(), 1); + pool.retain(|_, _| false); + assert_eq!(pool.active_connections(), 0); + } + + #[test] + fn process_lax_sliced_packet() { + let mut pool = new_pool::<()>(); + + // a laxly parsed packet is accepted as well + let payload = sequence(0, 8); + let pdata = build_ipv4_tcp_packet(1000, false, false, false, &payload); + let slice = LaxSlicedPacket::from_ethernet(&pdata).unwrap(); + let buf = sender(pool.process_lax_sliced_packet(&slice, (), 0).unwrap()); + assert_eq!(buf.contiguous(), &payload[..]); + assert_eq!(pool.active_connections(), 1); + + // a packet without a TCP layer is ignored + let empty = LaxSlicedPacket { + link: None, + link_exts: Default::default(), + net: None, + transport: None, + stop_err: None, + }; + assert_ignored(pool.process_lax_sliced_packet(&empty, (), 0).unwrap()); + } + #[test] fn non_tcp_and_process_sliced_packet() { let mut pool = TcpStreamReassemblyPool::<(), ()>::new().with_ack_policy(TcpAckPolicy::Ignore); @@ -1403,6 +1471,217 @@ mod test { assert_eq!(pool.active_connections(), 0); } + proptest! { + /// Drives a full bidirectional exchange through the pool: a handshake, + /// the client data split into randomly re-ordered & duplicated + /// segments, server acknowledgments releasing it and injected garbage + /// (out of window RSTs & pure ACKs) that must not disturb anything. + #[test] + fn pool_reassemble_random( + reference in proptest::collection::vec(any::(), 1..400usize), + client_isn in any::(), + server_isn in any::(), + seed in any::(), + ) { + // simple deterministic xorshift RNG so no extra dependency is needed + let mut state = seed | 1; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + + // split the reference into contiguous segments, duplicate some of + // them & shuffle the send order + let mut segments: Vec<(usize, usize)> = Vec::new(); + let mut pos = 0usize; + while pos < reference.len() { + let remaining = reference.len() - pos; + let len = 1 + (next() as usize % remaining.min(40)); + segments.push((pos, len)); + pos += len; + } + let mut to_send = segments.clone(); + for seg in &segments { + if next() & 1 == 0 { + to_send.push(*seg); + } + } + for i in (1..to_send.len()).rev() { + let j = next() as usize % (i + 1); + to_send.swap(i, j); + } + + // the SYN consumes one sequence number + let data_start = client_isn.wrapping_add(1); + let id = conn_id(0); + let mut pool = TcpStreamReassemblyPool::<(), u16>::new(); + let mut collected: Vec = Vec::new(); + + /// Feeds a segment & asserts the connection was not torn down. + macro_rules! feed { + ($seg:expr) => { + match pool.process_tcp($seg, ()) { + Ok(TcpReassemblyEvent::Closed(_)) => { + return Err(TestCaseError::fail("unexpected connection close")) + } + Ok(_) => {} + Err(err) => { + return Err(TestCaseError::fail(std::format!("{err}"))) + } + } + }; + } + + // handshake + let mut syn = segment(false, client_isn, &[], 0); + syn.syn = true; + feed!(syn); + let mut syn_ack = segment(true, server_isn, &[], 0); + syn_ack.syn = true; + syn_ack.acknowledgment_number = Some(data_start); + feed!(syn_ack); + + for (offset, len) in to_send { + let is_last = offset + len == reference.len(); + let mut seg = segment( + false, + data_start.wrapping_add(offset as u32), + &reference[offset..offset + len], + 0, + ); + seg.fin = is_last; + seg.acknowledgment_number = Some(server_isn.wrapping_add(1)); + feed!(seg); + + // an out of window RST must never end the connection: one far + // in the future & one behind the read cursor + if next() & 3 == 0 { + let bogus = if next() & 1 == 0 { + data_start.wrapping_add(5_000_000) + } else { + data_start.wrapping_sub(1_000) + }; + let mut rst = segment(false, bogus, &[], 0); + rst.rst = true; + feed!(rst); + } + + // the server acknowledges a random position of the stream + if next() & 1 == 0 { + let acked = next() as usize % (reference.len() + 1); + let mut ack = segment(true, server_isn.wrapping_add(1), &[], 0); + ack.acknowledgment_number = + Some(data_start.wrapping_add(acked as u32)); + feed!(ack); + } + + // drain whatever became available + if let Some(stream) = pool.stream_mut(&id, A_TO_B) { + stream.drain(|available| { + collected.extend_from_slice(available); + available.len() + }); + } + } + + // the server acknowledges everything + let mut ack = segment(true, server_isn.wrapping_add(1), &[], 0); + ack.acknowledgment_number = + Some(data_start.wrapping_add(reference.len() as u32)); + feed!(ack); + + let stream = pool.stream_mut(&id, A_TO_B).unwrap(); + stream.drain(|available| { + collected.extend_from_slice(available); + available.len() + }); + + prop_assert_eq!(&collected, &reference); + prop_assert!(stream.syn_observed()); + prop_assert!(stream.is_fin_reached()); + prop_assert_eq!(pool.active_connections(), 1); + prop_assert!(pool.connection(&id).unwrap().is_bidirectional()); + + // an in window RST does end it + let mut rst = segment( + false, + data_start.wrapping_add(reference.len() as u32), + &[], + 0, + ); + rst.rst = true; + match pool.process_tcp(rst, ()) { + Ok(TcpReassemblyEvent::Closed(_)) => {} + other => { + return Err(TestCaseError::fail(std::format!( + "expected the connection to be closed, got {other:?}" + ))) + } + } + prop_assert_eq!(pool.active_connections(), 0); + } + + /// A SYN with a new initial sequence number replaces the connection + /// without leaking any data of the previous one. + #[test] + fn pool_reconnect_random( + first in proptest::collection::vec(any::(), 1..200usize), + second in proptest::collection::vec(any::(), 1..200usize), + first_isn in any::(), + second_isn in any::(), + ) { + // the two connections have to be distinguishable + prop_assume!(first_isn != second_isn); + + let id = conn_id(0); + let mut pool = new_pool::<()>(); + + // first connection + let mut syn = segment(false, first_isn, &[], 0); + syn.syn = true; + pool.process_tcp(syn, ()).unwrap(); + pool.process_tcp( + segment(false, first_isn.wrapping_add(1), &first, 0), + (), + ) + .unwrap(); + + // reconnect with a fresh isn (which is just as likely to be lower + // as it is to be higher than the previous one) + let mut syn = segment(false, second_isn, &[], 0); + syn.syn = true; + match pool.process_tcp(syn, ()) { + Ok(TcpReassemblyEvent::Closed(connection)) => { + prop_assert_eq!( + connection.stream(A_TO_B).contiguous_unacked(), + &first[..] + ); + } + other => { + return Err(TestCaseError::fail(std::format!( + "expected the previous connection to be closed, got {other:?}" + ))) + } + } + + // the replacement carries only the data of the new connection + pool.process_tcp( + segment(false, second_isn.wrapping_add(1), &second, 0), + (), + ) + .unwrap(); + let stream = pool.stream_mut(&id, A_TO_B).unwrap(); + prop_assert_eq!( + stream.base_sequence_number(), + Some(second_isn.wrapping_add(1)) + ); + prop_assert_eq!(stream.contiguous(), &second[..]); + prop_assert_eq!(stream.sections().len(), 1); + } + } + fn build_ipv4_tcp_packet(seq: u32, syn: bool, fin: bool, rst: bool, payload: &[u8]) -> Vec { let mut tcp = TcpHeader::new(1234, 80, seq, 4096); tcp.syn = syn; From e706ad96f64f3f6b492a172aaf7bbf1699cfe38b Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Wed, 5 Aug 2026 06:59:15 +0200 Subject: [PATCH 6/8] Further iteration --- changelog.md | 8 +- etherparse/Cargo.toml | 1 + etherparse/examples/pcap_tcp_reassembly.rs | 209 +++++++++ etherparse/examples/tcp_reassembly.rs | 6 +- .../src/tcp_reassembly/tcp_segment_info.rs | 416 +++++++++++++++--- .../tcp_stream_reassembly_buf.rs | 228 ++++++++-- .../tcp_stream_reassembly_pool.rs | 149 +++++-- 7 files changed, 880 insertions(+), 137 deletions(-) create mode 100644 etherparse/examples/pcap_tcp_reassembly.rs diff --git a/changelog.md b/changelog.md index 31f598f3..5d29d0ff 100644 --- a/changelog.md +++ b/changelog.md @@ -3,18 +3,18 @@ ## Unreleased * Added TCP stream reassembly support (in the `tcp_reassembly` module, contains allocations): - * `TcpStreamReassemblyBuf` (requires the `alloc` feature), a re-usable buffer that reconstructs the payload byte stream of a single direction of a TCP connection. It is robust against retransmits, re-ordered & duplicated/overlapping segments, stale FINs, big offset jumps and 32 bit sequence number wrap-around (all bookkeeping is done in a monotonic 64 bit "absolute stream offset" space). Data is exposed in-place via `contiguous` and freed via `consume` ("collect then clean", amortized O(1) via deferred compaction). Streams whose start was never observed (capture started mid-connection) are reconstructed from the first seen segment and can be identified via `syn_observed`. + * `TcpStreamReassemblyBuf` (requires the `alloc` feature), a re-usable buffer that reconstructs the payload byte stream of a single direction of a TCP connection. It is robust against retransmits, re-ordered & duplicated/overlapping segments, stale or out-of-window FINs (which a real receiver would reject, e.g. blindly injected segments), big offset jumps and 32 bit sequence number wrap-around (all bookkeeping is done in a monotonic 64 bit "absolute stream offset" space). Data is exposed in-place via `contiguous` and freed via `consume` ("collect then clean", amortized O(1) via deferred compaction). Streams whose start was never observed (capture started mid-connection) are reconstructed from the first seen segment and can be identified via `syn_observed`. * `TcpStreamReassemblyBuf::add_segment` additionally interprets the `SYN` flag (establishing the stream start, recognizing duplicated, re-transmitted & late SYNs and reporting the SYN of a new connection re-using the same ports via `TcpSegmentOutcome::NewConnection`), while `add` just takes the payload of a segment. * Bytes that are not part of the stream are dropped instead of being handed out: data before the read cursor, data behind a received `FIN` and data in front of a *known* stream start. Overlapping segments carrying differing content are resolved deterministically ("first writer wins") so a reassembly cannot be de-synchronized from the actual receiver by overlapping re-transmits. * `TcpStreamReassemblyBuf::skip_gap` gives up on segments that were lost and never re-transmitted (e.g. dropped by the capture) by advancing the read cursor to the next received data section, which would otherwise stall the stream forever. * `TcpStreamReassemblyBuf::drain` (and `drain_unacked`) hand the available data to a closure and consume the number of bytes it reports as processed, so a parser that can only handle complete messages can keep the remainder buffered. The raw buffer behind the reconstruction (including the zero filled gaps of not yet received ranges) stays accessible via `raw_buffer` together with `sections`. * By default (`TcpAckPolicy::Required`) only data that the receiver acknowledged is handed out by `contiguous`, so segments that never reached the receiver (lost or rejected after the point of capture) do not end up in the reconstructed stream. The acknowledgment numbers travel in the opposite direction of the data and are fed via `TcpStreamReassemblyBuf::add_ack` (stale & nonsensical acknowledgments are ignored). As a capture usually ends before the last data is acknowledged, the trailing unacknowledged bytes stay reachable via `contiguous_unacked` & `consume_unacked`, `ack_observed` identifies streams that never saw an acknowledgment (e.g. one-way captures) and `TcpAckPolicy::Ignore` disables the behavior entirely. - * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many connections in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). Both directions of a connection are tracked together as a `TcpConnection`, which is what allows the acknowledgment numbers of one direction to release the data of the other one. It interprets the TCP control flags automatically (`SYN` establishes/replaces a connection — duplicated & late SYNs are recognized and keep the buffered state, `ACK` releases the data of the reverse direction, `FIN` marks the stream end, `RST` ends the whole connection) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Segment` / `Closed`, where `Segment` exposes both the stream the payload was added to and the stream the segment acknowledged, and `Closed` still exposes never consumed leftover data). Connections can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. + * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many connections in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). Both directions of a connection are tracked together as a `TcpConnection`, which is what allows the acknowledgment numbers of one direction to release the data of the other one. It interprets the TCP control flags automatically (`SYN` establishes/replaces a connection — duplicated & late SYNs are recognized and keep the buffered state, `ACK` releases the data of the reverse direction, `FIN` marks the stream end, `RST` ends the whole connection) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Segment` / `Closed`, where `Segment` exposes both the stream the payload was added to and the stream the segment acknowledged, and `Closed` still exposes never consumed leftover data; both carry the `TcpConnectionId` the connection is tracked under, which requires `Clone` on the custom channel id for the `process_*` methods). Connections can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. * A `RST` carrying a sequence number outside of the tracked window is ignored, so a blindly injected `RST` cannot tear down a connection. * `TcpConnection::is_closed` reports connections whose both directions received everything up to their `FIN` and `TcpConnection::is_bidirectional` identifies connections for which only one direction was observed (which under `TcpAckPolicy::Required` never hand out data). Packets parsed without length & consistency checks can be fed via `TcpStreamReassemblyPool::process_lax_sliced_packet` / `TcpSegmentInfo::from_lax_sliced_packet`. - * Connections are identified by a `TcpConnectionId` (VLAN ids, the two `TcpEndpoint`s in a canonical order and a custom channel id) together with a `TcpDirection`, so both directions of a connection map to the same identifier. `TcpSegmentInfo::from_sliced_packet` extracts everything the reassembly needs from a parsed packet and can be modified before being passed to `process_tcp` (e.g. clearing the VLAN ids and moving them into the channel id if the two directions of a connection are tagged differently). + * Connections are identified by a `TcpConnectionId` (VLAN ids, the two `TcpEndpoint`s in a canonical order and a custom channel id) together with a `TcpDirection`, so both directions of a connection map to the same identifier. `TcpSegmentInfo::from_sliced_packet` extracts everything the reassembly needs from a parsed packet and can be modified before being passed to `process_tcp` (e.g. clearing the VLAN ids and moving them into the channel id if the two directions of a connection are tagged differently). For segments that do not arrive as one parsable packet there are `TcpSegmentInfo::from_tcp_slice` (assembles the info from a `TcpSlice` plus the IP addresses & VLAN ids) and `TcpSegmentInfo::from_defragmented_payload` (takes the re-assembled IP payload of a fragmented packet together with any of its fragments, for combining `defrag::IpDefragPool` with the TCP reassembly). * Per-stream memory is bounded via a configurable `max_capacity` (buffered bytes ahead of the read cursor) and `max_sections` (tracked non-contiguous ranges), returning `TcpReassembleError` when a limit would be exceeded (the buffer is left unchanged in that case). The buffers kept around for re-use are bounded in count & size via `TcpStreamReassemblyPool::with_pool_limits` (so a burst of connections or a single big stream does not make the pool hold on to the memory forever) and the number of parallel connections can be limited via `TcpStreamReassemblyPool::with_max_connections` (`TcpReassembleError::TooManyConnections`). - * Supporting types `TcpConnection`, `TcpConnectionId`, `TcpDirection`, `TcpEndpoint`, `TcpSegmentInfo`, `TcpStreamRange`, `TcpSegmentOutcome`, `TcpAckPolicy`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY`, `DEFAULT_MAX_TCP_STREAM_SECTIONS`, `DEFAULT_MAX_TCP_POOLED_BUFS` & `DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY` constants, plus a `tcp_reassembly` example. + * Supporting types `TcpConnection`, `TcpConnectionId`, `TcpDirection`, `TcpEndpoint`, `TcpSegmentInfo`, `TcpStreamRange`, `TcpSegmentOutcome`, `TcpAckPolicy`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY`, `DEFAULT_MAX_TCP_STREAM_SECTIONS`, `DEFAULT_MAX_TCP_POOLED_BUFS` & `DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY` constants, plus a `tcp_reassembly` example and a `pcap_tcp_reassembly` example that reads a classic pcap file and reconstructs the TCP streams of all connections including fragmented IP packets (combining `defrag::IpDefragPool` & `TcpStreamReassemblyPool`). * Added zero-copy IGMP support (IGMPv1, IGMPv2 & IGMPv3): * `IgmpSlice`, an enum with one zero-copy variant per message type (`MembershipQuery`, `MembershipQueryWithSources`, `MembershipReportV1`, `MembershipReportV2`, `MembershipReportV3`, `LeaveGroup` & `Unknown`), plus common accessors (`header`, `payload`, `slice`, `checksum`, `is_checksum_valid`). diff --git a/etherparse/Cargo.toml b/etherparse/Cargo.toml index 27e7d03b..aa33b71a 100644 --- a/etherparse/Cargo.toml +++ b/etherparse/Cargo.toml @@ -29,6 +29,7 @@ arrayvec = { version = "0.7.2", default-features = false } [dev-dependencies] proptest = "1.4.0" +rpcap = "1.0.0" [package.metadata.docs.rs] all-features = true diff --git a/etherparse/examples/pcap_tcp_reassembly.rs b/etherparse/examples/pcap_tcp_reassembly.rs new file mode 100644 index 00000000..bcc3861a --- /dev/null +++ b/etherparse/examples/pcap_tcp_reassembly.rs @@ -0,0 +1,209 @@ +//! Reads a PCAP file, re-assembles fragmented IP packets and reconstructs +//! the payload byte streams of all TCP connections, printing the data as it +//! becomes available: +//! +//! ```sh +//! cargo run --example pcap_tcp_reassembly -- capture.pcap +//! ``` +//! +//! Only the classic `.pcap` format is supported (not `.pcapng`, convert via +//! `tshark -r capture.pcapng -F pcap -w capture.pcap`) with the link types +//! "Ethernet" and "Raw IP". To keep the example small there is no extra +//! handling for captures with a limited snapshot length (truncated packets +//! are reported as parse errors and skipped). + +use etherparse::{defrag::*, tcp_reassembly::*, *}; +use rpcap::{read::PcapReader, Linktype}; +use std::net::IpAddr; + +fn main() { + let Some(path) = std::env::args().nth(1) else { + eprintln!("Usage: pcap_tcp_reassembly "); + std::process::exit(1); + }; + let file = match std::fs::File::open(&path) { + Ok(v) => v, + Err(err) => { + eprintln!("Error opening '{path}': {err}"); + std::process::exit(1); + } + }; + let (file_options, mut pcap_reader) = + match PcapReader::new(std::io::BufReader::new(file)) { + Ok(v) => v, + Err(err) => { + eprintln!("Error parsing '{path}': {err}"); + std::process::exit(1); + } + }; + let is_ethernet = match file_options.linktype { + l if l == Linktype::ETHERNET as u32 => true, + l if l == Linktype::RAW as u32 => false, + other => { + eprintln!("Unsupported pcap link type {other}"); + std::process::exit(1); + } + }; + + // pool re-assembling fragmented IPv4 & IPv6 packets + let mut defrag_pool = IpDefragPool::<(), ()>::new(); + + // pool reconstructing the payload streams of the TCP connections (by + // default only data that the receiver acknowledged is handed out) + let mut tcp_pool = TcpStreamReassemblyPool::<(), ()>::new(); + + loop { + // the packet data is borrowed from the reader's internal buffer, so + // reading the next packet does not allocate or copy + let packet = match pcap_reader.next() { + Ok(Some(v)) => v, + Ok(None) => break, + Err(err) => { + eprintln!("Error reading pcap packet record: {err}"); + break; + } + }; + + // slice the packet into its different header components + let sliced = if is_ethernet { + SlicedPacket::from_ethernet(packet.data) + } else { + SlicedPacket::from_ip(packet.data) + }; + let sliced = match sliced { + Ok(v) => v, + Err(err) => { + eprintln!("Error parsing packet: {err}"); + continue; + } + }; + + if sliced.is_ip_payload_fragmented() { + // the TCP layer of a fragmented packet is not decoded, the IP + // payload has to be re-assembled from all fragments first + match defrag_pool.process_sliced_packet(&sliced, (), ()) { + Ok(Some(finished)) => { + if finished.ip_number == IpNumber::TCP { + // the re-assembled payload is the TCP segment, while + // the addresses are taken from the (last) fragment + match TcpSegmentInfo::from_defragmented_payload( + &sliced, + &finished.payload, + (), + ) { + Ok(Some(info)) => handle_tcp_event(tcp_pool.process_tcp(info, ())), + Ok(None) => { + // no IP header (cannot happen for a re-assembled packet) + } + Err(err) => { + eprintln!("Error parsing re-assembled TCP segment: {err}") + } + } + } + // return the buffer to avoid unneeded allocations + defrag_pool.return_buf(finished); + } + Ok(None) => { + // not all fragments received yet + } + Err(err) => eprintln!("Error re-assembling fragmented IP packet: {err}"), + } + } else { + handle_tcp_event(tcp_pool.process_sliced_packet(&sliced, (), ())); + } + } + + // At the end of a capture: drain whatever is left. The last data of a + // capture is usually never acknowledged (the capture ends before the ACK + // arrives), so the acknowledgment requirement is ignored here. + for (id, connection, _timestamp) in tcp_pool.iter_mut() { + if false == connection.is_bidirectional() { + println!( + "warning: only one direction captured for {} <-> {} (no data can be acknowledged)", + endpoint(id.first()), + endpoint(id.second()), + ); + } + for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + print_stream_data(id, direction, connection.stream_mut(direction), true); + } + } +} + +/// Prints the stream data that became available through a processed segment. +fn handle_tcp_event(event: Result, TcpReassembleError>) { + match event { + Ok(TcpReassemblyEvent::Segment { + id, + direction, + sender, + receiver, + }) => { + // the segment added its payload to "sender", while its + // acknowledgment number may have released data of "receiver" + print_stream_data(&id, direction, sender, false); + print_stream_data(&id, direction.reverse(), receiver, false); + } + Ok(TcpReassemblyEvent::Closed { id, connection }) => { + // a RST ended the connection (or a new connection replaced it): + // drain the data that was never consumed (the closing segment is + // usually not acknowledged anymore, so unacknowledged data is + // printed as well) + for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + print_stream_data(&id, direction, connection.stream_mut(direction), true); + } + println!( + "connection {} <-> {} closed", + endpoint(id.first()), + endpoint(id.second()), + ); + } + Ok(TcpReassemblyEvent::Ignored) => { + // not a TCP segment (or a segment of an unknown connection that + // carries nothing to reconstruct) + } + Err(err) => eprintln!("Error reconstructing TCP stream: {err}"), + } +} + +/// Prints & consumes the in-order data of one stream direction (including +/// the not yet acknowledged data if `include_unacked` is set). +fn print_stream_data( + id: &TcpConnectionId, + direction: TcpDirection, + stream: &mut TcpStreamReassemblyBuf, + include_unacked: bool, +) { + let print = |bytes: &[u8]| { + if false == bytes.is_empty() { + println!( + "{} -> {}{}: {:?}", + endpoint(id.source(direction)), + endpoint(id.destination(direction)), + if include_unacked { + " (incl. unacknowledged)" + } else { + "" + }, + String::from_utf8_lossy(bytes), + ); + } + // consume everything (returning less would keep the rest buffered, + // e.g. for a parser that only handles complete messages) + bytes.len() + }; + if include_unacked { + stream.drain_unacked(print); + } else { + stream.drain(print); + } +} + +/// Formats an endpoint as "ip:port". +fn endpoint(endpoint: &TcpEndpoint) -> String { + match endpoint.ip { + IpAddr::V4(ip) => format!("{}:{}", ip, endpoint.port), + IpAddr::V6(ip) => format!("[{}]:{}", ip, endpoint.port), + } +} + diff --git a/etherparse/examples/tcp_reassembly.rs b/etherparse/examples/tcp_reassembly.rs index 21d492bc..2923b026 100644 --- a/etherparse/examples/tcp_reassembly.rs +++ b/etherparse/examples/tcp_reassembly.rs @@ -103,14 +103,16 @@ fn main() { }); } } - Ok(TcpReassemblyEvent::Closed(connection)) => { + Ok(TcpReassemblyEvent::Closed { id, connection }) => { // a RST ended the connection (or a new connection replaced // it): the not yet consumed data can still be drained here for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { let leftover = connection.stream(direction).contiguous_unacked(); if false == leftover.is_empty() { println!( - "connection closed, leftover data: {:?}", + "connection {:?} <-> {:?} closed, leftover data: {:?}", + id.first(), + id.second(), core::str::from_utf8(leftover).unwrap_or("") ); } diff --git a/etherparse/src/tcp_reassembly/tcp_segment_info.rs b/etherparse/src/tcp_reassembly/tcp_segment_info.rs index bf4f1dc4..a0da6347 100644 --- a/etherparse/src/tcp_reassembly/tcp_segment_info.rs +++ b/etherparse/src/tcp_reassembly/tcp_segment_info.rs @@ -48,37 +48,48 @@ pub struct TcpSegmentInfo<'a, CustomChannelId = ()> { } impl<'a, CustomChannelId> TcpSegmentInfo<'a, CustomChannelId> { - /// Extracts the values relevant for the reassembly from a sliced packet. + /// Assembles the info from an already parsed TCP segment and the + /// addressing information of the packet that carried it. /// - /// Returns `None` if the packet does not contain a TCP segment with an IP - /// header (e.g. a non TCP packet or an IP fragment, as the TCP layer of a - /// fragmented packet is not decoded). - pub fn from_sliced_packet( - slice: &'a SlicedPacket, + /// The ports, sequence & acknowledgment numbers, flags and the payload + /// are taken from `tcp`, so only the IP addresses (and optionally the + /// VLAN ids & channel id, see [`TcpConnectionId`]) have to be supplied. + /// + /// Useful whenever the TCP segment does not arrive as one parsable packet + /// that [`TcpSegmentInfo::from_sliced_packet`] could be used on, e.g. + /// after re-assembling a fragmented IP packet (see + /// [`TcpSegmentInfo::from_defragmented_payload`] for a shortcut of that + /// case) or when the packet was parsed by something else than a + /// [`SlicedPacket`]. + /// + /// ``` + /// # use etherparse::{tcp_reassembly::*, *}; + /// # use core::net::IpAddr; + /// # let tcp_bytes = TcpHeader::new(1234, 80, 1000, 4096).to_bytes(); + /// # let source_ip: IpAddr = "1.2.3.4".parse().unwrap(); + /// # let destination_ip: IpAddr = "5.6.7.8".parse().unwrap(); + /// let tcp = TcpSlice::from_slice(&tcp_bytes).unwrap(); + /// let info = TcpSegmentInfo::from_tcp_slice( + /// &tcp, + /// source_ip, + /// destination_ip, + /// Default::default(), // vlan ids + /// (), // channel id + /// ); + /// assert_eq!(info.source, TcpEndpoint::new(source_ip, 1234)); + /// assert_eq!(info.sequence_number, 1000); + /// ``` + pub fn from_tcp_slice( + tcp: &TcpSlice<'a>, + source_ip: core::net::IpAddr, + destination_ip: core::net::IpAddr, + vlan_ids: ArrayVec, channel_id: CustomChannelId, - ) -> Option> { - use core::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - - let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { - return None; - }; - - let (source_ip, destination_ip) = match &slice.net { - Some(NetSlice::Ipv4(v4)) => ( - IpAddr::V4(Ipv4Addr::from(v4.header().source())), - IpAddr::V4(Ipv4Addr::from(v4.header().destination())), - ), - Some(NetSlice::Ipv6(v6)) => ( - IpAddr::V6(Ipv6Addr::from(v6.header().source())), - IpAddr::V6(Ipv6Addr::from(v6.header().destination())), - ), - Some(NetSlice::Arp(_)) | None => return None, - }; - - Some(TcpSegmentInfo { + ) -> TcpSegmentInfo<'a, CustomChannelId> { + TcpSegmentInfo { source: TcpEndpoint::new(source_ip, tcp.source_port()), destination: TcpEndpoint::new(destination_ip, tcp.destination_port()), - vlan_ids: slice.vlan_ids(), + vlan_ids, channel_id, sequence_number: tcp.sequence_number(), acknowledgment_number: if tcp.ack() { @@ -90,7 +101,30 @@ impl<'a, CustomChannelId> TcpSegmentInfo<'a, CustomChannelId> { syn: tcp.syn(), fin: tcp.fin(), rst: tcp.rst(), - }) + } + } + + /// Extracts the values relevant for the reassembly from a sliced packet. + /// + /// Returns `None` if the packet does not contain a TCP segment with an IP + /// header (e.g. a non TCP packet or an IP fragment, as the TCP layer of a + /// fragmented packet is not decoded, see + /// [`TcpSegmentInfo::from_defragmented_payload`] for that case). + pub fn from_sliced_packet( + slice: &'a SlicedPacket, + channel_id: CustomChannelId, + ) -> Option> { + let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { + return None; + }; + let (source_ip, destination_ip) = net_slice_ip_addrs(&slice.net)?; + Some(Self::from_tcp_slice( + tcp, + source_ip, + destination_ip, + slice.vlan_ids(), + channel_id, + )) } /// Extracts the values relevant for the reassembly from a laxly sliced @@ -109,40 +143,104 @@ impl<'a, CustomChannelId> TcpSegmentInfo<'a, CustomChannelId> { slice: &'a LaxSlicedPacket, channel_id: CustomChannelId, ) -> Option> { - use core::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - let Some(TransportSlice::Tcp(tcp)) = &slice.transport else { return None; }; + let (source_ip, destination_ip) = lax_net_slice_ip_addrs(&slice.net)?; + Some(Self::from_tcp_slice( + tcp, + source_ip, + destination_ip, + slice.vlan_ids(), + channel_id, + )) + } - let (source_ip, destination_ip) = match &slice.net { - Some(LaxNetSlice::Ipv4(v4)) => ( - IpAddr::V4(Ipv4Addr::from(v4.header().source())), - IpAddr::V4(Ipv4Addr::from(v4.header().destination())), - ), - Some(LaxNetSlice::Ipv6(v6)) => ( - IpAddr::V6(Ipv6Addr::from(v6.header().source())), - IpAddr::V6(Ipv6Addr::from(v6.header().destination())), - ), - Some(LaxNetSlice::Arp(_)) | None => return None, + /// Assembles the info of a TCP segment that had to be re-assembled from + /// IP fragments (e.g. via [`crate::defrag::IpDefragPool`]). + /// + /// `tcp_segment` is the re-assembled IP payload (the TCP header followed + /// by its payload), while the IP addresses & VLAN ids are taken from + /// `packet`. Any of the fragments can be passed as `packet` as they all + /// carry the same addresses (its transport layer is not used, as the TCP + /// layer of a fragmented packet is not decoded). + /// + /// Returns: + /// * `Ok(Some(..))` with the assembled info. + /// * `Ok(None)` if `packet` does not contain an IP header. + /// * `Err` if `tcp_segment` could not be parsed as a TCP header. + /// + /// Note that it is up to the caller to check that the re-assembled + /// payload actually is a TCP segment (compare + /// [`crate::defrag::IpDefragPayloadVec::ip_number`] against + /// [`IpNumber::TCP`]). + /// + /// ```no_run + /// # use etherparse::{defrag::*, tcp_reassembly::*, *}; + /// # let mut defrag_pool = IpDefragPool::<(), ()>::new(); + /// # let mut tcp_pool = TcpStreamReassemblyPool::<(), ()>::new(); + /// # let slice: SlicedPacket = unimplemented!(); + /// if let Ok(Some(finished)) = defrag_pool.process_sliced_packet(&slice, (), ()) { + /// if finished.ip_number == IpNumber::TCP { + /// if let Ok(Some(info)) = + /// TcpSegmentInfo::from_defragmented_payload(&slice, &finished.payload, ()) + /// { + /// tcp_pool.process_tcp(info, ()).unwrap(); + /// } + /// } + /// defrag_pool.return_buf(finished); + /// } + /// ``` + pub fn from_defragmented_payload( + packet: &SlicedPacket, + tcp_segment: &'a [u8], + channel_id: CustomChannelId, + ) -> Result>, err::tcp::HeaderSliceError> { + let Some((source_ip, destination_ip)) = net_slice_ip_addrs(&packet.net) else { + return Ok(None); }; - - Some(TcpSegmentInfo { - source: TcpEndpoint::new(source_ip, tcp.source_port()), - destination: TcpEndpoint::new(destination_ip, tcp.destination_port()), - vlan_ids: slice.vlan_ids(), + let tcp = TcpSlice::from_slice(tcp_segment)?; + Ok(Some(Self::from_tcp_slice( + &tcp, + source_ip, + destination_ip, + packet.vlan_ids(), channel_id, - sequence_number: tcp.sequence_number(), - acknowledgment_number: if tcp.ack() { - Some(tcp.acknowledgment_number()) - } else { - None - }, - payload: tcp.payload(), - syn: tcp.syn(), - fin: tcp.fin(), - rst: tcp.rst(), - }) + ))) + } +} + +/// Source & destination IP addresses of a sliced packet (`None` if it does +/// not contain an IP header). +fn net_slice_ip_addrs(net: &Option) -> Option<(core::net::IpAddr, core::net::IpAddr)> { + match net { + Some(NetSlice::Ipv4(v4)) => Some(( + v4.header().source_addr().into(), + v4.header().destination_addr().into(), + )), + Some(NetSlice::Ipv6(v6)) => Some(( + v6.header().source_addr().into(), + v6.header().destination_addr().into(), + )), + Some(NetSlice::Arp(_)) | None => None, + } +} + +/// Source & destination IP addresses of a laxly sliced packet (`None` if it +/// does not contain an IP header). +fn lax_net_slice_ip_addrs( + net: &Option, +) -> Option<(core::net::IpAddr, core::net::IpAddr)> { + match net { + Some(LaxNetSlice::Ipv4(v4)) => Some(( + v4.header().source_addr().into(), + v4.header().destination_addr().into(), + )), + Some(LaxNetSlice::Ipv6(v6)) => Some(( + v6.header().source_addr().into(), + v6.header().destination_addr().into(), + )), + Some(LaxNetSlice::Arp(_)) | None => None, } } @@ -150,6 +248,212 @@ impl<'a, CustomChannelId> TcpSegmentInfo<'a, CustomChannelId> { mod test { use super::*; use alloc::{format, vec::Vec}; + use core::net::{IpAddr, Ipv4Addr}; + + /// TCP segment bytes (header + payload) as they appear in an IP payload. + fn build_tcp_segment(ack: Option, payload: &[u8]) -> Vec { + let mut tcp = TcpHeader::new(1234, 80, 1000, 4096); + tcp.syn = true; + tcp.fin = true; + tcp.rst = true; + if let Some(ack) = ack { + tcp.ack = true; + tcp.acknowledgment_number = ack; + } + let mut result = tcp.to_bytes().to_vec(); + result.extend_from_slice(payload); + result + } + + /// Ethernet + IPv6 + TCP packet. + fn build_ipv6_packet(payload: &[u8]) -> Vec { + let tcp_bytes = build_tcp_segment(Some(555), payload); + let ipv6 = Ipv6Header { + payload_length: tcp_bytes.len() as u16, + next_header: IpNumber::TCP, + hop_limit: 4, + source: [1; 16], + destination: [2; 16], + ..Default::default() + }; + + let mut buf = Vec::new(); + buf.extend_from_slice( + &Ethernet2Header { + source: [0; 6], + destination: [0; 6], + ether_type: EtherType::IPV6, + } + .to_bytes(), + ); + buf.extend_from_slice(&ipv6.to_bytes()); + buf.extend_from_slice(&tcp_bytes); + buf + } + + /// Ethernet + IPv4 packet that is a fragment (so the TCP layer is not + /// decoded), as it is fed to a `defrag::IpDefragPool`. + fn build_ipv4_fragment(fragment_payload: &[u8]) -> Vec { + let mut ipv4 = Ipv4Header { + protocol: IpNumber::TCP, + source: [1, 2, 3, 4], + destination: [5, 6, 7, 8], + identification: 4711, + more_fragments: true, + total_len: (Ipv4Header::MIN_LEN + fragment_payload.len()) as u16, + time_to_live: 2, + ..Default::default() + }; + ipv4.header_checksum = ipv4.calc_header_checksum(); + + let mut buf = Vec::new(); + buf.extend_from_slice( + &Ethernet2Header { + source: [0; 6], + destination: [0; 6], + ether_type: EtherType::IPV4, + } + .to_bytes(), + ); + buf.extend_from_slice(&ipv4.to_bytes()); + buf.extend_from_slice(fragment_payload); + buf + } + + #[test] + fn from_tcp_slice_assembles_the_parts() { + let segment = build_tcp_segment(Some(555), &[1, 2, 3]); + let tcp = TcpSlice::from_slice(&segment).unwrap(); + + let source_ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + let destination_ip = IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8)); + let mut vlan_ids = ArrayVec::::new_const(); + vlan_ids.push(VlanId::try_new(12).unwrap()); + + let info = + TcpSegmentInfo::from_tcp_slice(&tcp, source_ip, destination_ip, vlan_ids.clone(), 7u16); + + // the ports are taken from the tcp slice & combined with the ips + assert_eq!(info.source, TcpEndpoint::new(source_ip, 1234)); + assert_eq!(info.destination, TcpEndpoint::new(destination_ip, 80)); + assert_eq!(info.vlan_ids, vlan_ids); + assert_eq!(info.channel_id, 7); + assert_eq!(info.sequence_number, 1000); + assert_eq!(info.acknowledgment_number, Some(555)); + assert_eq!(info.payload, &[1, 2, 3]); + assert!(info.syn); + assert!(info.fin); + assert!(info.rst); + + // without the ACK flag no acknowledgment number is reported + let segment = build_tcp_segment(None, &[]); + let tcp = TcpSlice::from_slice(&segment).unwrap(); + let info = + TcpSegmentInfo::from_tcp_slice(&tcp, source_ip, destination_ip, Default::default(), ()); + assert_eq!(info.acknowledgment_number, None); + assert!(info.payload.is_empty()); + } + + #[test] + fn from_defragmented_payload_ok() { + // the re-assembled TCP segment & any fragment of the packet + let segment = build_tcp_segment(Some(555), &[1, 2, 3]); + let fragment = build_ipv4_fragment(&segment[..8]); + let slice = SlicedPacket::from_ethernet(&fragment).unwrap(); + // the fragment itself carries no decoded transport layer + assert!(slice.transport.is_none()); + + let info = TcpSegmentInfo::from_defragmented_payload(&slice, &segment, 7u16) + .unwrap() + .unwrap(); + + // addresses from the fragment, everything else from the segment + assert_eq!(info.source, TcpEndpoint::from_ipv4([1, 2, 3, 4], 1234)); + assert_eq!(info.destination, TcpEndpoint::from_ipv4([5, 6, 7, 8], 80)); + assert_eq!(info.channel_id, 7); + assert_eq!(info.sequence_number, 1000); + assert_eq!(info.acknowledgment_number, Some(555)); + assert_eq!(info.payload, &[1, 2, 3]); + assert!(info.syn); + assert!(info.fin); + assert!(info.rst); + } + + #[test] + fn from_defragmented_payload_without_ip_header() { + let segment = build_tcp_segment(None, &[]); + let empty = SlicedPacket { + link: None, + link_exts: Default::default(), + net: None, + transport: None, + }; + assert_eq!( + TcpSegmentInfo::from_defragmented_payload(&empty, &segment, ()), + Ok(None) + ); + } + + #[test] + fn from_defragmented_payload_malformed_segment() { + let fragment = build_ipv4_fragment(&[1, 2, 3, 4]); + let slice = SlicedPacket::from_ethernet(&fragment).unwrap(); + + // too short to contain a TCP header + let err = TcpSegmentInfo::from_defragmented_payload(&slice, &[1, 2, 3], ()).unwrap_err(); + assert!(matches!(err, err::tcp::HeaderSliceError::Len(_))); + } + + #[test] + fn from_sliced_packet_ipv6() { + let data = build_ipv6_packet(&[1, 2, 3]); + let slice = SlicedPacket::from_ethernet(&data).unwrap(); + let info = TcpSegmentInfo::from_sliced_packet(&slice, ()).unwrap(); + assert_eq!(info.source, TcpEndpoint::from_ipv6([1; 16], 1234)); + assert_eq!(info.destination, TcpEndpoint::from_ipv6([2; 16], 80)); + assert_eq!(info.payload, &[1, 2, 3]); + } + + #[test] + fn from_lax_sliced_packet_ipv4_and_ipv6() { + // ipv4 + let data = build_packet(Some(555), &[1, 2, 3]); + let slice = LaxSlicedPacket::from_ethernet(&data).unwrap(); + let info = TcpSegmentInfo::from_lax_sliced_packet(&slice, 7u16).unwrap(); + assert_eq!(info.source, TcpEndpoint::from_ipv4([1, 2, 3, 4], 1234)); + assert_eq!(info.destination, TcpEndpoint::from_ipv4([5, 6, 7, 8], 80)); + assert_eq!(info.channel_id, 7); + assert_eq!(info.acknowledgment_number, Some(555)); + assert_eq!(info.payload, &[1, 2, 3]); + + // ipv6 + let data = build_ipv6_packet(&[4, 5]); + let slice = LaxSlicedPacket::from_ethernet(&data).unwrap(); + let info = TcpSegmentInfo::from_lax_sliced_packet(&slice, ()).unwrap(); + assert_eq!(info.source, TcpEndpoint::from_ipv6([1; 16], 1234)); + assert_eq!(info.destination, TcpEndpoint::from_ipv6([2; 16], 80)); + assert_eq!(info.payload, &[4, 5]); + + // packet without any content + let empty = LaxSlicedPacket { + link: None, + link_exts: Default::default(), + net: None, + transport: None, + stop_err: None, + }; + assert!(TcpSegmentInfo::from_lax_sliced_packet(&empty, ()).is_none()); + } + + #[test] + fn from_sliced_packet_without_ip_header() { + // a packet with a TCP layer but no IP header (e.g. only partially + // parsed) has no addresses to identify the connection with + let data = build_packet(Some(555), &[]); + let mut slice = SlicedPacket::from_ethernet(&data).unwrap(); + slice.net = None; + assert!(TcpSegmentInfo::from_sliced_packet(&slice, ()).is_none()); + } fn build_packet(ack: Option, payload: &[u8]) -> Vec { let mut tcp = TcpHeader::new(1234, 80, 1000, 4096); diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs index 8eff07db..e5bb2fed 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs @@ -124,6 +124,12 @@ impl TcpStreamReassemblyBuf { /// The maximum number of separately tracked data sections is set to /// [`DEFAULT_MAX_TCP_STREAM_SECTIONS`] (adjustable via /// [`TcpStreamReassemblyBuf::with_max_sections`]). + /// + /// `max_capacity` must be less than 2^31: the serial number arithmetic + /// used to map sequence numbers into the absolute stream offset space + /// interprets differences of 2^31 or more as "behind the read cursor", so + /// with a bigger window legitimate far-ahead segments would be + /// misinterpreted as stale data. pub fn new( mut data: Vec, mut sections: Vec, @@ -226,6 +232,10 @@ impl TcpStreamReassemblyBuf { /// Absolute stream offset up to which the receiver acknowledged the data /// (exclusive), or `None` if no acknowledgment was observed yet. + /// + /// Note that `Some(0)` is also returned if acknowledgments were observed + /// but none of them could be mapped into the stream (e.g. an ack arriving + /// before any segment established the base of this direction). #[inline] pub fn ack_offset(&self) -> Option { if self.ack_observed { @@ -348,7 +358,9 @@ impl TcpStreamReassemblyBuf { /// /// Allows rejecting blindly injected segments, e.g. a `RST` carrying an /// out of window sequence number. Returns `true` if no segment was - /// received for this stream yet (nothing to judge against). + /// received for this stream yet (nothing to judge against), so a segment + /// in a direction that was never observed before passes the check with + /// any sequence number. pub fn is_seq_in_window(&self, seq: u32) -> bool { let Some(base_seq) = self.base_seq else { return true; @@ -361,7 +373,9 @@ impl TcpStreamReassemblyBuf { /// while keeping the already buffered data. /// /// `start_abs` is the absolute stream offset `base` maps to and must be - /// negative (the stream started before the current anchor). + /// negative (the stream started before the current anchor). Only valid + /// while nothing was consumed yet (`base_offset == 0`), the caller has to + /// check that beforehand. fn anchor_stream_start( &mut self, base: u32, @@ -369,14 +383,7 @@ impl TcpStreamReassemblyBuf { ) -> Result<(), TcpReassembleError> { use TcpReassembleError::*; debug_assert!(start_abs < 0); - - // re-anchoring shifts the buffered data, which is only possible while - // nothing was consumed yet. If data was already handed out the missing - // prefix cannot be re-introduced, so the SYN is ignored and the stream - // keeps being flagged as "start not observed". - if self.base_offset > 0 { - return Ok(()); - } + debug_assert_eq!(0, self.base_offset); let buffered_len = self.data.len(); let Ok(shift) = usize::try_from(-start_abs) else { @@ -440,46 +447,60 @@ impl TcpStreamReassemblyBuf { // the SYN flag consumes one sequence number, the payload starts after it let base = seq.wrapping_add(1); - match self.base_seq { - None => { - self.base_seq = Some(base); - self.syn_observed = true; + if let Some(base_seq) = self.base_seq { + // Position of the stream start indicated by the SYN within the + // already reconstructed stream (offset 0 is where the buffer is + // anchored). + let start_abs = self.seq_to_abs_offset(base_seq, base); + + // If the stream start is already known only an exact match is a + // duplicated/re-transmitted SYN. Initial sequence numbers are + // random, so *any* other value (in either direction) belongs to + // a new connection. + // + // If the start is not known yet the buffer anchored itself on + // the first seen segment, so a SYN at or before that anchor is + // the (re-ordered) start of the very same stream. + let same_stream = if self.syn_observed { + start_abs == 0 + } else { + start_abs <= 0 + }; + if false == same_stream { + return Ok(TcpSegmentOutcome::NewConnection); } - Some(base_seq) => { - // Position of the stream start indicated by the SYN within the - // already reconstructed stream (offset 0 is where the buffer is - // anchored). - let start_abs = self.seq_to_abs_offset(base_seq, base); - - // If the stream start is already known only an exact match is a - // duplicated/re-transmitted SYN. Initial sequence numbers are - // random, so *any* other value (in either direction) belongs to - // a new connection. - // - // If the start is not known yet the buffer anchored itself on - // the first seen segment, so a SYN at or before that anchor is - // the (re-ordered) start of the very same stream. - let same_stream = if self.syn_observed { - start_abs == 0 - } else { - start_abs <= 0 - }; - if false == same_stream { - return Ok(TcpSegmentOutcome::NewConnection); - } - if start_abs < 0 { + if start_abs < 0 { + // re-anchoring shifts the buffered data, which is only + // possible while nothing was consumed yet. If data was + // already handed out the missing prefix cannot be + // re-introduced anymore, so the SYN is ignored (the stream + // keeps being flagged as "start not observed") and only its + // payload is fed (mostly trimmed as already consumed data). + if self.base_offset > 0 { + self.add(base, payload, fin)?; + return Ok(TcpSegmentOutcome::Continued); + } + // A SYN without payload cannot trigger the backwards + // re-anchoring of `add` (it re-anchors on payload bytes), so + // the buffer is shifted explicitly. The `add` afterwards only + // records a potential FIN, which cannot fail for an empty + // payload (so an error cannot leave a partially applied + // state). A SYN *with* payload (TCP Fast Open) skips this: + // `add` performs the identical re-anchoring itself, atomically + // together with the payload checks. + if payload.is_empty() { self.anchor_stream_start(base, start_abs)?; - } else { - self.syn_observed = true; } } } - // TCP Fast Open: a SYN may already carry payload (and a FIN) - if fin || false == payload.is_empty() { - self.add(base, payload, fin)?; - } + // Anchor / re-anchor the buffer & feed the payload and FIN (TCP Fast + // Open SYNs may already carry both). Only mark the stream start as + // observed after every fallible step succeeded, so an error leaves + // the buffer unmodified. + self.add(base, payload, fin)?; + self.syn_observed = true; Ok(TcpSegmentOutcome::Continued) } @@ -515,7 +536,11 @@ impl TcpStreamReassemblyBuf { /// /// * `seq` is the sequence number of the first payload byte. /// * `fin` records that this segment carried the FIN flag (marks the end of - /// the stream one byte past the payload). + /// the stream one byte past the payload). A FIN before the read cursor + /// or referencing a position more than `max_capacity` bytes beyond the + /// received data (which a real receiver would reject as out of window, + /// e.g. a blindly injected segment) is ignored, as is a second FIN at a + /// different position (the first one wins). /// /// Retransmits, re-ordered segments and duplicated / overlapping payloads /// are handled silently. Payload bytes that are not part of the stream are @@ -598,8 +623,20 @@ impl TcpStreamReassemblyBuf { // the end of the stream indicated by the FIN flag (the FIN occupies // the sequence number right after the payload). FINs before the read // cursor are stale (e.g. wrapped far-future sequence numbers) and - // ignored, as is a second FIN at a different position (first wins). - let planned_fin: Option = if fin && self.fin_offset.is_none() && eff_end >= cursor { + // FINs referencing a position far beyond anything that was received + // are implausible (e.g. a blindly injected segment that a real + // receiver would reject as out of window); both are ignored, as is a + // second FIN at a different position (first wins). The limit is + // shifted into the re-anchored coordinates if required (the payload + // window check below catches data segments either way, this matters + // for FINs without storable payload). + let fin_limit = + self.plausible_offset_limit().saturating_add(rebase_shift as u64); + let planned_fin: Option = if fin + && self.fin_offset.is_none() + && eff_end >= cursor + && eff_end <= fin_limit as i128 + { Some(eff_end as u64) } else { None @@ -1293,6 +1330,37 @@ mod test { assert!(buf.is_fin_reached()); } + #[test] + fn out_of_window_fin_is_ignored() { + // a pure FIN referencing a position far beyond the received data + // (e.g. a blindly injected segment that a real receiver would reject + // as out of window) must not set the end of the stream, as the first + // FIN wins & a bogus one would block the genuine FIN forever + let mut buf = new_buf_capacity(16); + buf.reset(1000); + buf.add(1000, &sequence(0, 4), false).unwrap(); + + // received data ends at 4 -> positions up to 4 + 16 are plausible + buf.add(1000u32.wrapping_add(21), &[], true).unwrap(); + assert_eq!(buf.fin_offset(), None); + let err = buf.add(1000u32.wrapping_add(1_000_000), &[], true); + assert_eq!(err, Ok(())); + assert_eq!(buf.fin_offset(), None); + + // the genuine FIN afterwards is still accepted + buf.add(1004, &[], true).unwrap(); + assert_eq!(buf.fin_offset(), Some(4)); + assert!(buf.is_fin_reached()); + + // a FIN exactly at the plausible limit is accepted (e.g. after the + // capture missed segments) + let mut buf = new_buf_capacity(16); + buf.reset(1000); + buf.add(1000, &sequence(0, 4), false).unwrap(); + buf.add(1000u32.wrapping_add(20), &[], true).unwrap(); + assert_eq!(buf.fin_offset(), Some(20)); + } + #[test] fn first_fin_wins() { let mut buf = new_buf(); @@ -1327,6 +1395,74 @@ mod test { assert_eq!(buf.contiguous(), &sequence(0, 4)[..]); } + #[test] + fn add_segment_error_leaves_state_unchanged() { + // a SYN with a TCP Fast Open payload exceeding the window must not + // commit the connection setup (base & syn_observed) either + let mut buf = new_buf_capacity(4); + let err = buf.add_segment(1000, &sequence(0, 8), true, false); + assert_eq!( + err, + Err(TcpReassembleError::SegmentBeyondMaxWindow { + required_capacity: 8, + max_capacity: 4 + }) + ); + assert_eq!(buf.base_sequence_number(), None); + assert_eq!(false, buf.syn_observed()); + assert!(buf.sections().is_empty()); + + // same for a late SYN whose payload would re-anchor the buffer + // beyond the window + let mut buf = new_buf_capacity(8); + buf.add(1010, &sequence(10, 2), false).unwrap(); + let err = buf.add_segment(999, &sequence(0, 8), true, false); + assert_eq!( + err, + Err(TcpReassembleError::SegmentBeyondMaxWindow { + required_capacity: 12, + max_capacity: 8 + }) + ); + assert_eq!(buf.base_sequence_number(), Some(1010)); + assert_eq!(buf.base_offset(), 0); + assert_eq!(false, buf.syn_observed()); + assert_eq!(buf.contiguous(), &sequence(10, 2)[..]); + assert_section_invariants(&buf); + } + + #[test] + fn late_pure_syn_reanchors_backwards() { + // buffer anchored mid-stream at 1004, then the (re-ordered) SYN + // announces the stream start at 1000 -> the buffer shifts backwards + // and leaves a gap for the not yet received bytes 1000..1004 + let mut buf = new_buf(); + buf.add(1004, &sequence(4, 4), false).unwrap(); + assert_eq!( + buf.add_segment(999, &[], true, false).unwrap(), + TcpSegmentOutcome::Continued + ); + assert!(buf.syn_observed()); + assert_eq!(buf.base_sequence_number(), Some(1000)); + // the first 4 bytes are still missing + assert_eq!(buf.contiguous(), &[]); + buf.add(1000, &sequence(0, 4), false).unwrap(); + assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); + assert_section_invariants(&buf); + + // a late SYN after data was already consumed cannot re-anchor: it is + // ignored & the stream start stays flagged as not observed + let mut buf = new_buf(); + buf.add(1004, &sequence(4, 4), false).unwrap(); + buf.consume(4); + assert_eq!( + buf.add_segment(999, &[], true, false).unwrap(), + TcpSegmentOutcome::Continued + ); + assert_eq!(false, buf.syn_observed()); + assert_eq!(buf.base_sequence_number(), Some(1008)); + } + #[test] fn seq_stream_offset() { let mut buf = new_buf(); diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs index 4ac0b80f..72ebe4ce 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs @@ -4,7 +4,7 @@ use std::vec::Vec; /// Result of processing a packet with a [`TcpStreamReassemblyPool`]. #[derive(Debug)] -pub enum TcpReassemblyEvent<'a> { +pub enum TcpReassemblyEvent<'a, CustomChannelId = ()> { /// The packet did not affect any connection (not a TCP segment, or a /// segment of an unknown connection that carries nothing to reconstruct, /// e.g. a pure ACK or an out of window RST). @@ -12,6 +12,11 @@ pub enum TcpReassemblyEvent<'a> { /// The segment was added to this (potentially newly created) connection. Segment { + /// Identifier the connection is tracked under (e.g. to key caller + /// side state or to access the connection later via + /// [`TcpStreamReassemblyPool::connection_mut`]). + id: TcpConnectionId, + /// Direction the segment was travelling in. direction: TcpDirection, @@ -32,16 +37,22 @@ pub enum TcpReassemblyEvent<'a> { /// The connection was ended by a RST or replaced by a SYN with a new /// initial sequence number. - /// - /// In-order data that was never consumed can still be read from both - /// directions of the returned connection (use - /// [`TcpStreamReassemblyBuf::contiguous_unacked`], as the closing segment - /// is usually not acknowledged anymore). The buffers are automatically - /// recycled on the next `process_*` call. - /// - /// In the "replaced by a SYN" case the newly created connection can be - /// accessed via [`TcpStreamReassemblyPool::connection_mut`]. - Closed(&'a mut TcpConnection), + Closed { + /// Identifier the connection was tracked under. + /// + /// In the "replaced by a SYN" case the newly created connection is + /// tracked under the same identifier and can be accessed via + /// [`TcpStreamReassemblyPool::connection_mut`]. + id: TcpConnectionId, + + /// The closed connection. + /// + /// In-order data that was never consumed can still be read from both + /// directions (use [`TcpStreamReassemblyBuf::contiguous_unacked`], as + /// the closing segment is usually not acknowledged anymore). The + /// buffers are automatically recycled on the next `process_*` call. + connection: &'a mut TcpConnection, + }, } /// Pool to reassemble the payload byte streams of multiple TCP connections in @@ -82,7 +93,11 @@ pub enum TcpReassemblyEvent<'a> { /// * `RST` ends the connection (both directions) & recycles its buffers (see /// [`TcpReassemblyEvent::Closed`]). RSTs carrying a sequence number outside /// of the tracked window are ignored so a blindly injected RST cannot tear -/// down a connection. +/// down a connection. Note that this check needs previously observed +/// segments of the direction the RST travels in to judge against: a RST +/// arriving in a direction that was never observed before is accepted with +/// any sequence number (rejecting it would also drop legitimate RSTs like +/// a "connection refused" answering the initial SYN). /// /// Streams for which no SYN was observed (e.g. when the capture starts in the /// middle of a connection) are anchored on their first segment and @@ -93,8 +108,11 @@ pub enum TcpReassemblyEvent<'a> { /// /// [`TcpStreamReassemblyPool::process_sliced_packet`] ignores IP fragments /// (the TCP layer of a fragmented packet is not decoded). Re-assemble -/// fragmented packets first (e.g. via [`crate::defrag::IpDefragPool`]) and -/// feed the result via [`TcpStreamReassemblyPool::process_tcp`]. +/// fragmented packets first (e.g. via [`crate::defrag::IpDefragPool`]), turn +/// the result into a [`TcpSegmentInfo`] via +/// [`TcpSegmentInfo::from_defragmented_payload`] and feed that via +/// [`TcpStreamReassemblyPool::process_tcp`] (see the `pcap_tcp_reassembly` +/// example). /// /// # TCP checksums are not verified /// @@ -262,6 +280,9 @@ where } /// Creates a new pool with a custom per stream buffer limit. + /// + /// `max_capacity` must be less than 2^31 (see + /// [`TcpStreamReassemblyBuf::new`]). pub fn with_max_capacity( max_capacity: usize, ) -> TcpStreamReassemblyPool { @@ -269,6 +290,9 @@ where } /// Creates a new pool with custom per stream buffer & section limits. + /// + /// `max_capacity` must be less than 2^31 (see + /// [`TcpStreamReassemblyBuf::new`]). pub fn with_limits( max_capacity: usize, max_sections: usize, @@ -347,7 +371,7 @@ where /// connection (e.g. not a TCP segment). /// * `Ok(TcpReassemblyEvent::Segment{..})` giving access to both /// directions of the affected connection. - /// * `Ok(TcpReassemblyEvent::Closed(..))` if a connection was ended by a + /// * `Ok(TcpReassemblyEvent::Closed{..})` if a connection was ended by a /// RST or replaced by a new connection (leftover data can be drained). /// * `Err` if the segment could not be added (see [`TcpReassembleError`]). pub fn process_sliced_packet( @@ -355,7 +379,10 @@ where slice: &SlicedPacket, timestamp: Timestamp, channel_id: CustomChannelId, - ) -> Result, TcpReassembleError> { + ) -> Result, TcpReassembleError> + where + CustomChannelId: Clone, + { match TcpSegmentInfo::from_sliced_packet(slice, channel_id) { Some(segment) => self.process(segment, timestamp), None => { @@ -376,7 +403,10 @@ where slice: &LaxSlicedPacket, timestamp: Timestamp, channel_id: CustomChannelId, - ) -> Result, TcpReassembleError> { + ) -> Result, TcpReassembleError> + where + CustomChannelId: Clone, + { match TcpSegmentInfo::from_lax_sliced_packet(slice, channel_id) { Some(segment) => self.process(segment, timestamp), None => { @@ -393,7 +423,10 @@ where &mut self, segment: TcpSegmentInfo<'_, CustomChannelId>, timestamp: Timestamp, - ) -> Result, TcpReassembleError> { + ) -> Result, TcpReassembleError> + where + CustomChannelId: Clone, + { self.process(segment, timestamp) } @@ -415,7 +448,10 @@ where &mut self, segment: TcpSegmentInfo<'_, CustomChannelId>, timestamp: Timestamp, - ) -> Result, TcpReassembleError> { + ) -> Result, TcpReassembleError> + where + CustomChannelId: Clone, + { use std::collections::hash_map::Entry; use TcpReassemblyEvent::*; @@ -449,7 +485,10 @@ where return Ok(Ignored); } return Ok(match self.active.remove(&id) { - Some((connection, _)) => Closed(self.pending_closed.insert(connection)), + Some((connection, _)) => Closed { + id, + connection: self.pending_closed.insert(connection), + }, None => Ignored, }); } @@ -459,6 +498,9 @@ where match self.active.entry(id) { Entry::Occupied(entry) => { + // the id was moved into the entry lookup, so the copy for the + // returned event has to be cloned back out of the key + let id = entry.key().clone(); let value = entry.into_mut(); // note: the timestamp is only updated after the fallible @@ -476,6 +518,7 @@ where value.1 = timestamp; let (sender, receiver) = value.0.streams_mut(direction); Ok(Segment { + id, direction, sender, receiver, @@ -516,7 +559,10 @@ where if old.has_leftover_data() { // "value" can no longer be returned, but the // leftover data of the replaced connection can - Ok(Closed(self.pending_closed.insert(old))) + Ok(Closed { + id, + connection: self.pending_closed.insert(old), + }) } else { recycle_connection( &mut self.finished_data_bufs, @@ -527,6 +573,7 @@ where ); let (sender, receiver) = value.0.streams_mut(direction); Ok(Segment { + id, direction, sender, receiver, @@ -571,9 +618,11 @@ where if let Some(ack) = ack { connection.stream_mut(direction.reverse()).add_ack(ack); } + let id = entry.key().clone(); let value = entry.insert((connection, timestamp)); let (sender, receiver) = value.0.streams_mut(direction); Ok(Segment { + id, direction, sender, receiver, @@ -741,7 +790,7 @@ mod test { } /// Unwraps the stream the payload was added to. - fn sender(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { + fn sender(ev: TcpReassemblyEvent<'_, C>) -> &mut TcpStreamReassemblyBuf { match ev { TcpReassemblyEvent::Segment { sender, .. } => sender, other => panic!("expected TcpReassemblyEvent::Segment, got {other:?}"), @@ -749,7 +798,9 @@ mod test { } /// Unwraps the stream of the opposite direction. - fn receiver(ev: TcpReassemblyEvent<'_>) -> &mut TcpStreamReassemblyBuf { + fn receiver( + ev: TcpReassemblyEvent<'_, C>, + ) -> &mut TcpStreamReassemblyBuf { match ev { TcpReassemblyEvent::Segment { receiver, .. } => receiver, other => panic!("expected TcpReassemblyEvent::Segment, got {other:?}"), @@ -757,14 +808,14 @@ mod test { } /// Unwraps a [`TcpReassemblyEvent::Closed`]. - fn closed(ev: TcpReassemblyEvent<'_>) -> &mut TcpConnection { + fn closed(ev: TcpReassemblyEvent<'_, C>) -> &mut TcpConnection { match ev { - TcpReassemblyEvent::Closed(connection) => connection, + TcpReassemblyEvent::Closed { connection, .. } => connection, other => panic!("expected TcpReassemblyEvent::Closed, got {other:?}"), } } - fn assert_ignored(ev: TcpReassemblyEvent<'_>) { + fn assert_ignored(ev: TcpReassemblyEvent<'_, C>) { assert!(matches!(ev, TcpReassemblyEvent::Ignored)); } @@ -850,10 +901,12 @@ mod test { let ev = pool.process_tcp(seg, ()).unwrap(); match ev { TcpReassemblyEvent::Segment { + id, direction, sender, receiver, } => { + assert_eq!(id, conn_id(0)); assert_eq!(direction, A_TO_B.reverse()); // the payload of this segment is not acknowledged yet assert_eq!(sender.contiguous(), &[]); @@ -904,6 +957,43 @@ mod test { assert_eq!(buf.contiguous_unacked(), &sequence(8, 4)[..]); } + #[test] + fn events_carry_the_connection_id() { + let mut pool = new_pool::<()>(); + + // a new connection reports the id it is tracked under ... + match pool + .process_tcp(segment(false, 1000, &sequence(0, 4), 7), ()) + .unwrap() + { + TcpReassemblyEvent::Segment { id, direction, .. } => { + assert_eq!(id, conn_id(7)); + assert_eq!(direction, A_TO_B); + } + other => panic!("expected Segment, got {other:?}"), + } + + // ... as do segments of both directions of an existing connection + match pool + .process_tcp(segment(true, 5000, &sequence(4, 4), 7), ()) + .unwrap() + { + TcpReassemblyEvent::Segment { id, direction, .. } => { + assert_eq!(id, conn_id(7)); + assert_eq!(direction, A_TO_B.reverse()); + } + other => panic!("expected Segment, got {other:?}"), + } + + // ... and the close event + let mut rst = segment(false, 1004, &[], 7); + rst.rst = true; + match pool.process_tcp(rst, ()).unwrap() { + TcpReassemblyEvent::Closed { id, .. } => assert_eq!(id, conn_id(7)), + other => panic!("expected Closed, got {other:?}"), + } + } + #[test] fn lazy_init_without_syn() { let mut pool = new_pool::<()>(); @@ -1523,7 +1613,7 @@ mod test { macro_rules! feed { ($seg:expr) => { match pool.process_tcp($seg, ()) { - Ok(TcpReassemblyEvent::Closed(_)) => { + Ok(TcpReassemblyEvent::Closed { .. }) => { return Err(TestCaseError::fail("unexpected connection close")) } Ok(_) => {} @@ -1613,7 +1703,7 @@ mod test { ); rst.rst = true; match pool.process_tcp(rst, ()) { - Ok(TcpReassemblyEvent::Closed(_)) => {} + Ok(TcpReassemblyEvent::Closed { .. }) => {} other => { return Err(TestCaseError::fail(std::format!( "expected the connection to be closed, got {other:?}" @@ -1653,7 +1743,8 @@ mod test { let mut syn = segment(false, second_isn, &[], 0); syn.syn = true; match pool.process_tcp(syn, ()) { - Ok(TcpReassemblyEvent::Closed(connection)) => { + Ok(TcpReassemblyEvent::Closed { id: closed_id, connection }) => { + prop_assert_eq!(closed_id, id.clone()); prop_assert_eq!( connection.stream(A_TO_B).contiguous_unacked(), &first[..] From c13b7ead825323765919c52a4ca95296d3271254 Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Sun, 9 Aug 2026 16:00:48 +0200 Subject: [PATCH 7/8] Rework example & timestamp eviction logic --- changelog.md | 5 +- etherparse/Cargo.toml | 1 + etherparse/examples/pcap_tcp_reassembly.rs | 490 +++++++++++++++--- etherparse/src/tcp_reassembly/mod.rs | 33 ++ .../src/tcp_reassembly/tcp_connection.rs | 7 +- .../src/tcp_reassembly/tcp_connection_id.rs | 5 +- etherparse/src/tcp_reassembly/tcp_endpoint.rs | 4 +- .../tcp_reassembly/tcp_reassemble_error.rs | 13 +- .../tcp_stream_reassembly_buf.rs | 11 +- .../tcp_stream_reassembly_pool.rs | 345 +++++++++++- 10 files changed, 791 insertions(+), 123 deletions(-) diff --git a/changelog.md b/changelog.md index 5d29d0ff..d521914f 100644 --- a/changelog.md +++ b/changelog.md @@ -9,12 +9,13 @@ * `TcpStreamReassemblyBuf::skip_gap` gives up on segments that were lost and never re-transmitted (e.g. dropped by the capture) by advancing the read cursor to the next received data section, which would otherwise stall the stream forever. * `TcpStreamReassemblyBuf::drain` (and `drain_unacked`) hand the available data to a closure and consume the number of bytes it reports as processed, so a parser that can only handle complete messages can keep the remainder buffered. The raw buffer behind the reconstruction (including the zero filled gaps of not yet received ranges) stays accessible via `raw_buffer` together with `sections`. * By default (`TcpAckPolicy::Required`) only data that the receiver acknowledged is handed out by `contiguous`, so segments that never reached the receiver (lost or rejected after the point of capture) do not end up in the reconstructed stream. The acknowledgment numbers travel in the opposite direction of the data and are fed via `TcpStreamReassemblyBuf::add_ack` (stale & nonsensical acknowledgments are ignored). As a capture usually ends before the last data is acknowledged, the trailing unacknowledged bytes stay reachable via `contiguous_unacked` & `consume_unacked`, `ack_observed` identifies streams that never saw an acknowledgment (e.g. one-way captures) and `TcpAckPolicy::Ignore` disables the behavior entirely. - * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many connections in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). Both directions of a connection are tracked together as a `TcpConnection`, which is what allows the acknowledgment numbers of one direction to release the data of the other one. It interprets the TCP control flags automatically (`SYN` establishes/replaces a connection — duplicated & late SYNs are recognized and keep the buffered state, `ACK` releases the data of the reverse direction, `FIN` marks the stream end, `RST` ends the whole connection) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Segment` / `Closed`, where `Segment` exposes both the stream the payload was added to and the stream the segment acknowledged, and `Closed` still exposes never consumed leftover data; both carry the `TcpConnectionId` the connection is tracked under, which requires `Clone` on the custom channel id for the `process_*` methods). Connections can be enumerated & drained via `iter_mut` and evicted via the id & timestamp aware `retain`. + * `TcpStreamReassemblyPool` (requires the `std` feature), a pool that reassembles many connections in parallel while re-using the underlying buffers to minimize allocations (mirrors `defrag::IpDefragPool`). Both directions of a connection are tracked together as a `TcpConnection`, which is what allows the acknowledgment numbers of one direction to release the data of the other one. It interprets the TCP control flags automatically (`SYN` establishes/replaces a connection — duplicated & late SYNs are recognized and keep the buffered state, `ACK` releases the data of the reverse direction, `FIN` marks the stream end, `RST` ends the whole connection) and reports the outcome per packet via `TcpReassemblyEvent` (`Ignored` / `Segment` / `Closed`, where `Segment` exposes both the stream the payload was added to and the stream the segment acknowledged, and `Closed` still exposes never consumed leftover data; both carry the `TcpConnectionId` the connection is tracked under, which requires `Clone` on the custom channel id for the `process_*` methods). Connections can be enumerated & drained via `iter_mut` and evicted via the id, connection & timestamp aware `retain`. + * Connections whose end is never observed (no `FIN`, no `RST`, e.g. because one side crashed or the capture does not contain the rest of them) would occupy memory forever, so `TcpStreamReassemblyPool::evict_older_than` (and `evict_older_than_with`, which hands the discarded connections to a closure so their leftover data can be drained first) discards the connections that have been inactive for longer than a caller chosen timeout, based on the `Timestamp` the segments were processed with (`last_activity` exposes it per connection). Every segment counts as activity, also the ones that carry no data (keep alive probes, the acknowledgments answering them, window updates & re-transmits), so a connection that is idle on the application layer but alive on the wire is not discarded. `DEFAULT_TCP_CONNECTION_TIMEOUT` (10 minutes) documents how to choose the timeout. * A `RST` carrying a sequence number outside of the tracked window is ignored, so a blindly injected `RST` cannot tear down a connection. * `TcpConnection::is_closed` reports connections whose both directions received everything up to their `FIN` and `TcpConnection::is_bidirectional` identifies connections for which only one direction was observed (which under `TcpAckPolicy::Required` never hand out data). Packets parsed without length & consistency checks can be fed via `TcpStreamReassemblyPool::process_lax_sliced_packet` / `TcpSegmentInfo::from_lax_sliced_packet`. * Connections are identified by a `TcpConnectionId` (VLAN ids, the two `TcpEndpoint`s in a canonical order and a custom channel id) together with a `TcpDirection`, so both directions of a connection map to the same identifier. `TcpSegmentInfo::from_sliced_packet` extracts everything the reassembly needs from a parsed packet and can be modified before being passed to `process_tcp` (e.g. clearing the VLAN ids and moving them into the channel id if the two directions of a connection are tagged differently). For segments that do not arrive as one parsable packet there are `TcpSegmentInfo::from_tcp_slice` (assembles the info from a `TcpSlice` plus the IP addresses & VLAN ids) and `TcpSegmentInfo::from_defragmented_payload` (takes the re-assembled IP payload of a fragmented packet together with any of its fragments, for combining `defrag::IpDefragPool` with the TCP reassembly). * Per-stream memory is bounded via a configurable `max_capacity` (buffered bytes ahead of the read cursor) and `max_sections` (tracked non-contiguous ranges), returning `TcpReassembleError` when a limit would be exceeded (the buffer is left unchanged in that case). The buffers kept around for re-use are bounded in count & size via `TcpStreamReassemblyPool::with_pool_limits` (so a burst of connections or a single big stream does not make the pool hold on to the memory forever) and the number of parallel connections can be limited via `TcpStreamReassemblyPool::with_max_connections` (`TcpReassembleError::TooManyConnections`). - * Supporting types `TcpConnection`, `TcpConnectionId`, `TcpDirection`, `TcpEndpoint`, `TcpSegmentInfo`, `TcpStreamRange`, `TcpSegmentOutcome`, `TcpAckPolicy`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY`, `DEFAULT_MAX_TCP_STREAM_SECTIONS`, `DEFAULT_MAX_TCP_POOLED_BUFS` & `DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY` constants, plus a `tcp_reassembly` example and a `pcap_tcp_reassembly` example that reads a classic pcap file and reconstructs the TCP streams of all connections including fragmented IP packets (combining `defrag::IpDefragPool` & `TcpStreamReassemblyPool`). + * Supporting types `TcpConnection`, `TcpConnectionId`, `TcpDirection`, `TcpEndpoint`, `TcpSegmentInfo`, `TcpStreamRange`, `TcpSegmentOutcome`, `TcpAckPolicy`, `TcpReassembleError` and the `DEFAULT_MAX_TCP_STREAM_CAPACITY`, `DEFAULT_MAX_TCP_STREAM_SECTIONS`, `DEFAULT_MAX_TCP_POOLED_BUFS`, `DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY` & `DEFAULT_TCP_CONNECTION_TIMEOUT` constants, plus a `tcp_reassembly` example and a `pcap_tcp_reassembly` example that reads a classic pcap file and reconstructs the TCP streams of all connections including fragmented IP packets (combining `defrag::IpDefragPool` & `TcpStreamReassemblyPool`), with a `--metadata` option to print a per connection summary instead of the stream contents (the stream data itself is printed as text if it decodes as UTF-8 - with terminal control characters escaped - and as a hex dump otherwise) and a `--timeout` option controlling when inactive connections are discarded. * Added zero-copy IGMP support (IGMPv1, IGMPv2 & IGMPv3): * `IgmpSlice`, an enum with one zero-copy variant per message type (`MembershipQuery`, `MembershipQueryWithSources`, `MembershipReportV1`, `MembershipReportV2`, `MembershipReportV3`, `LeaveGroup` & `Unknown`), plus common accessors (`header`, `payload`, `slice`, `checksum`, `is_checksum_valid`). diff --git a/etherparse/Cargo.toml b/etherparse/Cargo.toml index aa33b71a..5184788d 100644 --- a/etherparse/Cargo.toml +++ b/etherparse/Cargo.toml @@ -28,6 +28,7 @@ std = ["alloc", "arrayvec/std"] arrayvec = { version = "0.7.2", default-features = false } [dev-dependencies] +clap = { version = "4.5.61", features = ["derive"] } proptest = "1.4.0" rpcap = "1.0.0" diff --git a/etherparse/examples/pcap_tcp_reassembly.rs b/etherparse/examples/pcap_tcp_reassembly.rs index bcc3861a..b0e606a7 100644 --- a/etherparse/examples/pcap_tcp_reassembly.rs +++ b/etherparse/examples/pcap_tcp_reassembly.rs @@ -6,36 +6,94 @@ //! cargo run --example pcap_tcp_reassembly -- capture.pcap //! ``` //! +//! Data that decodes as UTF-8 is printed as text with the characters escaped +//! that could otherwise mess up the terminal (captured data is attacker +//! controlled, so an unescaped ANSI sequence could change colors, move the +//! cursor or overwrite previously printed lines), everything else is printed +//! as a hex dump. +//! +//! Pass `--metadata` to print a summary of the connections (endpoints, +//! reconstructed byte counts & the state of both directions) instead of the +//! stream contents: +//! +//! ```sh +//! cargo run --example pcap_tcp_reassembly -- --metadata capture.pcap +//! ``` +//! +//! Connections whose end is never observed (no `FIN`, no `RST`, e.g. because +//! one side crashed or the capture does not contain the rest of them) would +//! occupy memory until the end of the capture, so connections that have been +//! inactive for too long are discarded (`--timeout`, in seconds of capture +//! time, `0` disables it). +//! //! Only the classic `.pcap` format is supported (not `.pcapng`, convert via //! `tshark -r capture.pcapng -F pcap -w capture.pcap`) with the link types //! "Ethernet" and "Raw IP". To keep the example small there is no extra //! handling for captures with a limited snapshot length (truncated packets //! are reported as parse errors and skipped). +use clap::Parser; use etherparse::{defrag::*, tcp_reassembly::*, *}; use rpcap::{read::PcapReader, Linktype}; +use std::collections::HashMap; use std::net::IpAddr; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +/// Reconstructs the payload byte streams of all TCP connections in a pcap +/// file (re-assembling fragmented IP packets on the way). +#[derive(Parser)] +struct Args { + /// Pcap file to read (classic pcap format, not pcapng). + file: PathBuf, + + /// Print a summary of the connections (endpoints, byte counts & the + /// state of both directions) instead of the stream data. + #[arg(short, long)] + metadata: bool, + + /// Discard connections that have not seen a segment for this many seconds + /// of capture time (0 disables it). + #[arg(short, long, default_value_t = DEFAULT_TCP_CONNECTION_TIMEOUT.as_secs())] + timeout: u64, +} + +/// How often the inactive connections are looked for (in capture time). +/// +/// The check runs over all tracked connections, so it is done in intervals +/// instead of once per packet. +const EVICTION_INTERVAL: Duration = Duration::from_secs(1); + +/// Time after which the fragments of an IP packet that was never completed +/// are discarded. +/// +/// RFC 791 recommends 15 seconds for the re-assembly of a packet, real +/// implementations tend to use more (e.g. 30 seconds on Linux). +const IP_FRAGMENT_TIMEOUT: Duration = Duration::from_secs(30); fn main() { - let Some(path) = std::env::args().nth(1) else { - eprintln!("Usage: pcap_tcp_reassembly "); - std::process::exit(1); + let args = Args::parse(); + let mode = if args.metadata { + OutputMode::Metadata + } else { + OutputMode::Data }; - let file = match std::fs::File::open(&path) { + let path = args.file.display(); + + let file = match std::fs::File::open(&args.file) { Ok(v) => v, Err(err) => { eprintln!("Error opening '{path}': {err}"); std::process::exit(1); } }; - let (file_options, mut pcap_reader) = - match PcapReader::new(std::io::BufReader::new(file)) { - Ok(v) => v, - Err(err) => { - eprintln!("Error parsing '{path}': {err}"); - std::process::exit(1); - } - }; + let (file_options, mut pcap_reader) = match PcapReader::new(std::io::BufReader::new(file)) { + Ok(v) => v, + Err(err) => { + eprintln!("Error parsing '{path}': {err}"); + std::process::exit(1); + } + }; let is_ethernet = match file_options.linktype { l if l == Linktype::ETHERNET as u32 => true, l if l == Linktype::RAW as u32 => false, @@ -45,12 +103,20 @@ fn main() { } }; - // pool re-assembling fragmented IPv4 & IPv6 packets - let mut defrag_pool = IpDefragPool::<(), ()>::new(); + // pool re-assembling fragmented IPv4 & IPv6 packets (the capture time of + // the packets is used to discard incomplete ones later on) + let mut defrag_pool = IpDefragPool::::new(); // pool reconstructing the payload streams of the TCP connections (by // default only data that the receiver acknowledged is handed out) - let mut tcp_pool = TcpStreamReassemblyPool::<(), ()>::new(); + let mut tcp_pool = TcpStreamReassemblyPool::::new(); + + // decides what is printed & collects the per connection statistics + let mut output = Output::new(mode); + + // capture time at which the next check for inactive connections is due + let timeout = Duration::from_secs(args.timeout); + let mut next_eviction: Option = None; loop { // the packet data is borrowed from the reader's internal buffer, so @@ -64,6 +130,20 @@ fn main() { } }; + // get rid of the connections that have been inactive for too long + // (before the packet is processed, so a connection is never discarded + // because of the time that passed while it was being processed) + if false == timeout.is_zero() && next_eviction.is_none_or(|next| packet.time >= next) { + next_eviction = packet.time.checked_add(EVICTION_INTERVAL); + evict_inactive( + &mut tcp_pool, + &mut defrag_pool, + &mut output, + packet.time, + timeout, + ); + } + // slice the packet into its different header components let sliced = if is_ethernet { SlicedPacket::from_ethernet(packet.data) @@ -81,7 +161,7 @@ fn main() { if sliced.is_ip_payload_fragmented() { // the TCP layer of a fragmented packet is not decoded, the IP // payload has to be re-assembled from all fragments first - match defrag_pool.process_sliced_packet(&sliced, (), ()) { + match defrag_pool.process_sliced_packet(&sliced, packet.time, ()) { Ok(Some(finished)) => { if finished.ip_number == IpNumber::TCP { // the re-assembled payload is the TCP segment, while @@ -91,7 +171,9 @@ fn main() { &finished.payload, (), ) { - Ok(Some(info)) => handle_tcp_event(tcp_pool.process_tcp(info, ())), + Ok(Some(info)) => { + output.handle_event(tcp_pool.process_tcp(info, packet.time)) + } Ok(None) => { // no IP header (cannot happen for a re-assembled packet) } @@ -109,7 +191,7 @@ fn main() { Err(err) => eprintln!("Error re-assembling fragmented IP packet: {err}"), } } else { - handle_tcp_event(tcp_pool.process_sliced_packet(&sliced, (), ())); + output.handle_event(tcp_pool.process_sliced_packet(&sliced, packet.time, ())); } } @@ -117,85 +199,342 @@ fn main() { // capture is usually never acknowledged (the capture ends before the ACK // arrives), so the acknowledgment requirement is ignored here. for (id, connection, _timestamp) in tcp_pool.iter_mut() { - if false == connection.is_bidirectional() { - println!( - "warning: only one direction captured for {} <-> {} (no data can be acknowledged)", - endpoint(id.first()), - endpoint(id.second()), - ); - } for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { - print_stream_data(id, direction, connection.stream_mut(direction), true); + output.consume_stream(id, direction, connection.stream_mut(direction), true); } + output.finish_connection(id, connection, "still open at the end of the capture"); + } +} + +/// Discards the connections that have not seen a segment for `timeout` (and +/// the fragments of IP packets that were never completed). +/// +/// Every segment refreshes the timestamp of its connection, also the ones +/// that carry no data (keep alive probes, the acknowledgments answering them, +/// window updates & re-transmits), so a connection that is idle on the +/// application layer but alive on the wire is not discarded here. +fn evict_inactive( + tcp_pool: &mut TcpStreamReassemblyPool, + defrag_pool: &mut IpDefragPool, + output: &mut Output, + now: SystemTime, + timeout: Duration, +) { + // pcap timestamps are not guaranteed to be monotonic (and the capture may + // start before the timeout has passed since the unix epoch) + if let Some(cutoff) = now.checked_sub(timeout) { + tcp_pool.evict_older_than_with(&cutoff, |id, connection| { + // last chance to get at the data of the connection, so the + // unacknowledged data is taken as well + for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + output.consume_stream(id, direction, connection.stream_mut(direction), true); + } + output.finish_connection(id, connection, "discarded after being inactive"); + }); + } + + if let Some(cutoff) = now.checked_sub(IP_FRAGMENT_TIMEOUT) { + defrag_pool.retain(|timestamp| *timestamp >= cutoff); } } -/// Prints the stream data that became available through a processed segment. -fn handle_tcp_event(event: Result, TcpReassembleError>) { - match event { - Ok(TcpReassemblyEvent::Segment { - id, - direction, - sender, - receiver, - }) => { - // the segment added its payload to "sender", while its - // acknowledgment number may have released data of "receiver" - print_stream_data(&id, direction, sender, false); - print_stream_data(&id, direction.reverse(), receiver, false); +/// What the example prints for the reconstructed streams. +#[derive(Clone, Copy, PartialEq, Eq)] +enum OutputMode { + /// Print the reconstructed stream data. + Data, + + /// Only print metadata about the connections. + Metadata, +} + +/// Numbers collected over the lifetime of a connection. +/// +/// The data of a stream is handed out (and freed) piece by piece while the +/// capture is processed, so anything about the data as a whole has to be +/// accumulated on the way. +/// +/// Note that a connection that is replaced by a new one re-using the same +/// addresses & ports is only reported via [`TcpReassemblyEvent::Closed`] if +/// it still held undelivered data. As this example drains everything +/// immediately, the statistics of such a reconnect continue the ones of the +/// previous connection instead of starting fresh. +#[derive(Default, Clone, Copy)] +struct ConnectionStats { + /// Reconstructed bytes per direction (see [`direction_index`]). + bytes: [u64; 2], + + /// Number of processed segments (both directions). + segments: u64, +} + +/// Prints the reconstructed streams (or just metadata about them) & collects +/// the per connection statistics. +struct Output { + mode: OutputMode, + + /// Statistics of the connections that were seen so far. + stats: HashMap, +} + +impl Output { + fn new(mode: OutputMode) -> Output { + Output { + mode, + stats: HashMap::new(), } - Ok(TcpReassemblyEvent::Closed { id, connection }) => { - // a RST ended the connection (or a new connection replaced it): - // drain the data that was never consumed (the closing segment is - // usually not acknowledged anymore, so unacknowledged data is - // printed as well) + } + + /// Handles the data that became available through a processed segment. + fn handle_event(&mut self, event: Result, TcpReassembleError>) { + match event { + Ok(TcpReassemblyEvent::Segment { + id, + direction, + sender, + receiver, + }) => { + self.stats.entry(id.clone()).or_default().segments += 1; + // the segment added its payload to "sender", while its + // acknowledgment number may have released data of "receiver" + self.consume_stream(&id, direction, sender, false); + self.consume_stream(&id, direction.reverse(), receiver, false); + } + Ok(TcpReassemblyEvent::Closed { id, connection }) => { + // a RST ended the connection (or a new connection replaced + // it): drain the data that was never consumed (the closing + // segment is usually not acknowledged anymore, so the + // unacknowledged data is taken as well) + for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + self.consume_stream(&id, direction, connection.stream_mut(direction), true); + } + self.finish_connection(&id, connection, "closed by a RST or a new connection"); + } + Ok(TcpReassemblyEvent::Ignored) => { + // not a TCP segment (or a segment of an unknown connection + // that carries nothing to reconstruct) + } + Err(err) => eprintln!("Error reconstructing TCP stream: {err}"), + } + } + + /// Consumes the in-order data of one stream direction, printing it unless + /// only metadata was requested. + /// + /// `final_drain` marks the last chance to get at the data of the stream + /// (the connection ended or the capture is over). It additionally hands + /// out the data that was never acknowledged. + /// + /// The data has to be consumed in both modes, otherwise the buffer of the + /// stream fills up until segments get rejected with + /// [`TcpReassembleError::SegmentBeyondMaxWindow`]. + fn consume_stream( + &mut self, + id: &TcpConnectionId, + direction: TcpDirection, + stream: &mut TcpStreamReassemblyBuf, + final_drain: bool, + ) { + let mode = self.mode; + let print = |bytes: &[u8]| { + if mode == OutputMode::Metadata { + // only the amount of data is of interest + bytes.len() + } else { + print_stream_data(id, direction, bytes, final_drain) + } + }; + // `drain` returns the number of bytes the closure consumed + let consumed = if final_drain { + stream.drain_unacked(print) + } else { + stream.drain(print) + }; + + if consumed > 0 { + let stats = self.stats.entry(id.clone()).or_default(); + stats.bytes[direction_index(direction)] += consumed as u64; + } + } + + /// Reports a connection that ended (or that is still open at the end of + /// the capture) & drops its collected statistics. + fn finish_connection(&mut self, id: &TcpConnectionId, connection: &TcpConnection, note: &str) { + let stats = self.stats.remove(id).unwrap_or_default(); + + if self.mode == OutputMode::Metadata { + println!( + "{} <-> {} ({} segments, {note})", + endpoint(id.first()), + endpoint(id.second()), + stats.segments, + ); for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { - print_stream_data(&id, direction, connection.stream_mut(direction), true); + let stream = connection.stream(direction); + let mut state = Vec::new(); + if stream.syn_observed() { + state.push("start observed"); + } else { + state.push("start missing"); + } + if stream.is_fin_reached() { + state.push("fin reached"); + } + if false == stream.ack_observed() { + // under the default `TcpAckPolicy::Required` such a + // stream only hands out data via `contiguous_unacked` + state.push("no acknowledgments seen"); + } + println!( + " {} -> {}: {} bytes ({})", + endpoint(id.source(direction)), + endpoint(id.destination(direction)), + stats.bytes[direction_index(direction)], + state.join(", "), + ); } + } else if false == connection.is_bidirectional() { + // only one direction was seen, so nothing could be acknowledged println!( - "connection {} <-> {} closed", + "warning: only one direction captured for {} <-> {}", endpoint(id.first()), endpoint(id.second()), ); } - Ok(TcpReassemblyEvent::Ignored) => { - // not a TCP segment (or a segment of an unknown connection that - // carries nothing to reconstruct) - } - Err(err) => eprintln!("Error reconstructing TCP stream: {err}"), } } -/// Prints & consumes the in-order data of one stream direction (including -/// the not yet acknowledged data if `include_unacked` is set). +/// Prints stream data & returns the number of bytes that were printed (which +/// is what the caller then consumes). +/// +/// Valid UTF-8 is printed as text with the characters that could mess up the +/// terminal escaped, anything else as a hex dump. +/// +/// A multi byte UTF-8 character can be split across two segments, so data +/// ending in the middle of one is not printed (and not consumed) yet: the +/// rest of the character arrives with one of the following segments. Only if +/// `flush` is set (the last chance to print the data) the incomplete +/// character is dumped as it is. fn print_stream_data( id: &TcpConnectionId, direction: TcpDirection, - stream: &mut TcpStreamReassemblyBuf, - include_unacked: bool, -) { - let print = |bytes: &[u8]| { - if false == bytes.is_empty() { + bytes: &[u8], + flush: bool, +) -> usize { + match core::str::from_utf8(bytes) { + Ok(text) => { + print_text(id, direction, flush, text); + bytes.len() + } + // `error_len() == None` means the data just ends in the middle of a + // character (in contrast to containing an invalid byte sequence) + Err(err) if err.error_len().is_none() && false == flush => { + let valid = err.valid_up_to(); + if valid > 0 { + // the prefix was already checked to be valid UTF-8 + print_text( + id, + direction, + flush, + core::str::from_utf8(&bytes[..valid]).unwrap(), + ); + } + // keep the incomplete character buffered + valid + } + Err(_) => { println!( - "{} -> {}{}: {:?}", - endpoint(id.source(direction)), - endpoint(id.destination(direction)), - if include_unacked { - " (incl. unacknowledged)" - } else { - "" - }, - String::from_utf8_lossy(bytes), + "{}: {} bytes of non UTF-8 data", + stream_prefix(id, direction, flush), + bytes.len(), ); + print_hex_dump(bytes); + bytes.len() + } + } +} + +/// Prints reconstructed text with the characters escaped that could mess up +/// the terminal. +fn print_text(id: &TcpConnectionId, direction: TcpDirection, flush: bool, text: &str) { + if false == text.is_empty() { + println!( + "{}: \"{}\"", + stream_prefix(id, direction, flush), + escape_text(text), + ); + } +} + +/// "source -> destination" prefix of a line of stream data. +fn stream_prefix(id: &TcpConnectionId, direction: TcpDirection, flush: bool) -> String { + format!( + "{} -> {}{}", + endpoint(id.source(direction)), + endpoint(id.destination(direction)), + if flush { " (incl. unacknowledged)" } else { "" }, + ) +} + +/// Escapes the characters of a reconstructed string that could mess up the +/// terminal it is printed to. +/// +/// Never print captured data unescaped: it is attacker controlled and the +/// control characters would otherwise be interpreted by the terminal (e.g. +/// an escape character starting an ANSI sequence that changes colors, moves +/// the cursor or overwrites previously printed lines). +fn escape_text(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + // C0 & C1 control characters (including the escape character + // that starts an ANSI sequence) and the bidirectional overrides + // (which allow visually re-ordering the printed text) + _ if c.is_control() + || ('\u{202a}'..='\u{202e}').contains(&c) + || ('\u{2066}'..='\u{2069}').contains(&c) => + { + if (c as u32) < 0x100 { + result.push_str(&format!("\\x{:02x}", c as u32)); + } else { + result.push_str(&format!("\\u{{{:04x}}}", c as u32)); + } + } + _ => result.push(c), } - // consume everything (returning less would keep the rest buffered, - // e.g. for a parser that only handles complete messages) - bytes.len() - }; - if include_unacked { - stream.drain_unacked(print); - } else { - stream.drain(print); + } + result +} + +/// Prints data that is not valid UTF-8 as a hex dump (16 bytes per line +/// together with the printable ASCII characters). +fn print_hex_dump(bytes: &[u8]) { + for (index, chunk) in bytes.chunks(16).enumerate() { + let mut hex = String::with_capacity(3 * 16); + let mut ascii = String::with_capacity(16); + for byte in chunk { + hex.push_str(&format!("{byte:02x} ")); + ascii.push(if byte.is_ascii_graphic() || *byte == b' ' { + *byte as char + } else { + '.' + }); + } + // 16 bytes are 48 characters of hex, so shorter last lines line up + println!(" {:08x} {:<48}|{}|", index * 16, hex, ascii); + } +} + +/// Index of a direction in [`ConnectionStats::bytes`]. +fn direction_index(direction: TcpDirection) -> usize { + match direction { + TcpDirection::FirstToSecond => 0, + TcpDirection::SecondToFirst => 1, } } @@ -206,4 +545,3 @@ fn endpoint(endpoint: &TcpEndpoint) -> String { IpAddr::V6(ip) => format!("[{}]:{}", ip, endpoint.port), } } - diff --git a/etherparse/src/tcp_reassembly/mod.rs b/etherparse/src/tcp_reassembly/mod.rs index 189f645a..d14cedf9 100644 --- a/etherparse/src/tcp_reassembly/mod.rs +++ b/etherparse/src/tcp_reassembly/mod.rs @@ -53,3 +53,36 @@ pub const DEFAULT_MAX_TCP_POOLED_BUFS: usize = 32; /// so a single burst does not make the pool hold on to the memory for the /// rest of its lifetime. pub const DEFAULT_MAX_TCP_POOLED_BUF_CAPACITY: usize = 64 * 1024; + +/// Recommended duration of inactivity after which a TCP connection is +/// discarded from a [`TcpStreamReassemblyPool`] (10 minutes). +/// +/// A [`TcpStreamReassemblyPool`] never discards connections on its own (it +/// does not even know how to interpret its `Timestamp` type), so this is a +/// recommendation for the cutoff passed to +/// [`TcpStreamReassemblyPool::evict_older_than`], not an automatically +/// applied default. +/// +/// # Choosing a value +/// +/// Every segment that is attributed to a connection counts as activity, +/// including keep alive probes, their replies, pure acknowledgments and +/// re-transmits (see [`TcpStreamReassemblyPool::evict_older_than`]), so this +/// only has to outlast the *keep alive interval* of the connections that are +/// of interest, not their idle time. +/// +/// 10 minutes is a middle ground that follows the flow timeout other traffic +/// analyzers use for established TCP connections (e.g. 600s in Suricata, 300s +/// in Zeek). Consider a different value if: +/// +/// * Connections must never be lost while they are still alive and are only +/// kept alive by the operating system defaults: TCP keep alive probes only +/// start after 2 hours of idle time on Linux & Windows, so a timeout +/// following RFC 5382 (at least 2 hours and 4 minutes) is required to +/// survive them. +/// * The available memory is small compared to the number of parallel +/// connections: shorter timeouts free the buffers of connections that +/// silently disappeared (e.g. one side crashed, so neither a `FIN` nor a +/// `RST` is ever sent) earlier. +pub const DEFAULT_TCP_CONNECTION_TIMEOUT: core::time::Duration = + core::time::Duration::from_secs(600); diff --git a/etherparse/src/tcp_reassembly/tcp_connection.rs b/etherparse/src/tcp_reassembly/tcp_connection.rs index 71c5fcf6..754af6f3 100644 --- a/etherparse/src/tcp_reassembly/tcp_connection.rs +++ b/etherparse/src/tcp_reassembly/tcp_connection.rs @@ -91,7 +91,9 @@ impl TcpConnection { /// [`TcpStreamReassemblyPool`] automatically when this becomes true (the /// data still has to be drained). Use it to decide when to call /// [`TcpStreamReassemblyPool::end_connection`] or as part of a - /// [`TcpStreamReassemblyPool::retain`] predicate. + /// [`TcpStreamReassemblyPool::retain`] predicate (connections that are + /// simply inactive are better discarded via + /// [`TcpStreamReassemblyPool::evict_older_than`]). /// /// Note that this is about *reception* and ignores the /// [`TcpAckPolicy`], see @@ -207,7 +209,8 @@ mod test { // not acknowledged, but still "leftover" (unacked data counts) assert!(conn.has_leftover_data()); - conn.stream_mut(TcpDirection::SecondToFirst).consume_unacked(3); + conn.stream_mut(TcpDirection::SecondToFirst) + .consume_unacked(3); assert_eq!(false, conn.has_leftover_data()); let (a, b) = conn.take_bufs(); diff --git a/etherparse/src/tcp_reassembly/tcp_connection_id.rs b/etherparse/src/tcp_reassembly/tcp_connection_id.rs index 927837e2..746f4750 100644 --- a/etherparse/src/tcp_reassembly/tcp_connection_id.rs +++ b/etherparse/src/tcp_reassembly/tcp_connection_id.rs @@ -146,7 +146,10 @@ mod test { let (value, _) = TcpConnectionId::new(a, b, Default::default(), 7u16); let _ = format!("{:?}", value); assert_eq!(value, value.clone()); - assert_ne!(value, TcpConnectionId::new(a, b, Default::default(), 8u16).0); + assert_ne!( + value, + TcpConnectionId::new(a, b, Default::default(), 8u16).0 + ); use core::hash::{Hash, Hasher}; use std::collections::hash_map::DefaultHasher; diff --git a/etherparse/src/tcp_reassembly/tcp_endpoint.rs b/etherparse/src/tcp_reassembly/tcp_endpoint.rs index a9fcd9fc..f63eab76 100644 --- a/etherparse/src/tcp_reassembly/tcp_endpoint.rs +++ b/etherparse/src/tcp_reassembly/tcp_endpoint.rs @@ -84,9 +84,7 @@ mod test { IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)) ); - let v6 = [ - 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - ]; + let v6 = [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; assert_eq!( TcpEndpoint::from_ipv6(v6, 443).ip, IpAddr::V6(Ipv6Addr::from(v6)) diff --git a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs index 7afabac4..ac0c5713 100644 --- a/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs +++ b/etherparse/src/tcp_reassembly/tcp_reassemble_error.rs @@ -51,9 +51,10 @@ pub enum TcpReassembleError { /// Error if a segment would require tracking more connections than the /// [`crate::tcp_reassembly::TcpStreamReassemblyPool`] allows. /// - /// Evict connections that are no longer of interest (e.g. via - /// [`crate::tcp_reassembly::TcpStreamReassemblyPool::retain`]) to make - /// room for new ones. + /// Evict connections that are no longer of interest (e.g. the inactive + /// ones via + /// [`crate::tcp_reassembly::TcpStreamReassemblyPool::evict_older_than`]) + /// to make room for new ones. TooManyConnections { /// Maximum number of connections that can be tracked at the same /// time. @@ -149,10 +150,6 @@ mod tests { .source() .is_none()); assert!(TooManySections { max_sections: 0 }.source().is_none()); - assert!(TooManyConnections { - max_connections: 0 - } - .source() - .is_none()); + assert!(TooManyConnections { max_connections: 0 }.source().is_none()); } } diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs index e5bb2fed..15a14f7d 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_buf.rs @@ -630,8 +630,9 @@ impl TcpStreamReassemblyBuf { // shifted into the re-anchored coordinates if required (the payload // window check below catches data segments either way, this matters // for FINs without storable payload). - let fin_limit = - self.plausible_offset_limit().saturating_add(rebase_shift as u64); + let fin_limit = self + .plausible_offset_limit() + .saturating_add(rebase_shift as u64); let planned_fin: Option = if fin && self.fin_offset.is_none() && eff_end >= cursor @@ -773,8 +774,7 @@ impl TcpStreamReassemblyBuf { // gap in front of an already received section let from = (pos - start) as usize; let to = (sec.start - start) as usize; - self.data[data_start + from..data_start + to] - .copy_from_slice(&p[from..to]); + self.data[data_start + from..data_start + to].copy_from_slice(&p[from..to]); } pos = core::cmp::max(pos, sec.end); if pos >= end { @@ -1601,8 +1601,7 @@ mod test { #[test] fn too_many_sections() { - let mut buf = - new_buf_capacity(1 << 16).with_max_sections(2); + let mut buf = new_buf_capacity(1 << 16).with_max_sections(2); buf.add(0, &sequence(0, 1), false).unwrap(); buf.add(10, &sequence(10, 1), false).unwrap(); assert_eq!(buf.sections().len(), 2); diff --git a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs index 72ebe4ce..78780931 100644 --- a/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs +++ b/etherparse/src/tcp_reassembly/tcp_stream_reassembly_pool.rs @@ -104,6 +104,13 @@ pub enum TcpReassemblyEvent<'a, CustomChannelId = ()> { /// reconstructed from there on. They can be identified via /// [`TcpStreamReassemblyBuf::syn_observed`]. /// +/// Segments that carry nothing new for the reconstruction are accepted as +/// well and only refresh the timestamp of the connection (see +/// [`TcpStreamReassemblyPool::evict_older_than`]): keep alive probes (whose +/// sequence number points at the last byte that was already sent, optionally +/// with one byte of payload that a receiver discards), the acknowledgments +/// answering them, window updates and re-transmits. +/// /// # IP fragmentation /// /// [`TcpStreamReassemblyPool::process_sliced_packet`] ignores IP fragments @@ -134,8 +141,25 @@ pub enum TcpReassemblyEvent<'a, CustomChannelId = ()> { /// opening up many parallel connections. Set /// [`TcpStreamReassemblyPool::with_max_connections`] to bound it (new /// connections are then rejected with -/// [`TcpReassembleError::TooManyConnections`]) and evict stale connections -/// via [`TcpStreamReassemblyPool::retain`]. +/// [`TcpReassembleError::TooManyConnections`]). +/// +/// # Discarding stale connections +/// +/// Connections are never discarded automatically: a connection whose end is +/// never observed (no `FIN`, no `RST`, e.g. because one side crashed or the +/// capture simply does not contain the rest of it) would otherwise occupy +/// memory forever. +/// +/// Pass the capture time of the packets as `Timestamp` and discard the +/// connections that have been inactive for too long via +/// [`TcpStreamReassemblyPool::evict_older_than`] (or +/// [`TcpStreamReassemblyPool::evict_older_than_with`] to get at the data of +/// the discarded connections first). Every segment counts as activity, also +/// keep alives & pure acknowledgments, see +/// [`DEFAULT_TCP_CONNECTION_TIMEOUT`] for choosing the timeout. +/// [`TcpStreamReassemblyPool::retain`] covers everything that is not purely +/// time based (e.g. dropping connections that reached +/// [`TcpConnection::is_closed`]). #[derive(Debug, Clone)] pub struct TcpStreamReassemblyPool { /// Currently reconstructing TCP connections. @@ -342,6 +366,7 @@ where /// Segments starting a new connection while the limit is reached are /// rejected with [`TcpReassembleError::TooManyConnections`]. Evict /// connections that are no longer of interest via + /// [`TcpStreamReassemblyPool::evict_older_than`], /// [`TcpStreamReassemblyPool::retain`] or /// [`TcpStreamReassemblyPool::end_connection`] to make room. pub fn with_max_connections( @@ -632,10 +657,7 @@ where } /// Direct access to an active connection. - pub fn connection( - &self, - id: &TcpConnectionId, - ) -> Option<&TcpConnection> { + pub fn connection(&self, id: &TcpConnectionId) -> Option<&TcpConnection> { self.active.get(id).map(|(connection, _)| connection) } @@ -692,19 +714,35 @@ where self.active.iter_mut().map(|(id, v)| (id, &mut v.0, &v.1)) } + /// Timestamp of the last segment that was added to the given connection + /// (`None` if the connection is not tracked). + /// + /// See [`TcpStreamReassemblyPool::evict_older_than`] for what counts as + /// activity. + #[inline] + pub fn last_activity(&self, id: &TcpConnectionId) -> Option<&Timestamp> { + self.active.get(id).map(|(_, timestamp)| timestamp) + } + /// Retains only the connections specified by the predicate and recycles - /// the buffers of the evicted ones (e.g. to remove connections that have - /// not received data for a while based on the `Timestamp`). + /// the buffers of the evicted ones. + /// + /// The connection is passed to the predicate mutably, so data that was + /// reconstructed but never handed out can be drained before the + /// connection is dropped (see [`TcpStreamReassemblyBuf::drain_unacked`]). + /// + /// Use [`TcpStreamReassemblyPool::evict_older_than`] to discard + /// connections purely based on how long they have been inactive. pub fn retain(&mut self, mut f: F) where - F: FnMut(&TcpConnectionId, &Timestamp) -> bool, + F: FnMut(&TcpConnectionId, &mut TcpConnection, &Timestamp) -> bool, { let finished_data_bufs = &mut self.finished_data_bufs; let finished_section_bufs = &mut self.finished_section_bufs; let max_pooled_bufs = self.max_pooled_bufs; let max_pooled_buf_capacity = self.max_pooled_buf_capacity; self.active.retain(|id, value| { - if f(id, &value.1) { + if f(id, &mut value.0, &value.1) { true } else { recycle_connection( @@ -724,6 +762,106 @@ where } }); } + + /// Discards all connections whose last activity is older than `cutoff` + /// (recycling their buffers) and returns how many were discarded. + /// + /// Data that was reconstructed but never handed out is dropped together + /// with the connection, use + /// [`TcpStreamReassemblyPool::evict_older_than_with`] to get at it first. + /// + /// Note that discarding a connection that is still alive is not just a + /// loss of the buffered data: a later segment of it starts a *new* + /// connection that is anchored on that segment (as if the capture had + /// started in the middle of the connection, see + /// [`TcpStreamReassemblyBuf::syn_observed`]). + /// + /// # What counts as activity + /// + /// The timestamp of a connection is updated by every segment that was + /// successfully added to it, **including segments that carry no data**: + /// keep alive probes, the acknowledgments answering them, window updates + /// and re-transmits all keep the connection alive, in either direction. + /// So the cutoff only has to outlast the keep alive interval of the + /// connections that are of interest, not their idle time (see + /// [`DEFAULT_TCP_CONNECTION_TIMEOUT`]). + /// + /// Segments that were rejected with a [`TcpReassembleError`] do *not* + /// count as activity, so a stream that is stalled (e.g. a segment that + /// was never re-transmitted followed by a full buffer, see + /// [`TcpStreamReassemblyBuf::skip_gap`]) is eventually discarded as well. + /// + /// # Timestamps + /// + /// The pool does not interpret its `Timestamp` type, it only requires it + /// to be comparable and compares with "less than", so the caller decides + /// what "older" means (e.g. the capture time of the packets or the wall + /// clock time at which they were processed). Note that pcap timestamps + /// are not guaranteed to increase monotonically. + /// + /// ``` + /// # use etherparse::tcp_reassembly::*; + /// # use std::time::{Duration, SystemTime}; + /// # let mut pool = TcpStreamReassemblyPool::::new(); + /// # let packet_timestamp = SystemTime::now(); + /// // "now" in capture time, e.g. the timestamp of the last read packet + /// if let Some(cutoff) = packet_timestamp.checked_sub(DEFAULT_TCP_CONNECTION_TIMEOUT) { + /// pool.evict_older_than(&cutoff); + /// } + /// ``` + /// + /// The check runs over all tracked connections, so call it in intervals + /// (e.g. once per second of capture time) instead of once per packet. + pub fn evict_older_than(&mut self, cutoff: &Timestamp) -> usize + where + Timestamp: PartialOrd, + { + self.evict_older_than_with(cutoff, |_, _| {}) + } + + /// Discards all connections whose last activity is older than `cutoff` + /// after handing each of them to the given closure (e.g. to drain the + /// data that was reconstructed but never handed out) and returns how many + /// were discarded. + /// + /// See [`TcpStreamReassemblyPool::evict_older_than`] for what counts as + /// activity & how the timestamps are compared. + /// + /// ``` + /// # use etherparse::tcp_reassembly::*; + /// # let mut pool = TcpStreamReassemblyPool::::new(); + /// # let cutoff = 0u64; + /// pool.evict_older_than_with(&cutoff, |id, connection| { + /// for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + /// let stream = connection.stream_mut(direction); + /// // the connection is gone, so the data that was never + /// // acknowledged is taken as well + /// let len = stream.contiguous_unacked().len(); + /// if len > 0 { + /// println!("{} -> {}: {} leftover bytes", + /// id.source(direction).port, id.destination(direction).port, len); + /// stream.consume_unacked(len); + /// } + /// } + /// }); + /// ``` + pub fn evict_older_than_with(&mut self, cutoff: &Timestamp, mut f: F) -> usize + where + Timestamp: PartialOrd, + F: FnMut(&TcpConnectionId, &mut TcpConnection), + { + let mut evicted = 0; + self.retain(|id, connection, timestamp| { + if *timestamp < *cutoff { + f(id, connection); + evicted += 1; + false + } else { + true + } + }); + evicted + } } impl Default for TcpStreamReassemblyPool @@ -755,7 +893,12 @@ mod test { const A_TO_B: TcpDirection = TcpDirection::FirstToSecond; /// Segment travelling from a to b (`reverse == false`) or from b to a. - fn segment(reverse: bool, seq: u32, payload: &[u8], channel_id: u16) -> TcpSegmentInfo<'_, u16> { + fn segment( + reverse: bool, + seq: u32, + payload: &[u8], + channel_id: u16, + ) -> TcpSegmentInfo<'_, u16> { let (source, destination) = if reverse { (endpoint_b(), endpoint_a()) } else { @@ -798,9 +941,7 @@ mod test { } /// Unwraps the stream of the opposite direction. - fn receiver( - ev: TcpReassemblyEvent<'_, C>, - ) -> &mut TcpStreamReassemblyBuf { + fn receiver(ev: TcpReassemblyEvent<'_, C>) -> &mut TcpStreamReassemblyBuf { match ev { TcpReassemblyEvent::Segment { receiver, .. } => receiver, other => panic!("expected TcpReassemblyEvent::Segment, got {other:?}"), @@ -1369,20 +1510,176 @@ mod test { assert_eq!(pool.active_connections(), 2); // no-op retain - pool.retain(|_, ts| *ts > 0); + pool.retain(|_, _, ts| *ts > 0); assert_eq!(pool.active_connections(), 2); // evict timestamp 1 (the connection id is passed to the predicate too) - pool.retain(|id, ts| { + let mut leftover = Vec::new(); + pool.retain(|id, connection, ts| { assert_eq!(id.second().port, 80); - *ts > 1 + if *ts > 1 { + true + } else { + // the connection is handed out mutably, so the data that was + // never consumed can be taken before it is dropped + leftover.push(connection.stream_mut(A_TO_B).contiguous_unacked().to_vec()); + false + } }); + assert_eq!(leftover, std::vec![sequence(0, 8)]); assert_eq!(pool.active_connections(), 1); assert_eq!(pool.finished_data_bufs.len(), 2); assert_eq!(pool.finished_section_bufs.len(), 2); assert!(pool.connection(&conn_id(1)).is_some()); } + #[test] + fn evict_older_than_discards_and_recycles() { + let mut pool = new_pool::(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), 10) + .unwrap(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 1), 20) + .unwrap(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 2), 30) + .unwrap(); + assert_eq!(pool.active_connections(), 3); + assert_eq!(pool.last_activity(&conn_id(0)), Some(&10)); + assert_eq!(pool.last_activity(&conn_id(3)), None); + + // nothing is older than the cutoff (the comparison is "less than", so + // a connection with the cutoff as timestamp is kept) + assert_eq!(pool.evict_older_than(&10), 0); + assert_eq!(pool.active_connections(), 3); + + // only the connection that was inactive for too long is discarded & + // its buffers are recycled + assert_eq!(pool.evict_older_than(&20), 1); + assert_eq!(pool.active_connections(), 2); + assert!(pool.connection(&conn_id(0)).is_none()); + assert!(pool.connection(&conn_id(1)).is_some()); + assert_eq!(pool.finished_data_bufs.len(), 2); + assert_eq!(pool.finished_section_bufs.len(), 2); + + assert_eq!(pool.evict_older_than(&1000), 2); + assert_eq!(pool.active_connections(), 0); + } + + #[test] + fn evict_older_than_with_drains() { + let mut pool = new_pool::(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), 10) + .unwrap(); + pool.process_tcp(segment(true, 5000, &sequence(8, 4), 0), 10) + .unwrap(); + pool.process_tcp(segment(false, 1000, &sequence(0, 2), 1), 30) + .unwrap(); + + // the data of the discarded connections can be drained before they + // are dropped (both directions & including the data that was never + // acknowledged, as the connection is gone afterwards) + let mut drained = Vec::new(); + let evicted = pool.evict_older_than_with(&20, |id, connection| { + for direction in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + let stream = connection.stream_mut(direction); + let data = stream.contiguous_unacked().to_vec(); + stream.consume_unacked(data.len()); + drained.push((*id.channel_id(), direction, data)); + } + }); + + assert_eq!(evicted, 1); + assert_eq!( + drained, + std::vec![ + (0, TcpDirection::FirstToSecond, sequence(0, 8)), + (0, TcpDirection::SecondToFirst, sequence(8, 4)), + ] + ); + assert_eq!(pool.active_connections(), 1); + assert!(pool.connection(&conn_id(1)).is_some()); + } + + /// Keep alive probes (and the acknowledgments answering them) must keep a + /// connection from being discarded as inactive, otherwise a connection + /// that is idle on the application layer but alive on the wire is lost. + #[test] + fn keep_alives_refresh_the_timestamp() { + for keep_alive_payload in [&[][..], &[0u8][..]] { + let mut pool = new_pool::(); + + // a connection with data in both directions + let payload = sequence(0, 8); + pool.process_tcp(segment(false, 1000, &payload, 0), 10) + .unwrap(); + let buf = sender( + pool.process_tcp(segment(true, 5000, &sequence(8, 4), 0), 10) + .unwrap(), + ); + // consume it, so the read cursor sits behind the data the keep + // alive probe references + let len = buf.contiguous().len(); + buf.consume(len); + let buf = pool.stream_mut(&conn_id(0), A_TO_B).unwrap(); + let len = buf.contiguous().len(); + buf.consume(len); + assert_eq!(pool.last_activity(&conn_id(0)), Some(&10)); + + // keep alive probe: the sequence number points at the last byte + // that was already sent (RFC 1122 4.2.3.6), optionally with one + // byte of payload that the receiver discards + let probe = segment( + false, + 1000 + payload.len() as u32 - 1, + keep_alive_payload, + 0, + ); + let buf = sender(pool.process_tcp(probe, 500).unwrap()); + // the probe is not part of the stream (it only re-sends data that + // was already handed out) + assert!(buf.contiguous().is_empty()); + assert_eq!(pool.last_activity(&conn_id(0)), Some(&500)); + + // the acknowledgment answering it keeps the connection alive too + let mut reply = segment(true, 5000 + 4, &[], 0); + reply.acknowledgment_number = Some(1000 + payload.len() as u32); + pool.process_tcp(reply, 900).unwrap(); + assert_eq!(pool.last_activity(&conn_id(0)), Some(&900)); + + // ... so the connection survives an eviction that would have + // discarded it based on the timestamp of the last data segment + assert_eq!(pool.evict_older_than(&500), 0); + assert_eq!(pool.active_connections(), 1); + + // (while it is discarded once the keep alives stop) + assert_eq!(pool.evict_older_than(&901), 1); + assert_eq!(pool.active_connections(), 0); + } + } + + /// Segments that are not attributed to a connection must not keep it + /// alive. + #[test] + fn rejected_segments_do_not_refresh_the_timestamp() { + let mut pool = new_pool::(); + pool.process_tcp(segment(false, 1000, &sequence(0, 8), 0), 10) + .unwrap(); + + // a segment that is rejected leaves the connection unchanged + let payload = sequence(0, 8); + let mut oversized = segment(false, 1000, &payload, 0); + oversized.sequence_number = 1000u32.wrapping_add(1 << 30); + assert!(pool.process_tcp(oversized, 20).is_err()); + assert_eq!(pool.last_activity(&conn_id(0)), Some(&10)); + + // an out of window RST is ignored & does not keep the connection alive + let mut rst = segment(false, 1000u32.wrapping_add(1 << 30), &[], 0); + rst.rst = true; + assert_ignored(pool.process_tcp(rst, 30).unwrap()); + assert_eq!(pool.last_activity(&conn_id(0)), Some(&10)); + + assert_eq!(pool.evict_older_than(&20), 1); + } + #[test] fn free_lists_are_bounded() { // evicting many connections must not make the pool hold on to the @@ -1395,7 +1692,7 @@ mod test { } assert_eq!(pool.active_connections(), 200); - pool.retain(|_, _| false); + pool.retain(|_, _, _| false); assert_eq!(pool.active_connections(), 0); assert_eq!(pool.finished_data_bufs.len(), DEFAULT_MAX_TCP_POOLED_BUFS); @@ -1466,7 +1763,7 @@ mod test { assert_eq!(buf.contiguous(), &sequence(0, 8)[..]); // evicting makes room again - pool.retain(|id, _| *id.channel_id() != 0); + pool.retain(|id, _, _| *id.channel_id() != 0); assert_eq!(pool.active_connections(), 1); pool.process_tcp(segment(false, 1000, &payload, 2), ()) .unwrap(); @@ -1502,7 +1799,7 @@ mod test { // the connection is not evicted automatically, the data is still there assert_eq!(pool.active_connections(), 1); - pool.retain(|_, _| false); + pool.retain(|_, _, _| false); assert_eq!(pool.active_connections(), 0); } @@ -1531,7 +1828,8 @@ mod test { #[test] fn non_tcp_and_process_sliced_packet() { - let mut pool = TcpStreamReassemblyPool::<(), ()>::new().with_ack_policy(TcpAckPolicy::Ignore); + let mut pool = + TcpStreamReassemblyPool::<(), ()>::new().with_ack_policy(TcpAckPolicy::Ignore); // empty sliced packet -> Ignored let empty = SlicedPacket { @@ -1554,10 +1852,7 @@ mod test { let pdata = build_ipv4_tcp_packet(1008, false, false, true, &[]); let slice = SlicedPacket::from_ethernet(&pdata).unwrap(); let connection = closed(pool.process_sliced_packet(&slice, (), ()).unwrap()); - assert_eq!( - connection.stream(A_TO_B).contiguous_unacked(), - &payload[..] - ); + assert_eq!(connection.stream(A_TO_B).contiguous_unacked(), &payload[..]); assert_eq!(pool.active_connections(), 0); } From f41c39f32a1d169a3803a19ea0ab2caed907ddc4 Mon Sep 17 00:00:00 2001 From: Julian Schmid Date: Sun, 16 Aug 2026 07:10:13 +0200 Subject: [PATCH 8/8] Add example to export tcp streams --- etherparse/examples/pcap_tcp_extract.rs | 1505 +++++++++++++++++++++++ 1 file changed, 1505 insertions(+) create mode 100644 etherparse/examples/pcap_tcp_extract.rs diff --git a/etherparse/examples/pcap_tcp_extract.rs b/etherparse/examples/pcap_tcp_extract.rs new file mode 100644 index 00000000..2982d1de --- /dev/null +++ b/etherparse/examples/pcap_tcp_extract.rs @@ -0,0 +1,1505 @@ +//! Reads a PCAP file, re-assembles fragmented IP packets, reconstructs the +//! payload byte streams of all TCP connections and writes them to disk +//! together with an index & a per connection log of what every packet did: +//! +//! ```sh +//! cargo run --example pcap_tcp_extract -- capture.pcap --output extracted +//! ``` +//! +//! # Output layout +//! +//! ```text +//! extracted/ +//! index.jsonl one JSON object per connection (see below) +//! data/1_orig.bin reconstructed byte stream originator -> responder +//! data/1_resp.bin reconstructed byte stream responder -> originator +//! info/1.txt what every packet of connection 1 contributed +//! ``` +//! +//! The file name prefix is the connection id, which is also the `id` field of +//! the index. Ids are handed out in the order the connections are first seen. +//! Data files are only created if the direction actually carried data (see the +//! `files` field of the index). +//! +//! ## `index.jsonl` +//! +//! One JSON object per line ("JSON Lines" / NDJSON), written as soon as a +//! connection is finished (so the file is usable while a long capture is still +//! being processed and nothing has to be kept in memory). The records are +//! therefore ordered by *end* time, not by id. +//! +//! The field names follow [Zeek](https://docs.zeek.org)'s `conn.log`, which is +//! the de facto standard for "one record per connection" logs and is what +//! existing tooling (jq recipes, pandas, SIEM ingests, ...) expects: +//! +//! | field | meaning | +//! |--------------------------------------------------|--------------------------------------------------| +//! | `ts`, `ts_iso` | time of the first packet of the connection | +//! | `ts_end`, `ts_end_iso`, `duration` | time of the last packet & the time in between | +//! | `id.orig_h`, `id.orig_p`, `id.resp_h`, `id.resp_p` | endpoints ("originator" = sender of the first packet seen) | +//! | `proto`, `vlan`, `inner_vlan` | `"tcp"` & the VLAN ids of the packets | +//! | `orig_pkts`, `resp_pkts` | number of segments per direction | +//! | `orig_bytes`, `resp_bytes` | reconstructed payload bytes per direction | +//! | `conn_state` | Zeek connection state (`SF`, `S1`, `REJ`, ...) | +//! | `history` | Zeek history string (see `History` below) | +//! +//! Everything Zeek has no field for is added under its own name: `id`, +//! `midstream`, `closed_by`, `files` and the per direction +//! `*_syn_observed` / `*_fin_observed` / `*_complete` / `*_missing_bytes`. +//! +//! ## `data/_.bin` +//! +//! The raw reconstructed byte stream, one file per direction (the convention +//! [tcpflow](https://github.com/simsong/tcpflow) uses, which encodes the +//! endpoints in the file name instead of using an index file). +//! +//! Bytes that were never captured (a segment that is missing from the capture +//! and never re-transmitted) are **skipped**, so the file only contains bytes +//! that were really observed. The number of missing bytes ends up in the index +//! (`*_missing_bytes`) and their position in the info file. Note that tcpflow +//! makes the opposite choice and fills gaps with zero bytes, which keeps "file +//! offset == stream offset" at the price of fabricating data. +//! +//! ## `info/.txt` +//! +//! One line per packet of the connection, stating what it changed about the +//! reconstruction: new bytes, re-transmits, gaps, acknowledgments, how much +//! data it released & how the connection was started and ended. Useful to +//! understand why a reconstructed stream looks the way it does. +//! +//! # Notes +//! +//! Only the classic `.pcap` format is supported (not `.pcapng`, convert via +//! `tshark -r capture.pcapng -F pcap -w capture.pcap`) with the link types +//! "Ethernet" and "Raw IP". To keep the example small there is no extra +//! handling for captures with a limited snapshot length (truncated packets are +//! reported as parse errors and skipped). +//! +//! Connections whose end is never observed (no `FIN`, no `RST`, e.g. because +//! one side crashed or the capture does not contain the rest of them) would +//! occupy memory until the end of the capture, so connections that have been +//! inactive for too long are finished early (`--timeout`, in seconds of +//! capture time, `0` disables it). +//! +//! See the `pcap_tcp_reassembly` example for a smaller introduction that just +//! prints the reconstructed streams. + +use clap::Parser; +use etherparse::{defrag::*, tcp_reassembly::*, *}; +use rpcap::{read::PcapReader, Linktype}; +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Extracts the payload byte streams of all TCP connections in a pcap file +/// (re-assembling fragmented IP packets on the way) into a directory. +#[derive(Parser)] +struct Args { + /// Pcap file to read (classic pcap format, not pcapng). + file: PathBuf, + + /// Directory the index, the streams & the packet infos are written to + /// (created if it does not exist). + #[arg(short, long, default_value = "extracted")] + output: PathBuf, + + /// Finish connections that have not seen a segment for this many seconds + /// of capture time (0 disables it). + #[arg(short, long, default_value_t = DEFAULT_TCP_CONNECTION_TIMEOUT.as_secs())] + timeout: u64, +} + +/// Set if writing any of the output files failed (the process then ends with +/// a non zero exit code, so a truncated result is not mistaken for a +/// successful extraction). +static WRITE_FAILED: AtomicBool = AtomicBool::new(false); + +/// How often the inactive connections are looked for (in capture time). +/// +/// The check runs over all tracked connections, so it is done in intervals +/// instead of once per packet. +const EVICTION_INTERVAL: Duration = Duration::from_secs(1); + +/// Time after which the fragments of an IP packet that was never completed +/// are discarded. +/// +/// RFC 791 recommends 15 seconds for the re-assembly of a packet, real +/// implementations tend to use more (e.g. 30 seconds on Linux). +const IP_FRAGMENT_TIMEOUT: Duration = Duration::from_secs(30); + +fn main() { + let args = Args::parse(); + let path = args.file.display().to_string(); + + let file = match std::fs::File::open(&args.file) { + Ok(v) => v, + Err(err) => exit_with(&format!("Error opening '{path}': {err}")), + }; + let (file_options, mut pcap_reader) = match PcapReader::new(std::io::BufReader::new(file)) { + Ok(v) => v, + Err(err) => exit_with(&format!("Error parsing '{path}': {err}")), + }; + let is_ethernet = match file_options.linktype { + l if l == Linktype::ETHERNET as u32 => true, + l if l == Linktype::RAW as u32 => false, + other => exit_with(&format!("Unsupported pcap link type {other}")), + }; + + // pool re-assembling fragmented IPv4 & IPv6 packets (the capture time of + // the packets is used to discard incomplete ones later on) + let mut defrag_pool = IpDefragPool::::new(); + + // pool reconstructing the payload streams of the TCP connections (by + // default only data that the receiver acknowledged is handed out) + let mut tcp_pool = TcpStreamReassemblyPool::::new(); + + // writes the index, the stream data & the per packet infos + let mut out = match Output::new(&args.output) { + Ok(v) => v, + Err(err) => exit_with(&format!( + "Error preparing the output directory '{}': {err}", + args.output.display() + )), + }; + + // capture time at which the next check for inactive connections is due + let timeout = Duration::from_secs(args.timeout); + let mut next_eviction: Option = None; + + let mut frame = 0u64; + let mut parse_errors = 0u64; + + loop { + // the packet data is borrowed from the reader's internal buffer, so + // reading the next packet does not allocate or copy + let packet = match pcap_reader.next() { + Ok(Some(v)) => v, + Ok(None) => break, + Err(err) => { + eprintln!("Error reading pcap packet record: {err}"); + break; + } + }; + frame += 1; + + // finish the connections that have been inactive for too long (before + // the packet is processed, so a connection is never discarded because + // of the time that passed while it was being processed) + if false == timeout.is_zero() && next_eviction.is_none_or(|next| packet.time >= next) { + next_eviction = packet.time.checked_add(EVICTION_INTERVAL); + evict_inactive( + &mut tcp_pool, + &mut defrag_pool, + &mut out, + packet.time, + timeout, + ); + } + + // slice the packet into its different header components + let sliced = if is_ethernet { + SlicedPacket::from_ethernet(packet.data) + } else { + SlicedPacket::from_ip(packet.data) + }; + let sliced = match sliced { + Ok(v) => v, + Err(err) => { + eprintln!("Error parsing packet {frame}: {err}"); + parse_errors += 1; + continue; + } + }; + + if sliced.is_ip_payload_fragmented() { + // the TCP layer of a fragmented packet is not decoded, the IP + // payload has to be re-assembled from all fragments first + match defrag_pool.process_sliced_packet(&sliced, packet.time, ()) { + Ok(Some(finished)) => { + if finished.ip_number == IpNumber::TCP { + // the re-assembled payload is the TCP segment, while + // the addresses are taken from the (last) fragment + match TcpSegmentInfo::from_defragmented_payload( + &sliced, + &finished.payload, + (), + ) { + Ok(Some(info)) => { + out.feed(&mut tcp_pool, info, packet.time, frame); + } + Ok(None) => { + // no IP header (cannot happen for a re-assembled packet) + } + Err(err) => { + eprintln!("Error parsing re-assembled TCP segment: {err}"); + parse_errors += 1; + } + } + } + // return the buffer to avoid unneeded allocations + defrag_pool.return_buf(finished); + } + Ok(None) => { + // not all fragments received yet + } + Err(err) => eprintln!("Error re-assembling fragmented IP packet: {err}"), + } + } else if let Some(info) = TcpSegmentInfo::from_sliced_packet(&sliced, ()) { + out.feed(&mut tcp_pool, info, packet.time, frame); + } + } + + // At the end of a capture: drain whatever is left. The last data of a + // capture is usually never acknowledged (the capture ends before the ACK + // arrives), so the acknowledgment requirement is ignored here. + for (id, connection, _timestamp) in tcp_pool.iter_mut() { + out.close(id, Some(connection), CloseReason::CaptureEnd); + } + + out.flush(); + println!( + "{frame} packets read ({parse_errors} not parsable), \ + {} connections & {} reconstructed bytes written to '{}'", + out.finished, + out.written_bytes, + args.output.display(), + ); + if WRITE_FAILED.load(Ordering::Relaxed) { + exit_with("Some of the output could not be written (see the errors above)"); + } +} + +/// Finishes the connections that have not seen a segment for `timeout` (and +/// discards the fragments of IP packets that were never completed). +/// +/// Every segment refreshes the timestamp of its connection, also the ones that +/// carry no data (keep alive probes, the acknowledgments answering them, +/// window updates & re-transmits), so a connection that is idle on the +/// application layer but alive on the wire is not finished here. +fn evict_inactive( + tcp_pool: &mut TcpStreamReassemblyPool, + defrag_pool: &mut IpDefragPool, + out: &mut Output, + now: SystemTime, + timeout: Duration, +) { + // pcap timestamps are not guaranteed to be monotonic (and the capture may + // start before the timeout has passed since the unix epoch) + if let Some(cutoff) = now.checked_sub(timeout) { + tcp_pool.evict_older_than_with(&cutoff, |id, connection| { + // last chance to get at the data of the connection + out.close(id, Some(connection), CloseReason::Timeout); + }); + } + + if let Some(cutoff) = now.checked_sub(IP_FRAGMENT_TIMEOUT) { + defrag_pool.retain(|timestamp| *timestamp >= cutoff); + } +} + +/// Prints an error & ends the process. +fn exit_with(message: &str) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} + +/// Why a connection was finished (the `closed_by` field of the index). +#[derive(Clone, Copy, PartialEq, Eq)] +enum CloseReason { + /// Both directions reached their FIN. + Fin, + + /// A RST ended the connection. + Rst, + + /// A SYN with a new initial sequence number started a new connection + /// re-using the same endpoints. + Reconnect, + + /// No segment was seen for `--timeout`. + Timeout, + + /// The connection was still open when the capture ended. + CaptureEnd, +} + +impl CloseReason { + fn as_str(&self) -> &'static str { + match self { + CloseReason::Fin => "fin", + CloseReason::Rst => "rst", + CloseReason::Reconnect => "reconnect", + CloseReason::Timeout => "timeout", + CloseReason::CaptureEnd => "capture_end", + } + } +} + +/// Writes the index, the reconstructed streams & the per packet infos. +struct Output { + /// Directory everything is written to. + dir: PathBuf, + + /// `index.jsonl`, one record per finished connection. + index: Sink, + + /// Id handed out to the next connection. + next_id: u64, + + /// Connections that are currently being reconstructed. + conns: HashMap, + + /// Number of connections that were written to the index. + finished: u64, + + /// Number of reconstructed stream bytes that were written. + written_bytes: u64, +} + +impl Output { + fn new(dir: &Path) -> Result { + std::fs::create_dir_all(dir.join("data"))?; + std::fs::create_dir_all(dir.join("info"))?; + Ok(Output { + dir: dir.to_path_buf(), + index: Sink::new(dir.join("index.jsonl")), + next_id: 1, + conns: HashMap::new(), + finished: 0, + written_bytes: 0, + }) + } + + /// Feeds a segment into the reassembly & records what it did. + fn feed( + &mut self, + pool: &mut TcpStreamReassemblyPool, + info: TcpSegmentInfo<'_>, + time: SystemTime, + frame: u64, + ) { + // The id is built here (instead of taking the one the events carry) + // so it is available before & after the segment is processed. + let (id, direction) = + TcpConnectionId::new(info.source, info.destination, info.vlan_ids.clone(), ()); + + // values that are needed after `info` was moved into the pool + let segment = Segment { + frame, + time, + direction, + seq: info.sequence_number, + ack: info.acknowledgment_number, + syn: info.syn, + fin: info.fin, + rst: info.rst, + len: info.payload.len() as u64, + }; + + // state of the connection before the segment is added (what the + // segment changes is derived by comparing against it afterwards) + let before = Before::capture(pool.connection(&id), &segment); + + let mut effects = describe(&before, &segment); + // set if the follow up work has to happen after the borrow of the + // event ended (see the `Closed` case below) + let mut started_after_close = false; + // set once both directions ended (the connection can be finished) + let mut completed = false; + + match pool.process_tcp(info, time) { + Ok(TcpReassemblyEvent::Segment { + sender, receiver, .. + }) => { + // A SYN with a new initial sequence number replaces the + // connection. The pool only reports that via `Closed` if the + // replaced connection still held undelivered data, so the + // (already detected) reconnect is finished here. + if before.reconnect { + self.close(&id, None, CloseReason::Reconnect); + } + + if let Some(released) = acknowledged(&before, receiver) { + effects.push(format!( + "acknowledges {released} more bytes of the reverse stream" + )); + } + // both directions ended, so nothing can arrive anymore + completed = sender.is_fin_reached() && receiver.is_fin_reached(); + if completed { + effects.push("both directions reached their FIN".to_string()); + } + + let conn = self.record(&id, direction, time); + conn.observe(&segment, &before); + + // the segment added its payload to "sender", while its + // acknowledgment number may have released data of "receiver" + deliver(conn, direction, sender, &mut effects); + deliver(conn, direction.reverse(), receiver, &mut effects); + conn.update_state(direction, sender, receiver); + } + Ok(TcpReassemblyEvent::Closed { connection, .. }) => { + // The connection ended (RST) or was replaced by a new one + // (SYN with a new initial sequence number): drain the data + // that was never consumed. The closing segment is usually not + // acknowledged anymore, so the unacknowledged data is taken + // as well. + let reason = if segment.rst { + CloseReason::Rst + } else { + CloseReason::Reconnect + }; + if let Some(conn) = self.conns.get_mut(&id) { + if segment.rst { + // the RST is the last packet of the connection that is + // ending, so it belongs into its info file (while the + // reconnecting SYN belongs to the new connection) + conn.observe(&segment, &before); + effects.push("connection reset".to_string()); + conn.info(&segment_line(&segment, conn.orig, &effects)); + } + for d in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + final_drain(conn, d, connection.stream_mut(d)); + } + let (first, second) = connection.streams_mut(TcpDirection::FirstToSecond); + conn.update_state(TcpDirection::FirstToSecond, first, second); + } + self.close(&id, None, reason); + started_after_close = false == segment.rst; + } + Ok(TcpReassemblyEvent::Ignored) => { + // The segment did not affect any connection. For a connection + // that is tracked this only happens for a RST that is not + // plausible for the stream it was sent in, so that a blindly + // injected RST cannot tear the connection down (segments of + // untracked connections end up here as well, but those have + // no info file the line below could be written to). + effects = vec![ + "ignored, no effect on the reconstruction (a RST outside of the \ + window of this stream, or a segment of an untracked connection)" + .to_string(), + ]; + } + Err(err) => { + if false == self.conns.contains_key(&id) { + // the segment did not create a connection, so there is no + // info file it could be documented in + eprintln!("Error reconstructing TCP stream (packet {frame}): {err}"); + return; + } + let conn = self.record(&id, direction, time); + conn.observe(&segment, &before); + // the reassembly leaves the streams unmodified on an error, + // so what the segment would have contributed did not happen + effects = vec![format!("rejected, the streams are unchanged: {err}")]; + } + } + + if started_after_close { + // the reconnect case: the SYN was added to the *new* connection, + // which is tracked under the same id (the borrow of the closed + // connection ended, so the pool can be accessed again) + let conn = self.record(&id, direction, time); + conn.observe(&segment, &before); + if let Some(connection) = pool.connection_mut(&id) { + let (sender, receiver) = connection.streams_mut(direction); + deliver(conn, direction, sender, &mut effects); + conn.update_state(direction, sender, receiver); + } + } + + // write the line describing the packet (in the RST case it was + // already written into the connection that ended) + if let Some(conn) = self.conns.get_mut(&id) { + conn.info(&segment_line(&segment, conn.orig, &effects)); + } + + // a connection whose both directions reached their FIN is finished + // right away (the pool keeps it around otherwise, as a FIN does not + // guarantee that nothing follows) + if completed { + self.close(&id, pool.connection_mut(&id), CloseReason::Fin); + pool.end_connection(&id); + } + } + + /// Returns the record of a connection, creating it if it is the first + /// segment (which also decides what the "originator" of the connection + /// is & hands out the id used in the file names). + fn record( + &mut self, + id: &TcpConnectionId, + direction: TcpDirection, + time: SystemTime, + ) -> &mut Conn { + let next_id = &mut self.next_id; + let dir = &self.dir; + let conn = self.conns.entry(id.clone()).or_insert_with(|| { + let new_id = *next_id; + *next_id += 1; + Conn::new(new_id, dir, id, direction, time) + }); + conn.last_time = time; + conn + } + + /// Finishes a connection: drains what is left (if the connection is still + /// available), writes its index record & closes its files. + fn close( + &mut self, + id: &TcpConnectionId, + connection: Option<&mut TcpConnection>, + reason: CloseReason, + ) { + let Some(mut conn) = self.conns.remove(id) else { + return; + }; + if let Some(connection) = connection { + for d in [TcpDirection::FirstToSecond, TcpDirection::SecondToFirst] { + final_drain(&mut conn, d, connection.stream_mut(d)); + } + let (first, second) = connection.streams_mut(TcpDirection::FirstToSecond); + conn.update_state(TcpDirection::FirstToSecond, first, second); + } + + // a connection that ended normally but was only noticed later (e.g. + // because a gap kept it from reaching its FIN earlier) + let reason = if (reason == CloseReason::CaptureEnd || reason == CloseReason::Timeout) + && conn.fin_observed[0] + && conn.fin_observed[1] + { + CloseReason::Fin + } else { + reason + }; + + self.written_bytes += conn.bytes[0] + conn.bytes[1]; + self.finished += 1; + self.index.write(conn.index_record(id, reason).as_bytes()); + conn.finish(); + } + + /// Finishes everything that is still open & flushes the index. + fn flush(&mut self) { + let open: Vec = self.conns.keys().cloned().collect(); + for id in open { + self.close(&id, None, CloseReason::CaptureEnd); + } + self.index.flush(); + } +} + +/// Hands the in-order data of one direction to its data file. +/// +/// The data has to be consumed continuously, otherwise the buffer of the +/// stream fills up until segments get rejected with +/// [`TcpReassembleError::SegmentBeyondMaxWindow`]. +fn deliver( + conn: &mut Conn, + direction: TcpDirection, + stream: &mut TcpStreamReassemblyBuf, + effects: &mut Vec, +) { + let index = direction_index(direction); + let start = stream.base_offset(); + let consumed = stream.drain(|bytes| { + conn.data[index].write(bytes); + bytes.len() + }) as u64; + + if consumed > 0 { + conn.bytes[index] += consumed; + conn.data_written[index] = true; + effects.push(format!( + "wrote {consumed} bytes to {} (stream offset {start})", + conn.data[index].name(), + )); + } +} + +/// Last chance to get at the data of a stream (the connection ended or the +/// capture is over). +/// +/// In addition to [`deliver`] this hands out the data that was never +/// acknowledged and gives up on the bytes that were never captured (see +/// [`TcpStreamReassemblyBuf::skip_gap`]), which would otherwise hold back +/// everything behind them. +/// +/// What it did is written to the info file directly, as there is no packet +/// the data could be attributed to anymore. +fn final_drain(conn: &mut Conn, direction: TcpDirection, stream: &mut TcpStreamReassemblyBuf) { + let index = direction_index(direction); + let role = role_name(direction, conn.orig); + loop { + let start = stream.base_offset(); + let consumed = stream.drain_unacked(|bytes| { + conn.data[index].write(bytes); + bytes.len() + }) as u64; + + if consumed > 0 { + conn.bytes[index] += consumed; + conn.data_written[index] = true; + conn.info(&format!( + "# {role}: wrote {consumed} bytes to {} (stream offset {start})", + conn.data[index].name(), + )); + } + + // give up on the bytes that were never captured & continue behind them + // (the drain above may have moved the read cursor, so the position of + // the gap is taken after it) + let gap_start = stream.base_offset(); + let skipped = stream.skip_gap(); + if skipped == 0 { + return; + } + conn.missing[index] += skipped; + conn.history.add(direction, conn.orig, 'G'); + conn.info(&format!( + "# {role}: gave up on {skipped} bytes that were never captured \ + (stream offset {gap_start})", + )); + } +} + +/// Describes what a segment contributed to the reconstruction (derived from +/// the state of the streams before it was added). +fn describe(before: &Before, segment: &Segment) -> Vec { + if before.reconnect { + // the offsets of the previous connection say nothing about this one + return vec![ + "new connection re-using the same endpoints (SYN with a new initial \ + sequence number)" + .to_string(), + ]; + } + + let mut effects = Vec::new(); + if false == before.known { + effects.push( + if segment.syn { + "new connection, stream start taken from the SYN" + } else { + "new connection, no SYN captured (stream anchored on this segment)" + } + .to_string(), + ); + } else if segment.syn { + // the connection is known, but this may still be the first segment of + // this direction (e.g. the SYN+ACK answering the initial SYN) + effects.push( + if before.offset.is_none() { + "stream start taken from the SYN" + } else { + "duplicated or re-ordered SYN" + } + .to_string(), + ); + } + + // the stream offset the payload of the segment starts at (a direction + // that has no base yet is anchored on this segment, so it starts at 0) + let start = before.offset.unwrap_or(0); + if segment.len > 0 { + let new = before.new_bytes(segment.len); + if new == segment.len { + effects.push(format!("{new} new bytes at stream offset {start}")); + } else if new == 0 { + effects.push(format!( + "{} bytes that were already received (re-transmit)", + segment.len + )); + } else { + effects.push(format!( + "{new} new bytes at stream offset {start} ({} already received, \ + {} in front of the read cursor)", + before.duplicate, before.stale, + )); + } + if before.gap > 0 { + effects.push(format!( + "{} bytes in front of it were not received yet", + before.gap + )); + } + } else if false == segment.syn && false == segment.fin && false == segment.rst { + effects.push("no payload (acknowledgment, window update or keep alive)".to_string()); + } + + if segment.fin { + effects.push(format!( + "stream ends (FIN) at stream offset {}", + start + segment.len as i128 + )); + } + effects +} + +/// Number of additional bytes of the reverse stream that the acknowledgment +/// number of the segment released (`None` if it released nothing). +fn acknowledged(before: &Before, receiver: &TcpStreamReassemblyBuf) -> Option { + let after = receiver.ack_offset()?; + let released = after.saturating_sub(before.reverse_ack.unwrap_or(0)); + if released > 0 { + Some(released) + } else { + None + } +} + +/// State of a connection before a segment was processed (used to describe +/// what the segment changed). +#[derive(Default)] +struct Before { + /// True if the connection was already tracked. + known: bool, + + /// True if this segment is the SYN of a **new** connection re-using the + /// same endpoints (the pool will replace the tracked connection). + reconnect: bool, + + /// Stream offset the payload of the segment starts at (`None` if the + /// direction has no base yet, i.e. the segment establishes it). + offset: Option, + + /// Payload bytes that were already received before. + duplicate: u64, + + /// Payload bytes that lie before the read cursor (already delivered or + /// skipped, so they are dropped). + stale: u64, + + /// Number of bytes between the last received byte & the start of this + /// segment (a re-ordered or lost segment). + gap: u64, + + /// Stream offset up to which the **reverse** direction was acknowledged. + reverse_ack: Option, +} + +impl Before { + fn capture(connection: Option<&TcpConnection>, segment: &Segment) -> Before { + let Some(connection) = connection else { + return Before::default(); + }; + let stream = connection.stream(segment.direction); + + // the SYN flag consumes one sequence number, the payload starts after it + let payload_seq = if segment.syn { + segment.seq.wrapping_add(1) + } else { + segment.seq + }; + let offset = stream.seq_stream_offset(payload_seq); + + // Mirrors the rule the reassembly itself uses: if the start of the + // stream is known only an exact match is a duplicated SYN (initial + // sequence numbers are random, so any other value belongs to a new + // connection), otherwise the buffer anchored itself on the first seen + // segment and a SYN at or before that anchor starts the same stream. + let reconnect = segment.syn + && match offset { + Some(start) => { + if stream.syn_observed() { + start != 0 + } else { + start > 0 + } + } + None => false, + }; + + let mut result = Before { + known: true, + reconnect, + offset, + reverse_ack: connection.stream(segment.direction.reverse()).ack_offset(), + ..Default::default() + }; + + if let Some(start) = offset { + let end = start + segment.len as i128; + let cursor = stream.base_offset() as i128; + result.stale = (core::cmp::min(end, cursor) - start).max(0) as u64; + + // bytes that were already received (a re-transmit or an overlap) + let from = core::cmp::max(start, cursor); + for section in stream.sections() { + let overlap = core::cmp::min(end, section.end as i128) + - core::cmp::max(from, section.start as i128); + if overlap > 0 { + result.duplicate += overlap as u64; + } + } + + // distance to the data that was received so far + let received_end = stream + .sections() + .last() + .map(|section| section.end as i128) + .unwrap_or(cursor); + if start > received_end { + result.gap = (start - received_end) as u64; + } + } + + result + } + + /// Payload bytes of the segment that are new for the reconstruction. + fn new_bytes(&self, len: u64) -> u64 { + len.saturating_sub(self.duplicate) + .saturating_sub(self.stale) + } +} + +/// Zeek style history string of a connection. +/// +/// Each letter stands for the first occurrence of an event, upper case for +/// the originator & lower case for the responder: +/// +/// | letter | event | +/// |--------|----------------------------------------------| +/// | `S` | `SYN` without `ACK` | +/// | `H` | `SYN` + `ACK` (the answer of the handshake) | +/// | `A` | `ACK` | +/// | `D` | segment carrying payload | +/// | `T` | re-transmitted payload | +/// | `G` | a gap of never captured bytes | +/// | `F` | `FIN` | +/// | `R` | `RST` | +#[derive(Default)] +struct History(String); + +impl History { + /// Records the first occurrence of an event. + fn add(&mut self, direction: TcpDirection, orig: TcpDirection, letter: char) { + let letter = if direction == orig { + letter.to_ascii_uppercase() + } else { + letter.to_ascii_lowercase() + }; + if false == self.0.contains(letter) { + self.0.push(letter); + } + } +} + +/// Everything that is collected about a connection while it is reconstructed. +/// +/// The data of a stream is handed out (and freed) piece by piece while the +/// capture is processed, so anything about the connection as a whole has to be +/// accumulated on the way. +struct Conn { + /// Id of the connection (index in the file names). + id: u64, + + /// Direction of the first segment that was seen ("originator" in the + /// Zeek sense, which is the sender of the SYN for a connection whose + /// start was captured). + orig: TcpDirection, + + /// Capture time of the first & the last segment. + first_time: SystemTime, + last_time: SystemTime, + + /// Number of segments per direction (see [`direction_index`]). + pkts: [u64; 2], + + /// Reconstructed bytes that were written per direction. + bytes: [u64; 2], + + /// Bytes that were never captured & had to be skipped per direction. + missing: [u64; 2], + + /// True once a data file was created for the direction. + data_written: [bool; 2], + + /// State of the two stream directions (kept up to date on every segment + /// so it is still available once the streams themselves are gone). + syn_observed: [bool; 2], + fin_observed: [bool; 2], + complete: [bool; 2], + ack_observed: [bool; 2], + + /// Zeek style history of the connection. + history: History, + + /// Files the reconstructed streams are written to. + data: [Sink; 2], + + /// File the per packet infos are written to. + info: Sink, +} + +impl Conn { + fn new( + id: u64, + dir: &Path, + conn_id: &TcpConnectionId, + orig: TcpDirection, + time: SystemTime, + ) -> Conn { + let mut data = [ + Sink::new(dir.join(format!("data/{id}_resp.bin"))), + Sink::new(dir.join(format!("data/{id}_resp.bin"))), + ]; + data[direction_index(orig)] = Sink::new(dir.join(format!("data/{id}_orig.bin"))); + + let mut result = Conn { + id, + orig, + first_time: time, + last_time: time, + pkts: [0; 2], + bytes: [0; 2], + missing: [0; 2], + data_written: [false; 2], + syn_observed: [false; 2], + fin_observed: [false; 2], + complete: [false; 2], + ack_observed: [false; 2], + history: History::default(), + data, + info: Sink::new(dir.join(format!("info/{id}.txt"))), + }; + + // same column widths as `segment_line` + let columns = format!( + "#{:>6} {:<27} {:<12} {:<12} {:>10} {:>10} {:>4} {}", + "frame", "time", "direction", "flags", "seq", "ack", "len", "effect", + ); + let vlan_ids = conn_id.vlan_ids(); + result.info(&format!( + "# connection {id}\n\ + # originator: {}\n\ + # responder: {}\n\ + # vlan ids: {}\n\ + # data: {} (originator -> responder)\n\ + # {} (responder -> originator)\n\ + #\n\ + # sequence & acknowledgment numbers are the raw values of the segments,\n\ + # stream offsets count the bytes of the reconstructed stream\n\ + #\n\ + {}", + endpoint(conn_id.source(orig)), + endpoint(conn_id.destination(orig)), + if vlan_ids.is_empty() { + "-".to_string() + } else { + vlan_ids + .iter() + .map(|v| v.value().to_string()) + .collect::>() + .join(", ") + }, + result.data[direction_index(orig)].name(), + result.data[direction_index(orig.reverse())].name(), + columns, + )); + result + } + + /// Records the flags & payload of a segment. + fn observe(&mut self, segment: &Segment, before: &Before) { + let direction = segment.direction; + self.pkts[direction_index(direction)] += 1; + if segment.ack.is_some() && false == segment.syn { + // the ACK of a SYN is part of the handshake ('H'), not a plain + // acknowledgment (same as Zeek) + self.history.add(direction, self.orig, 'A'); + } + if segment.syn { + // a SYN answering a SYN is the second half of the handshake + self.history.add( + direction, + self.orig, + if direction == self.orig { 'S' } else { 'H' }, + ); + } + if segment.len > 0 { + self.history.add(direction, self.orig, 'D'); + if before.new_bytes(segment.len) == 0 { + self.history.add(direction, self.orig, 'T'); + } + } + if segment.fin { + self.history.add(direction, self.orig, 'F'); + } + if segment.rst { + self.history.add(direction, self.orig, 'R'); + } + } + + /// Copies the state of the two stream directions into the record. + fn update_state( + &mut self, + direction: TcpDirection, + sender: &TcpStreamReassemblyBuf, + receiver: &TcpStreamReassemblyBuf, + ) { + for (index, stream) in [ + (direction_index(direction), sender), + (direction_index(direction.reverse()), receiver), + ] { + self.syn_observed[index] = stream.syn_observed(); + self.fin_observed[index] |= stream.fin_offset().is_some(); + self.complete[index] |= stream.is_fin_reached(); + self.ack_observed[index] = stream.ack_observed(); + } + } + + /// Adds a line to the info file of the connection. + fn info(&mut self, line: &str) { + self.info.write(line.as_bytes()); + self.info.write(b"\n"); + } + + /// Builds the index record of the connection. + fn index_record(&self, id: &TcpConnectionId, reason: CloseReason) -> String { + let o = direction_index(self.orig); + let r = direction_index(self.orig.reverse()); + let vlan_ids = id.vlan_ids(); + let midstream = false == self.syn_observed[o] && false == self.syn_observed[r]; + + let mut result = String::new(); + result.push('{'); + json_field(&mut result, "id", &self.id.to_string()); + json_field(&mut result, "ts", &epoch(self.first_time)); + json_field(&mut result, "ts_iso", &json_str(&iso_time(self.first_time))); + json_field(&mut result, "ts_end", &epoch(self.last_time)); + json_field( + &mut result, + "ts_end_iso", + &json_str(&iso_time(self.last_time)), + ); + json_field( + &mut result, + "duration", + &format!( + "{:.6}", + self.last_time + .duration_since(self.first_time) + .unwrap_or_default() + .as_secs_f64() + ), + ); + json_field(&mut result, "proto", "\"tcp\""); + json_field( + &mut result, + "id.orig_h", + &json_str(&id.source(self.orig).ip.to_string()), + ); + json_field( + &mut result, + "id.orig_p", + &id.source(self.orig).port.to_string(), + ); + json_field( + &mut result, + "id.resp_h", + &json_str(&id.destination(self.orig).ip.to_string()), + ); + json_field( + &mut result, + "id.resp_p", + &id.destination(self.orig).port.to_string(), + ); + // Zeek logs the outer & the first inner VLAN id + json_field( + &mut result, + "vlan", + &vlan_ids + .first() + .map(|v| v.value().to_string()) + .unwrap_or("null".to_string()), + ); + json_field( + &mut result, + "inner_vlan", + &vlan_ids + .get(1) + .map(|v| v.value().to_string()) + .unwrap_or("null".to_string()), + ); + json_field(&mut result, "conn_state", &json_str(self.conn_state())); + json_field(&mut result, "history", &json_str(&self.history.0)); + json_field(&mut result, "closed_by", &json_str(reason.as_str())); + json_field(&mut result, "midstream", &midstream.to_string()); + json_field(&mut result, "orig_pkts", &self.pkts[o].to_string()); + json_field(&mut result, "resp_pkts", &self.pkts[r].to_string()); + json_field(&mut result, "orig_bytes", &self.bytes[o].to_string()); + json_field(&mut result, "resp_bytes", &self.bytes[r].to_string()); + json_field( + &mut result, + "orig_missing_bytes", + &self.missing[o].to_string(), + ); + json_field( + &mut result, + "resp_missing_bytes", + &self.missing[r].to_string(), + ); + json_field( + &mut result, + "orig_syn_observed", + &self.syn_observed[o].to_string(), + ); + json_field( + &mut result, + "resp_syn_observed", + &self.syn_observed[r].to_string(), + ); + json_field( + &mut result, + "orig_fin_observed", + &self.fin_observed[o].to_string(), + ); + json_field( + &mut result, + "resp_fin_observed", + &self.fin_observed[r].to_string(), + ); + json_field(&mut result, "orig_complete", &self.complete[o].to_string()); + json_field(&mut result, "resp_complete", &self.complete[r].to_string()); + // a stream that never saw an acknowledgment only hands out data via + // `contiguous_unacked` (see `TcpAckPolicy::Required`) + json_field( + &mut result, + "orig_ack_observed", + &self.ack_observed[o].to_string(), + ); + json_field( + &mut result, + "resp_ack_observed", + &self.ack_observed[r].to_string(), + ); + result.push_str("\"files\":{"); + result.push_str(&format!("\"info\":{}", json_str(self.info.name()))); + for (name, index) in [("orig", o), ("resp", r)] { + result.push(','); + if self.data_written[index] { + result.push_str(&format!("\"{name}\":{}", json_str(self.data[index].name()))); + } else { + result.push_str(&format!("\"{name}\":null")); + } + } + result.push_str("}}\n"); + result + } + + /// Zeek style connection state. + /// + /// See the [Zeek documentation](https://docs.zeek.org/en/master/scripts/base/protocols/conn/main.zeek.html) + /// for the meaning of the individual states. Note that this is derived + /// from the reconstruction state and not from a full TCP state machine, + /// so it is an approximation of what Zeek would report. + fn conn_state(&self) -> &'static str { + let o = direction_index(self.orig); + let r = direction_index(self.orig.reverse()); + let syn = self.history.0.contains('S'); + let syn_ack = self.history.0.contains('h'); + let rst_orig = self.history.0.contains('R'); + let rst_resp = self.history.0.contains('r'); + + if syn && syn_ack { + // the handshake was captured + if rst_orig { + "RSTO" + } else if rst_resp { + "RSTR" + } else if self.fin_observed[o] && self.fin_observed[r] { + "SF" + } else if self.fin_observed[o] { + "S2" + } else if self.fin_observed[r] { + "S3" + } else { + "S1" + } + } else if syn { + // no answer of the responder was captured + if rst_resp { + "REJ" + } else if rst_orig { + "RSTOS0" + } else if self.fin_observed[o] { + "SH" + } else { + "S0" + } + } else if syn_ack { + // the SYN of the originator is missing from the capture + if rst_resp { + "RSTRH" + } else if self.fin_observed[r] { + "SHR" + } else { + "OTH" + } + } else { + "OTH" + } + } + + /// Writes out everything that is still buffered. + fn finish(mut self) { + self.info(&format!( + "# {} bytes reconstructed originator -> responder, {} bytes responder -> originator", + self.bytes[direction_index(self.orig)], + self.bytes[direction_index(self.orig.reverse())], + )); + self.info.flush(); + for sink in self.data.iter_mut() { + sink.flush(); + } + } +} + +/// Values of a received TCP segment that are needed after the segment itself +/// was moved into the reassembly. +struct Segment { + /// Number of the packet in the capture (1 based, so it matches the frame + /// numbers of Wireshark & tshark). + frame: u64, + + /// Capture time of the packet. + time: SystemTime, + + /// Direction the segment was travelling in. + direction: TcpDirection, + + /// Sequence number (of the SYN if the flag is set, otherwise of the first + /// payload byte). + seq: u32, + + /// Acknowledgment number (`None` if the ACK flag was not set). + ack: Option, + + /// Flags that are relevant for the reconstruction. + syn: bool, + fin: bool, + rst: bool, + + /// Number of payload bytes. + len: u64, +} + +/// Builds the info file line describing a segment. +fn segment_line(segment: &Segment, orig: TcpDirection, effects: &[String]) -> String { + let mut flags = Vec::new(); + if segment.syn { + flags.push("SYN"); + } + if segment.fin { + flags.push("FIN"); + } + if segment.rst { + flags.push("RST"); + } + if segment.ack.is_some() { + flags.push("ACK"); + } + + // same column widths as the header written by `Conn::new` + format!( + "{:>7} {:<27} {:<12} {:<12} {:>10} {:>10} {:>4} {}", + segment.frame, + iso_time(segment.time), + role_name(segment.direction, orig), + flags.join(","), + segment.seq, + segment + .ack + .map(|v| v.to_string()) + .unwrap_or("-".to_string()), + segment.len, + if effects.is_empty() { + "nothing new".to_string() + } else { + effects.join("; ") + }, + ) +} + +/// Buffers output & appends it to a file. +/// +/// The file is only opened while something is written to it: a capture can +/// contain a lot more parallel connections (three files each) than the process +/// is allowed to keep open at the same time. +struct Sink { + /// File the buffered data is written to. + path: PathBuf, + + /// Path as it ends up in the index & the info files (relative to the + /// output directory). + name: String, + + /// Data that was not written to the file yet. + buf: Vec, + + /// True once the file was created (further writes append to it). + created: bool, +} + +/// Number of bytes that are buffered before a [`Sink`] writes them out. +const SINK_BUF_SIZE: usize = 8 * 1024; + +impl Sink { + fn new(path: PathBuf) -> Sink { + // the last two components are the ones inside the output directory + let name = match path.parent().and_then(|p| p.file_name()) { + Some(dir) => format!( + "{}/{}", + dir.to_string_lossy(), + path.file_name().unwrap_or_default().to_string_lossy() + ), + None => path.to_string_lossy().to_string(), + }; + Sink { + path, + name, + buf: Vec::new(), + created: false, + } + } + + /// Path of the file relative to the output directory. + fn name(&self) -> &str { + &self.name + } + + fn write(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + if self.buf.len() >= SINK_BUF_SIZE { + self.flush(); + } + } + + fn flush(&mut self) { + if self.buf.is_empty() { + return; + } + // a re-run into the same directory has to overwrite the old files + let file = OpenOptions::new() + .write(true) + .append(self.created) + .truncate(false == self.created) + .create(true) + .open(&self.path); + match file { + Ok(mut file) => { + self.created = true; + if let Err(err) = file.write_all(&self.buf) { + eprintln!("Error writing '{}': {err}", self.path.display()); + WRITE_FAILED.store(true, Ordering::Relaxed); + } + } + Err(err) => { + eprintln!("Error opening '{}': {err}", self.path.display()); + WRITE_FAILED.store(true, Ordering::Relaxed); + } + } + self.buf.clear(); + } +} + +/// Index of a direction in the per direction arrays. +fn direction_index(direction: TcpDirection) -> usize { + match direction { + TcpDirection::FirstToSecond => 0, + TcpDirection::SecondToFirst => 1, + } +} + +/// Name of a direction relative to the originator of the connection. +fn role_name(direction: TcpDirection, orig: TcpDirection) -> &'static str { + if direction == orig { + "orig > resp" + } else { + "resp > orig" + } +} + +/// Formats an endpoint as "ip:port". +fn endpoint(endpoint: &TcpEndpoint) -> String { + match endpoint.ip { + IpAddr::V4(ip) => format!("{}:{}", ip, endpoint.port), + IpAddr::V6(ip) => format!("[{}]:{}", ip, endpoint.port), + } +} + +/// Adds a `"name":value,` pair to a JSON object. +fn json_field(out: &mut String, name: &str, value: &str) { + out.push('"'); + out.push_str(name); + out.push_str("\":"); + out.push_str(value); + out.push(','); +} + +/// Escapes & quotes a JSON string. +fn json_str(value: &str) -> String { + let mut result = String::with_capacity(value.len() + 2); + result.push('"'); + for c in value.chars() { + match c { + '"' => result.push_str("\\\""), + '\\' => result.push_str("\\\\"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + _ if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)), + _ => result.push(c), + } + } + result.push('"'); + result +} + +/// Formats a capture time as seconds since the unix epoch (the format Zeek +/// uses for its `ts` fields). +fn epoch(time: SystemTime) -> String { + match time.duration_since(UNIX_EPOCH) { + Ok(value) => format!("{:.6}", value.as_secs_f64()), + Err(_) => "null".to_string(), + } +} + +/// Formats a capture time as an ISO 8601 / RFC 3339 timestamp in UTC. +fn iso_time(time: SystemTime) -> String { + let Ok(duration) = time.duration_since(UNIX_EPOCH) else { + return "?".to_string(); + }; + let secs = duration.as_secs(); + let (days, time_of_day) = ((secs / 86400) as i64, secs % 86400); + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:06}Z", + time_of_day / 3600, + (time_of_day % 3600) / 60, + time_of_day % 60, + duration.subsec_micros(), + ) +} + +/// Converts days since the unix epoch into a year, month & day (algorithm +/// from Howard Hinnant's "chrono-Compatible Low-Level Date Algorithms"). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let year = yoe + era * 400 + i64::from(month <= 2); + (year, month, day) +}