From b02016bb50affd772a74fce0836908fa53d2e3b1 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Fri, 25 Sep 2026 18:15:58 +0300 Subject: [PATCH 01/28] fix: harden DNS parsing, signal shutdown, and PCAP generation --- Cargo.lock | 1 + Cargo.toml | 5 +- README.md | 7 +- docs/architecture.md | 9 +- docs/rfc/0005-dual-path-pcap-parsing.md | 4 + docs/synthetic-pcap-generator.md | 12 +- src/app.rs | 18 +- src/dns_processor/parser.rs | 73 +++++--- src/dns_processor/pipeline.rs | 7 +- src/dns_processor/tests.rs | 88 ++++++++- src/packet_parser.rs | 143 +++++++++++++-- src/packet_parser_wire_tests.rs | 1 + tests/end_to_end.rs | 210 ++++++++++++++++++++++ tools/dns-pcap-generator/src/artifact.rs | 9 + tools/dns-pcap-generator/src/catalog.rs | 10 ++ tools/dns-pcap-generator/src/error.rs | 16 ++ tools/dns-pcap-generator/src/generator.rs | 29 ++- tools/dns-pcap-generator/src/packet.rs | 26 ++- tools/dns-pcap-generator/src/tests.rs | 170 ++++++++++++++++++ 19 files changed, 771 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d19e68b..9147058 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -681,6 +681,7 @@ dependencies = [ "itoa", "memchr", "mimalloc", + "nix", "num-format", "parquet", "pbkdf2", diff --git a/Cargo.toml b/Cargo.toml index 6b7e8af..3af7ad3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,9 +74,12 @@ sha2 = "0.11.0" itoa = "1.0" memchr = "2.8" serde_json = "1.0" -ctrlc = "3.5" +ctrlc = { version = "3.5", features = ["termination"] } thiserror = "2.0" +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31", features = ["poll", "process", "signal"] } + [target.'cfg(all(target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64")))'.dependencies] tcmalloc-better = { version = "0.1.19", optional = true } # Google TCMalloc wrapper for supported Linux targets diff --git a/README.md b/README.md index 7e4c215..a34de34 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,8 @@ history is needed or allocated. DNS QR determines query/response direction, including valid exchanges with UDP port 53 on both endpoints. QCLASS and OPCODE distinguish transactions internally and do not add output columns. +Ordinary DNS QUERY messages with more than one question are rejected under RFC 9619. Responses +whose declared answer or authority records are truncated are also rejected before matching. For QNAME matching, DPP preserves the observed presentation-form name bytes and does not lowercase them before building matcher identity keys. This is a deliberate Community Edition trade-off, not @@ -297,7 +299,10 @@ its own file while making the end-of-run report easy to capture and parse. If DPP receives `SIGINT` or `SIGTERM`, it stops accepting new packet batches, drains already accepted work, skips synthetic timeout finalization for pending unmatched queries, discards any still-buffered output tail, and then exits. This applies to CSV and Parquet outputs alike. The -final JSON summary reports this through the `warnings.graceful_signal_shutdown` field. +final JSON summary reports this through the `warnings.graceful_signal_shutdown` field. When input +comes from stdin, shutdown does not require the stream producer to close its end of the pipe. +If a signal arrives before a capture header can be read, no output writer or final report is +created. The final report now also includes basic processing and matching-quality metrics: diff --git a/docs/architecture.md b/docs/architecture.md index 65942a4..80f7f1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,6 +103,8 @@ or second encoding of canonical addresses and ports. final signed timestamp. It never invokes the dependency's stateful timestamp conversion. Buffers grow with received block bytes rather than untrusted declared lengths. Stdin probing needs no temp-file or second ingest owner. Unsupported stdin stream magic is rejected explicitly. + Signal-driven shutdown can interrupt a blocked stdin read without waiting for the producer to + close the stream; capture reading remains parser-owned. The pure-Rust classic-PCAP reader still relies on the upstream `pcap-file` `3.0.0-rc1` release candidate until a stable line with the required functionality is available. The parser can also enforce globally monotonic capture timestamps for the optional batched @@ -165,8 +167,11 @@ or second encoding of canonical addresses and ports. `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains the semantic fallback for rare DNS messages that the fast path does not accept. Compression pointers must target prior, nonoverlapping names; enabling the fast path must not weaken that - validation. TSIG status extraction consumes and bounds-checks the declared Other Data, including - the six-byte server time in BADTIME responses. Both paths accept + validation. For ordinary QUERY messages (OPCODE 0), more than one question is rejected under + RFC 9619. A response cannot claim answer or authority records that are absent from its DNS + payload, whether or not it has additional records. TSIG status extraction consumes and + bounds-checks the declared Other Data, including the six-byte server time in BADTIME responses. + Both paths accept decompressed wire QNAMEs up to the RFC 1035 limit of 255 octets, including label-length octets and the terminating root octet. A valid name can expand to 1003 bytes in escaped presentation form and must remain distinct through matching and export. If any QNAME exceeds the wire limit, the diff --git a/docs/rfc/0005-dual-path-pcap-parsing.md b/docs/rfc/0005-dual-path-pcap-parsing.md index 42f210b..2ce22fd 100644 --- a/docs/rfc/0005-dual-path-pcap-parsing.md +++ b/docs/rfc/0005-dual-path-pcap-parsing.md @@ -102,6 +102,10 @@ as large as 1003 bytes and remains valid input; DPP preserves it for matching an replacing it with an empty name. Both question decoders reject compression pointers that point forward or overlap the current name. The fast path's fallback must not become a way to accept a message the semantic decoder rejects. +Ordinary QUERY messages (OPCODE 0) with QDCOUNT greater than one are malformed under RFC 9619 and +are rejected by both decoders. Responses that declare answer or authority records must contain +those complete records even when ARCOUNT is zero; the decoder checks their wire boundaries before +the matcher sees the response. If any decompressed QNAME exceeds the wire limit, the entire DNS message is rejected before matcher or writer handoff. The processing counter `oversized_qname_message_count` increments exactly once diff --git a/docs/synthetic-pcap-generator.md b/docs/synthetic-pcap-generator.md index 3a4fb06..6177bd2 100644 --- a/docs/synthetic-pcap-generator.md +++ b/docs/synthetic-pcap-generator.md @@ -80,6 +80,9 @@ The checked-in `server1-jul-2024` profile points its `catalog_path` at the works truth instead of being duplicated inside the profile directory. Runtime failures are reported through typed CLI errors, so invalid arguments and I/O failures surface with stable top-level messages and source chains. +Catalog rows with zero weights or DNS names longer than 255 wire bytes are rejected, as are fitted +profiles whose `duplicate_max` is below every configured retry count. Generation returns an error +if a packet timestamp exceeds the classic PCAP 32-bit seconds range. ## Model @@ -89,9 +92,12 @@ messages and source chains. - `--clients` is validated against the distinct address capacity of that client pool, which is 4,161,536 synthetic client IPs. - Inter-arrival times are sampled from an exponential distribution around the configured `--qps`. -- Duplicate retries use increasing retry delays so slow or unanswered lookups produce realistic - retry spacing; unanswered transactions keep long retransmit backoff, while answered retries stay - short enough not to distort matched RTT. + The generator schedules them at nanosecond precision with fractional carry. Classic PCAP stores + microsecond timestamps, so at high QPS multiple packets can share a timestamp without imposing + a one-microsecond minimum gap on the generated rate. +- Duplicate retries use the profile's fitted delay for each retry step. These delays are not + necessarily increasing: the first unanswered steps have long backoff, while later fitted or + hypothesized steps can be much shorter. A response follows the last retry. - Matched response latency is calibrated from the local `server1_jul_2024.csv` distribution: most replies land in a few dozen microseconds, with a rare long tail and heavier `ServFail` delays. diff --git a/src/app.rs b/src/app.rs index 07f1248..2f04873 100644 --- a/src/app.rs +++ b/src/app.rs @@ -571,8 +571,22 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { ) .map_err(|source| AppRunError::DnsProcessorInit { source })?, ); - let mut packet_parser = PacketParser::new(&args.input_source, args.monotonic_capture) - .map_err(|source| AppRunError::PacketParserInit { source })?; + let mut packet_parser = match PacketParser::new_with_shutdown( + &args.input_source, + args.monotonic_capture, + Arc::clone(&shutdown_requested), + ) { + Ok(parser) => parser, + Err(error) + if matches!(args.input_source, InputSource::Stdin) + && shutdown_requested.load(AtomicOrdering::SeqCst) + && crate::packet_parser::is_stdin_shutdown_error(&error) => + { + // No capture header was parsed, so no output writer exists to shut down. + return Ok(()); + } + Err(source) => return Err(AppRunError::PacketParserInit { source }), + }; let (tx, rx) = channel::bounded(args.output_channel_message_capacity()); let (max_memory_usage, memory_thread) = runtime::maybe_start_memory_monitoring(args.silent)?; diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index 6df10fe..e221a78 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -415,6 +415,12 @@ impl DnsProcessor { } } + #[inline] + fn invalid_question_count(opcode: u8, query_count: usize) -> bool { + // RFC 9619 limits standard QUERY messages to one question. + opcode == 0 && query_count > 1 + } + fn decode_dns_questions_fast( dns_data: &[u8], decode_extended_rcode: bool, @@ -425,12 +431,16 @@ impl DnsProcessor { let id = Self::parse_u16_at(dns_data, 0, "Failed to parse DNS header")?; let flags = Self::parse_u16_at(dns_data, 2, "Failed to parse DNS header")?; + let opcode = ((flags >> 11) & 0x0f) as u8; let low_response_code = (flags & 0x000f) as u8; let query_count = usize::from(Self::parse_u16_at( dns_data, 4, "Failed to parse DNS question count", )?); + if Self::invalid_question_count(opcode, query_count) { + return Err(DnsQuestionDecodeError::Invalid); + } let mut cursor = DNS_HEADER_LEN; let mut queries = Vec::with_capacity(query_count); @@ -454,35 +464,38 @@ impl DnsProcessor { let mut response_code = ProtoResponseCode::from(HickoryResponseCode::from(0, low_response_code)); if decode_extended_rcode { - let additional_count = usize::from(Self::parse_u16_at( - dns_data, - 10, - "Failed to parse DNS additional count", - )?); - if additional_count > 0 { - response_code = Self::decode_response_code_with_additionals( + let section_counts = DnsSectionCounts { + answers: usize::from(Self::parse_u16_at( + dns_data, + 6, + "Failed to parse DNS answer count", + )?), + authorities: usize::from(Self::parse_u16_at( + dns_data, + 8, + "Failed to parse DNS authority count", + )?), + additionals: usize::from(Self::parse_u16_at( + dns_data, + 10, + "Failed to parse DNS additional count", + )?), + }; + if section_counts.answers > 0 + || section_counts.authorities > 0 + || section_counts.additionals > 0 + { + response_code = Self::decode_response_code_with_sections( dns_data, cursor, - DnsSectionCounts { - answers: usize::from(Self::parse_u16_at( - dns_data, - 6, - "Failed to parse DNS answer count", - )?), - authorities: usize::from(Self::parse_u16_at( - dns_data, - 8, - "Failed to parse DNS authority count", - )?), - additionals: additional_count, - }, + section_counts, low_response_code, )?; } } let header = DecodedDnsHeader { id, - opcode: ((flags >> 11) & 0x0f) as u8, + opcode, response_code, }; @@ -495,11 +508,19 @@ impl DnsProcessor { ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { let mut decoder = BinDecoder::new(dns_data); let header = Header::read(&mut decoder).map_err(|_| DnsQuestionDecodeError::Invalid)?; + let opcode: u8 = header.op_code.into(); + if Self::invalid_question_count(opcode, header.counts.queries as usize) { + return Err(DnsQuestionDecodeError::Invalid); + } let queries = Message::read_queries(&mut decoder, header.counts.queries as usize) .map_err(Self::classify_hickory_question_error)?; let mut response_code = ProtoResponseCode::from(header.response_code); - if decode_extended_rcode && header.counts.additionals > 0 { - response_code = Self::decode_response_code_with_additionals( + if decode_extended_rcode + && (header.counts.answers > 0 + || header.counts.authorities > 0 + || header.counts.additionals > 0) + { + response_code = Self::decode_response_code_with_sections( dns_data, decoder.index(), DnsSectionCounts { @@ -514,7 +535,7 @@ impl DnsProcessor { Ok(( DecodedDnsHeader { id: header.id, - opcode: header.op_code.into(), + opcode, response_code, }, queries @@ -531,7 +552,7 @@ impl DnsProcessor { )) } - fn decode_response_code_with_additionals( + fn decode_response_code_with_sections( dns_data: &[u8], mut cursor: usize, section_counts: DnsSectionCounts, @@ -1269,7 +1290,7 @@ mod protocol_regression_tests { assert!(DnsProcessor::read_wire_domain_name(&overlap, &mut 3).is_err()); assert!(Name::read(&mut BinDecoder::new(&overlap).clone(3)).is_err()); - let mut dns = question(0x0100, 1); + let mut dns = question(0x0900, 1); dns[4..6].copy_from_slice(&2_u16.to_be_bytes()); dns.extend_from_slice(b"\xc0\x0c\0\x1c\0\x01"); for processor in processors() { diff --git a/src/dns_processor/pipeline.rs b/src/dns_processor/pipeline.rs index 18c748e..d43cb88 100644 --- a/src/dns_processor/pipeline.rs +++ b/src/dns_processor/pipeline.rs @@ -1139,12 +1139,7 @@ mod tests { fn oversized_qname_batch(test_name: &str) -> PacketBatch { let path = temp_test_path(test_name, "pcap"); - let mut dns_payload = encode_dns_header(0x1234, 0x0100, 2); - dns_payload.extend_from_slice(&[ - 7, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0, - ]); - dns_payload.extend_from_slice(&1_u16.to_be_bytes()); - dns_payload.extend_from_slice(&1_u16.to_be_bytes()); + let mut dns_payload = encode_dns_header(0x1234, 0x0100, 1); for label_len in [63_usize, 63, 63, 62] { dns_payload.push(label_len as u8); diff --git a/src/dns_processor/tests.rs b/src/dns_processor/tests.rs index 6f2f8f6..867161b 100644 --- a/src/dns_processor/tests.rs +++ b/src/dns_processor/tests.rs @@ -588,7 +588,7 @@ fn packet_routing_meta_preserves_standard_packet_processing_output() { #[test] fn packet_processing_appends_records_with_final_ordinals() { - let mut dns_payload = encode_dns_header(0x1234, 0x0100, 2); + let mut dns_payload = encode_dns_header(0x1234, 0x0900, 2); append_example_query(&mut dns_payload); dns_payload.extend_from_slice(&[3, b'w', b'w', b'w', 0xC0, 0x0C]); dns_payload.extend_from_slice(&28_u16.to_be_bytes()); @@ -628,7 +628,7 @@ fn packet_processing_appends_records_with_final_ordinals() { #[test] fn packet_processing_failure_does_not_append_partial_records() { - let mut oversized = encode_dns_header(0x1234, 0x8180, 2); + let mut oversized = encode_dns_header(0x1234, 0x8980, 2); append_example_query(&mut oversized); append_repeated_byte_question( &mut oversized, @@ -636,7 +636,7 @@ fn packet_processing_failure_does_not_append_partial_records() { 1, ); - let mut malformed = encode_dns_header(0x1234, 0x0100, 2); + let mut malformed = encode_dns_header(0x1234, 0x0900, 2); append_example_query(&mut malformed); malformed.extend_from_slice(&[3, b'a']); @@ -712,7 +712,7 @@ fn packet_processing_zero_questions_leaves_destination_unchanged() { #[test] fn packet_processing_response_appends_only_first_question() { - let mut dns_payload = encode_dns_header(0x1234, 0x8180, 2); + let mut dns_payload = encode_dns_header(0x1234, 0x8980, 2); append_example_query(&mut dns_payload); dns_payload.extend_from_slice(&[3, b'w', b'w', b'w', 0xC0, 0x0C]); dns_payload.extend_from_slice(&28_u16.to_be_bytes()); @@ -1800,7 +1800,7 @@ fn parser_rejects_oversized_wire_qname_in_both_decoder_modes() { #[test] fn parser_rejects_oversized_compressed_qname_by_expanded_wire_length() { - let mut dns_payload = encode_dns_header(0x1234, 0x0100, 2); + let mut dns_payload = encode_dns_header(0x1234, 0x0900, 2); append_repeated_byte_question( &mut dns_payload, &[(63, b'a'), (63, b'a'), (63, b'a'), (61, b'a')], @@ -1831,7 +1831,7 @@ fn parser_rejects_oversized_compressed_qname_by_expanded_wire_length() { #[test] fn parser_accepts_compressed_qname_at_exact_expanded_wire_limit() { - let mut dns_payload = encode_dns_header(0x1234, 0x0100, 2); + let mut dns_payload = encode_dns_header(0x1234, 0x0900, 2); append_repeated_byte_question( &mut dns_payload, &[(63, b'a'), (63, b'a'), (63, b'a'), (59, b'a')], @@ -1921,7 +1921,7 @@ fn parser_does_not_count_malformed_forward_pointer_as_oversized_qname() { #[test] fn parser_decodes_multiple_wire_questions_including_compression() { let processor = test_processor_with_dns_wire_fast_path(); - let mut dns_payload = encode_dns_header(0x1234, 0x0100, 2); + let mut dns_payload = encode_dns_header(0x1234, 0x0900, 2); dns_payload.extend_from_slice(&[ 7, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0, ]); @@ -1948,6 +1948,30 @@ fn parser_decodes_multiple_wire_questions_including_compression() { assert_eq!(records[1].query_type, HickoryRecordType::AAAA); } +#[test] +fn parser_rejects_multiple_questions_for_standard_query_opcode() { + for (flags, is_response) in [(0x0100, false), (0x8180, true)] { + let mut dns_payload = encode_dns_header(0x1234, flags, 2); + append_example_query(&mut dns_payload); + append_example_query(&mut dns_payload); + let packet = if is_response { + make_udp_dns_packet_with_payload([8, 8, 8, 8], [10, 0, 0, 1], 53, 53_000, &dns_payload) + } else { + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &dns_payload) + }; + + for (fast_path, processor) in [ + (false, test_processor()), + (true, test_processor_with_dns_wire_fast_path()), + ] { + assert!( + processor.process_packet_batch(&packet, 1_234_567).is_none(), + "is_response={is_response}, fast_path={fast_path}" + ); + } + } +} + #[test] fn parser_decodes_response_code_from_wire_header() { let mut dns_payload = encode_dns_header(0xBEEF, 0x8183, 1); @@ -1980,6 +2004,54 @@ fn parser_decodes_response_code_from_wire_header() { } } +#[test] +fn parser_rejects_missing_declared_answer_or_authority_without_additionals() { + let mut base = encode_dns_header(0xBEEF, 0x8180, 1); + append_example_query(&mut base); + + let mut missing_answer = base.clone(); + missing_answer[6..8].copy_from_slice(&1_u16.to_be_bytes()); + + let mut missing_authority = base.clone(); + missing_authority[8..10].copy_from_slice(&1_u16.to_be_bytes()); + + let mut truncated_answer = missing_answer.clone(); + truncated_answer.extend_from_slice(&[0xC0, 0x0C, 0, 1, 0]); + + let mut missing_authority_after_answer = missing_answer.clone(); + missing_authority_after_answer[8..10].copy_from_slice(&1_u16.to_be_bytes()); + append_a_resource_record( + &mut missing_authority_after_answer, + &[0xC0, 0x0C], + 60, + [1, 2, 3, 4], + ); + + for (case, payload) in [ + ("missing answer", missing_answer), + ("missing authority", missing_authority), + ("truncated answer", truncated_answer), + ( + "missing authority after answer", + missing_authority_after_answer, + ), + ] { + assert_response_payload_rejected(case, &payload); + } +} + +#[test] +fn parser_accepts_declared_answer_and_authority_without_additionals() { + let mut dns_payload = encode_dns_header(0xBEEF, 0x8180, 1); + dns_payload[6..8].copy_from_slice(&1_u16.to_be_bytes()); + dns_payload[8..10].copy_from_slice(&1_u16.to_be_bytes()); + append_example_query(&mut dns_payload); + append_a_resource_record(&mut dns_payload, &[0xC0, 0x0C], 60, [1, 2, 3, 4]); + append_a_resource_record(&mut dns_payload, &[0xC0, 0x0C], 30, [5, 6, 7, 8]); + + assert_response_code_from_payload(&dns_payload, 0, "No Error"); +} + #[test] fn parser_decodes_edns_extended_response_code_from_opt_rr() { for (extended_high, low_rcode, expected_code) in [(1, 0, 16), (2, 3, 35)] { @@ -2093,7 +2165,7 @@ fn parser_rejects_wire_name_compression_loops() { fn parser_fast_path_flag_preserves_legacy_packet_output() { let legacy_processor = test_processor(); let fast_path_processor = test_processor_with_dns_wire_fast_path(); - let mut dns_payload = encode_dns_header(0x1234, 0x0100, 2); + let mut dns_payload = encode_dns_header(0x1234, 0x0900, 2); dns_payload.extend_from_slice(&[ 7, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0, ]); diff --git a/src/packet_parser.rs b/src/packet_parser.rs index 7ad6c15..c673703 100644 --- a/src/packet_parser.rs +++ b/src/packet_parser.rs @@ -16,11 +16,20 @@ use pcap_file::pcapng::{ }; use pcap_file::{DataLink, Endianness}; use std::fs::File; -use std::io::{BufRead, BufReader, Cursor, ErrorKind, Read}; +use std::io::{self, BufRead, BufReader, Cursor, ErrorKind, Read}; use std::ops::Deref; use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::time::Duration; +#[cfg(unix)] +use nix::errno::Errno; +#[cfg(unix)] +use nix::poll::{PollFd, PollFlags, poll}; +#[cfg(unix)] +use std::os::fd::AsFd; + /// Packet payload storage that remains valid after batch handoff to worker threads. #[derive(Clone, Debug)] pub struct PacketPayload(Box<[u8]>); @@ -63,6 +72,7 @@ pub(crate) fn sort_packet_batch(packet_batch: &mut [PacketData]) { /// A parser for reading packets from an offline capture source. pub struct PacketParser { backend: PacketBackend, + stdin_shutdown: Option>, enforce_monotonic_timestamps: bool, packet_ordinal: u64, last_timestamp_micros: Option, @@ -90,10 +100,13 @@ enum PacketBackend { } impl PacketBackend { - fn from_input_source(input_source: &InputSource) -> Result { + fn from_input_source( + input_source: &InputSource, + shutdown_requested: Arc, + ) -> Result { match input_source { InputSource::File(path) => Self::from_file(path), - InputSource::Stdin => Self::from_stdin(), + InputSource::Stdin => Self::from_stdin(shutdown_requested), } } @@ -118,13 +131,13 @@ impl PacketBackend { Ok(Self::Libpcap(capture)) } - #[cfg(not(windows))] - fn from_stdin() -> Result { - Self::from_stream(Box::new(std::io::stdin()), "stdin") + #[cfg(unix)] + fn from_stdin(shutdown_requested: Arc) -> Result { + Self::from_stream(Box::new(StdinReader::new(shutdown_requested)), "stdin") } - #[cfg(windows)] - fn from_stdin() -> Result { + #[cfg(not(unix))] + fn from_stdin(_shutdown_requested: Arc) -> Result { anyhow::bail!("Reading the input capture from stdin is not supported on Windows.") } @@ -187,9 +200,25 @@ impl PacketParser { /// /// Classic pcap files use a pure-Rust streaming reader. Other formats fall back to libpcap to /// preserve existing compatibility assumptions. Stdin uses parser-owned streaming readers. + #[cfg(test)] pub fn new(input_source: &InputSource, enforce_monotonic_timestamps: bool) -> Result { + Self::new_with_shutdown( + input_source, + enforce_monotonic_timestamps, + Arc::new(AtomicBool::new(false)), + ) + } + + pub(crate) fn new_with_shutdown( + input_source: &InputSource, + enforce_monotonic_timestamps: bool, + shutdown_requested: Arc, + ) -> Result { + let stdin_shutdown = + matches!(input_source, InputSource::Stdin).then(|| Arc::clone(&shutdown_requested)); Ok(Self { - backend: PacketBackend::from_input_source(input_source)?, + backend: PacketBackend::from_input_source(input_source, shutdown_requested)?, + stdin_shutdown, enforce_monotonic_timestamps, packet_ordinal: 0, last_timestamp_micros: None, @@ -206,7 +235,20 @@ impl PacketParser { let mut packet_buffer: PacketBatch = Vec::with_capacity(chunk_size.min(PACKET_BATCH_SIZE)); while packet_buffer.len() < chunk_size { - match self.backend.next_packet_data()? { + if self.stdin_shutdown_requested() { + return Ok(None); + } + + let next_packet = match self.backend.next_packet_data() { + Err(error) + if self.stdin_shutdown_requested() && is_stdin_shutdown_error(&error) => + { + return Ok(None); + } + result => result?, + }; + + match next_packet { Some(packet) => { if let Some(previous_timestamp_micros) = self.last_timestamp_micros && packet.timestamp_micros < previous_timestamp_micros @@ -257,6 +299,86 @@ impl PacketParser { pub(crate) fn first_non_monotonic_timestamp(&self) -> Option { self.first_non_monotonic_timestamp } + + fn stdin_shutdown_requested(&self) -> bool { + self.stdin_shutdown + .as_ref() + .is_some_and(|flag| flag.load(AtomicOrdering::SeqCst)) + } +} + +#[derive(Debug)] +struct StdinReadCancelled; + +impl std::fmt::Display for StdinReadCancelled { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("stdin read cancelled by termination signal") + } +} + +impl std::error::Error for StdinReadCancelled {} + +pub(crate) fn is_stdin_shutdown_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause.is::() + || cause + .downcast_ref::() + .and_then(io::Error::get_ref) + .is_some_and(|inner| inner.is::()) + }) +} + +#[cfg(unix)] +struct StdinReader { + stdin: io::Stdin, + shutdown_requested: Arc, +} + +#[cfg(unix)] +impl StdinReader { + fn new(shutdown_requested: Arc) -> Self { + Self { + stdin: io::stdin(), + shutdown_requested, + } + } + + fn cancelled(&self) -> bool { + self.shutdown_requested.load(AtomicOrdering::SeqCst) + } +} + +#[cfg(unix)] +impl Read for StdinReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + + loop { + if self.cancelled() { + return Err(io::Error::other(StdinReadCancelled)); + } + + // ctrlc installs SA_RESTART, so a blocking read cannot rely on EINTR for + // cancellation. Polling keeps the only stdin reader owned by this parser. + let mut fds = [PollFd::new(self.stdin.as_fd(), PollFlags::POLLIN)]; + match poll(&mut fds, 50_u16) { + Ok(0) | Err(Errno::EINTR) => continue, + Ok(_) => { + if self.cancelled() { + return Err(io::Error::other(StdinReadCancelled)); + } + match nix::unistd::read(&self.stdin, buf) { + Ok(read) => return Ok(read), + Err(Errno::EINTR) => continue, + Err(error) => return Err(io::Error::from_raw_os_error(error as i32)), + } + } + Err(error) => return Err(io::Error::from_raw_os_error(error as i32)), + } + } + } } fn is_classic_pcap(filename: &Path) -> Result { @@ -652,6 +774,7 @@ mod tests { PacketParser { backend: PacketBackend::from_stream(Box::new(Cursor::new(bytes)), "test-stream") .expect("stream parser opens"), + stdin_shutdown: None, enforce_monotonic_timestamps: false, packet_ordinal: 0, last_timestamp_micros: None, diff --git a/src/packet_parser_wire_tests.rs b/src/packet_parser_wire_tests.rs index e8bde61..96dfaf6 100644 --- a/src/packet_parser_wire_tests.rs +++ b/src/packet_parser_wire_tests.rs @@ -123,6 +123,7 @@ fn parser(bytes: Vec) -> PacketParser { PacketParser { backend: PacketBackend::from_stream(Box::new(Cursor::new(bytes)), "wire-fixture") .expect("stream opens"), + stdin_shutdown: None, enforce_monotonic_timestamps: false, packet_ordinal: 0, last_timestamp_micros: None, diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index c22ba14..29811cb 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -15,6 +15,21 @@ use std::fs; use std::io::{BufRead, BufReader, Write}; use std::process::Command; use std::process::Stdio; +#[cfg(unix)] +use std::time::{Duration, Instant}; + +#[cfg(unix)] +use nix::poll::{PollFd, PollFlags, poll}; +#[cfg(unix)] +use nix::sys::signal::{Signal, kill}; +#[cfg(unix)] +use nix::unistd::Pid; +#[cfg(unix)] +use std::io::Read; +#[cfg(unix)] +use std::os::fd::AsFd; +#[cfg(unix)] +use std::process::Child; fn dpp_binary() -> &'static str { env!("CARGO_BIN_EXE_dpp") @@ -50,6 +65,149 @@ fn append_opt_record(dns_payload: &mut Vec, extended_high: u8, edns_version: dns_payload.extend_from_slice(&0_u16.to_be_bytes()); } +#[cfg(unix)] +fn wait_for_child_exit(child: &mut Child) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child + .try_wait() + .expect("child status is available") + .is_some() + { + return; + } + if Instant::now() >= deadline { + child.kill().expect("stalled child is killed"); + child.wait().expect("killed child is reaped"); + panic!("DPP did not exit promptly after the termination signal"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[cfg(unix)] +fn wait_for_output_creation(child: &mut Child, path: &std::path::Path) { + let deadline = Instant::now() + Duration::from_secs(5); + while !path.exists() { + assert!( + child + .try_wait() + .expect("child status is available") + .is_none(), + "DPP exited before its output writer was created" + ); + if Instant::now() >= deadline { + child.kill().expect("stalled child is killed"); + child.wait().expect("killed child is reaped"); + panic!("DPP did not create its output writer"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[cfg(unix)] +fn wait_for_stdin_start_log(child: &mut Child) { + let deadline = Instant::now() + Duration::from_secs(5); + let mut logs = Vec::new(); + while !logs + .windows(b"Starting to process PCAP stream from stdin".len()) + .any(|window| window == b"Starting to process PCAP stream from stdin") + { + if Instant::now() >= deadline { + child.kill().expect("stalled child is killed"); + child.wait().expect("killed child is reaped"); + panic!("DPP did not reach stdin initialization"); + } + let ready = { + let stderr = child.stderr.as_ref().expect("stderr is piped"); + let mut fds = [PollFd::new(stderr.as_fd(), PollFlags::POLLIN)]; + poll(&mut fds, 100_u16).expect("stderr readiness is polled") + }; + if ready != 0 { + let mut buf = [0_u8; 2048]; + let read = child + .stderr + .as_mut() + .expect("stderr is piped") + .read(&mut buf) + .expect("startup log is readable"); + assert_ne!(read, 0, "DPP exited before stdin initialization"); + logs.extend_from_slice(&buf[..read]); + } + } +} + +#[cfg(unix)] +fn run_open_stdin_signal_test(name: &str, bytes: &[u8], signal: Signal, has_header: bool) { + let output_path = temp_test_path(name, "csv"); + let mut child = Command::new(dpp_binary()) + .args([ + "--report-format", + if has_header { "json" } else { "text" }, + "-", + ]) + .arg(&output_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("DPP starts"); + + if !has_header { + wait_for_stdin_start_log(&mut child); + } + let mut stdin = child.stdin.take().expect("stdin is piped"); + stdin.write_all(bytes).expect("capture bytes are written"); + if has_header { + wait_for_output_creation(&mut child, &output_path); + } + std::thread::sleep(Duration::from_millis(100)); + assert!( + child + .try_wait() + .expect("child status is available") + .is_none(), + "DPP must still be waiting on the open stdin pipe" + ); + + kill( + Pid::from_raw(i32::try_from(child.id()).expect("PID fits i32")), + signal, + ) + .expect("termination signal is sent"); + wait_for_child_exit(&mut child); + let output = child.wait_with_output().expect("DPP output is collected"); + assert!( + output.status.success(), + "DPP did not shut down cleanly: {}", + String::from_utf8_lossy(&output.stderr) + ); + + if has_header { + let report: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("JSON report parses"); + assert_eq!( + report["warnings"]["graceful_signal_shutdown"], + serde_json::json!(true) + ); + assert_eq!( + fs::read_to_string(&output_path).expect("CSV writer closed and flushed"), + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n" + ); + fs::remove_file(&output_path).expect("remove output csv"); + } else { + assert!( + output.stdout.is_empty(), + "no summary exists before capture setup" + ); + assert!(!output_path.exists(), "writer was not initialized"); + } + + // Keep the parent end of the pipe open until after DPP exits. A closed pipe + // would test EOF handling instead of signal cancellation. + drop(stdin); +} + #[test] fn matched_query_response_pair_round_trips_to_exact_csv_record() { let input_path = temp_test_path("matched-query-response", "pcap"); @@ -504,6 +662,58 @@ fn stdin_pcapng_stream_round_trips_to_exact_csv_record() { fs::remove_file(&output_path).expect("remove output csv"); } +#[cfg(unix)] +#[test] +fn sigint_stops_classic_pcap_stdin_without_eof() { + let mut dns_payload = encode_dns_header(0x1234, 0x0100, 1); + append_example_a_query(&mut dns_payload); + let packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &dns_payload); + let bytes = classic_pcap_bytes(&[(1, 0, &packet)]); + run_open_stdin_signal_test("sigint-classic-open-pipe", &bytes, Signal::SIGINT, true); +} + +#[cfg(unix)] +#[test] +fn sigint_stops_pcapng_stdin_mid_block_without_eof() { + let mut dns_payload = encode_dns_header(0x1234, 0x0100, 1); + append_example_a_query(&mut dns_payload); + let packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &dns_payload); + let mut bytes = pcapng_bytes(&[(1_000_000, &packet)]); + // A complete next block prefix declares a body that never arrives. This + // exercises read_exact/read_to_end cancellation while stdin stays open. + bytes.extend_from_within(..12); + run_open_stdin_signal_test("sigint-pcapng-mid-block", &bytes, Signal::SIGINT, true); +} + +#[cfg(unix)] +#[test] +fn sigterm_stops_classic_pcap_stdin_mid_record_without_eof() { + let mut dns_payload = encode_dns_header(0x1234, 0x0100, 1); + append_example_a_query(&mut dns_payload); + let packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &dns_payload); + let mut bytes = classic_pcap_bytes(&[(1, 0, &packet)]); + bytes.extend_from_slice(&2_u32.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&64_u32.to_le_bytes()); + bytes.extend_from_slice(&64_u32.to_le_bytes()); + bytes.extend_from_slice(&[0_u8; 3]); + run_open_stdin_signal_test("sigterm-classic-mid-record", &bytes, Signal::SIGTERM, true); +} + +#[cfg(unix)] +#[test] +fn sigint_stops_stdin_probe_before_capture_header_without_eof() { + run_open_stdin_signal_test( + "sigint-before-capture-header", + &[0xd4, 0xc3, 0xb2], + Signal::SIGINT, + false, + ); +} + #[test] fn unmatched_query_finalizes_to_timeout_record_in_completed_run() { let input_path = temp_test_path("timeout-query", "pcap"); diff --git a/tools/dns-pcap-generator/src/artifact.rs b/tools/dns-pcap-generator/src/artifact.rs index 7dfaee9..6c3cca4 100644 --- a/tools/dns-pcap-generator/src/artifact.rs +++ b/tools/dns-pcap-generator/src/artifact.rs @@ -396,6 +396,15 @@ fn validate_fitted_profile(profile: &RawFittedGeneratorProfile, path: &Path) -> "duplicate_model.retry_count_weights", &profile.duplicate_model.retry_count_weights, )?; + let minimum_retry_count = profile.duplicate_model.retry_count_weights[0].retry_count; + if profile.generation_defaults.duplicate_max < minimum_retry_count { + return Err(invalid_fitted_profile( + path, + format!( + "generation_defaults.duplicate_max must be at least the first duplicate_model.retry_count_weights retry_count ({minimum_retry_count})" + ), + )); + } validate_response_codes(path, &profile.response_codes)?; validate_response_delay(path, &profile.response_delay)?; validate_retry_steps( diff --git a/tools/dns-pcap-generator/src/catalog.rs b/tools/dns-pcap-generator/src/catalog.rs index 55fdef4..ec18c32 100644 --- a/tools/dns-pcap-generator/src/catalog.rs +++ b/tools/dns-pcap-generator/src/catalog.rs @@ -7,6 +7,7 @@ use crate::error::{Error, Result}; use crate::model::WeightedDomain; +use crate::packet::validate_dns_name; use std::borrow::Cow; use std::fs; use std::path::Path; @@ -26,6 +27,15 @@ pub(crate) fn load_catalog(tsv: &str) -> Result> { line: line_number + 1, source, })?; + if weight == 0 { + return Err(Error::ZeroCatalogWeight { + line: line_number + 1, + }); + } + validate_dns_name(name).map_err(|source| Error::InvalidCatalogName { + line: line_number + 1, + source: Box::new(source), + })?; domains.push(WeightedDomain { name: Cow::Owned(name.to_string()), weight, diff --git a/tools/dns-pcap-generator/src/error.rs b/tools/dns-pcap-generator/src/error.rs index 5deee16..52914c1 100644 --- a/tools/dns-pcap-generator/src/error.rs +++ b/tools/dns-pcap-generator/src/error.rs @@ -76,6 +76,16 @@ pub enum Error { source: std::num::ParseIntError, }, + #[error("catalog weight at line {line} must be greater than 0")] + ZeroCatalogWeight { line: usize }, + + #[error("invalid DNS name in catalog at line {line}")] + InvalidCatalogName { + line: usize, + #[source] + source: Box, + }, + #[error("failed to read catalog '{path}'")] CatalogRead { path: PathBuf, @@ -133,6 +143,12 @@ pub enum Error { #[error("DNS label '{label}' exceeds 63 bytes")] DnsLabelTooLong { label: String }, + #[error("DNS name '{qname}' exceeds 255 wire bytes")] + DnsNameTooLong { qname: String }, + + #[error("generated timestamp exceeds the classic PCAP 32-bit seconds range")] + TimestampOutOfRange, + #[error("profile '{profile}' must expose at least {minimum} positive domains, found {found}")] ProfileTooFewPositiveDomains { profile: String, diff --git a/tools/dns-pcap-generator/src/generator.rs b/tools/dns-pcap-generator/src/generator.rs index 89015bd..1fed68f 100644 --- a/tools/dns-pcap-generator/src/generator.rs +++ b/tools/dns-pcap-generator/src/generator.rs @@ -92,6 +92,7 @@ struct GeneratorState<'a> { resolvers: Vec, next_packet_ordinal: u64, current_timestamp: Duration, + fractional_interarrival_nanoseconds: f64, summary: GenerationSummary, } @@ -106,6 +107,7 @@ impl<'a> GeneratorState<'a> { resolvers: build_resolver_pool(config.resolvers), next_packet_ordinal: 0, current_timestamp: Duration::from_secs(config.start_epoch_seconds), + fractional_interarrival_nanoseconds: 0.0, summary: GenerationSummary::default(), } } @@ -113,8 +115,11 @@ impl<'a> GeneratorState<'a> { fn run(&mut self, writer: &mut PcapWriter) -> Result<()> { for _ in 0..self.config.transactions { self.summary.logical_transactions += 1; - let interarrival = self.sample_interarrival_duration(); - self.current_timestamp += interarrival; + let interarrival = self.sample_interarrival_duration()?; + self.current_timestamp = self + .current_timestamp + .checked_add(interarrival) + .ok_or(Error::TimestampOutOfRange)?; self.flush_due_packets(writer, self.current_timestamp)?; @@ -188,10 +193,23 @@ impl<'a> GeneratorState<'a> { Ok(()) } - fn sample_interarrival_duration(&mut self) -> Duration { + fn sample_interarrival_duration(&mut self) -> Result { let uniform = (1.0 - self.rng.next_f64()).clamp(f64::MIN_POSITIVE, 1.0); let seconds = -uniform.ln() / self.config.qps; - Duration::from_micros((seconds.mul_add(1_000_000.0, 0.0).round() as u64).max(1)) + if !seconds.is_finite() || seconds >= u64::MAX as f64 { + return Err(Error::TimestampOutOfRange); + } + // Keep sub-microsecond inter-arrivals in the scheduler. Classic PCAP + // truncates timestamps to microseconds when packets are written, so + // multiple queries can legitimately share one capture timestamp. + let nanoseconds = seconds + .fract() + .mul_add(1_000_000_000.0, self.fractional_interarrival_nanoseconds); + let whole_nanoseconds = nanoseconds.floor(); + self.fractional_interarrival_nanoseconds = nanoseconds - whole_nanoseconds; + Duration::from_secs(seconds.trunc() as u64) + .checked_add(Duration::from_nanos(whole_nanoseconds as u64)) + .ok_or(Error::TimestampOutOfRange) } fn sample_duplicate_count(&mut self) -> u8 { @@ -313,6 +331,9 @@ impl<'a> GeneratorState<'a> { timestamp: Duration, bytes: Vec, ) -> Result<()> { + if timestamp.as_secs() > u64::from(u32::MAX) { + return Err(Error::TimestampOutOfRange); + } self.summary.first_timestamp.get_or_insert(timestamp); self.summary.last_timestamp = Some(timestamp); diff --git a/tools/dns-pcap-generator/src/packet.rs b/tools/dns-pcap-generator/src/packet.rs index 135276c..e2f59d7 100644 --- a/tools/dns-pcap-generator/src/packet.rs +++ b/tools/dns-pcap-generator/src/packet.rs @@ -13,6 +13,7 @@ use std::net::Ipv4Addr; pub(crate) const DNS_PORT: u16 = 53; pub(crate) const MAX_SYNTHETIC_CLIENTS: usize = 64 * 256 * 254; pub(crate) const MAX_SYNTHETIC_RESOLVERS: usize = 203 * 256; +const MAX_DNS_NAME_WIRE_LEN: usize = 255; const IPV4_HEADER_LEN: usize = 20; const UDP_HEADER_LEN: usize = 8; pub(crate) const ROOT_NAME_SERVER_TARGETS: &[&str] = &[ @@ -256,7 +257,20 @@ fn synthetic_ptr_target_for_name(name: &str) -> String { format!("ptr-{:016x}.synthetic.example", value) } +pub(crate) fn validate_dns_name(qname: &str) -> Result<()> { + visit_dns_labels(qname, |_| {}) +} + fn append_dns_name(buffer: &mut Vec, qname: &str) -> Result<()> { + visit_dns_labels(qname, |label| { + buffer.push(label.len() as u8); + buffer.extend_from_slice(label); + })?; + buffer.push(0); + Ok(()) +} + +fn visit_dns_labels(qname: &str, mut visit: impl FnMut(&[u8])) -> Result<()> { if qname.is_empty() { return Err(Error::EmptyDnsName); } @@ -268,10 +282,10 @@ fn append_dns_name(buffer: &mut Vec, qname: &str) -> Result<()> { }; if canonical.is_empty() { - buffer.push(0); return Ok(()); } + let mut wire_len = 1; // Final root label. for label in canonical.split('.') { if label.is_empty() { return Err(Error::EmptyDnsLabel { @@ -283,11 +297,15 @@ fn append_dns_name(buffer: &mut Vec, qname: &str) -> Result<()> { label: label.to_string(), }); } - buffer.push(label.len() as u8); - buffer.extend_from_slice(label.as_bytes()); + wire_len += 1 + label.len(); + if wire_len > MAX_DNS_NAME_WIRE_LEN { + return Err(Error::DnsNameTooLong { + qname: qname.to_string(), + }); + } + visit(label.as_bytes()); } - buffer.push(0); Ok(()) } diff --git a/tools/dns-pcap-generator/src/tests.rs b/tools/dns-pcap-generator/src/tests.rs index 67340db..90b3753 100644 --- a/tools/dns-pcap-generator/src/tests.rs +++ b/tools/dns-pcap-generator/src/tests.rs @@ -146,6 +146,57 @@ fn catalog_exposes_large_domain_set() { assert_eq!(domains.len(), 10_000); } +#[test] +fn catalog_rejects_zero_weights_and_names_longer_than_dns_wire_limit() { + assert!(matches!( + load_catalog("0\texample.com\n"), + Err(Error::ZeroCatalogWeight { line: 1 }) + )); + + let long_name = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(62) + ); + let error = load_catalog(&format!("1\t{long_name}\n")) + .expect_err("256-byte wire name must be rejected at catalog load"); + assert!(matches!( + error, + Error::InvalidCatalogName { line: 1, source } + if matches!(*source, Error::DnsNameTooLong { .. }) + )); +} + +#[test] +fn dns_name_wire_limit_accepts_255_bytes_and_rejects_256_bytes() { + let labels = ["a".repeat(63), "b".repeat(63), "c".repeat(63)]; + let valid_name = format!( + "{}.{}.{}.{}", + labels[0], + labels[1], + labels[2], + "d".repeat(61) + ); + let query = build_dns_query_payload(0x1234, &valid_name, DnsQuestionType::A) + .expect("255-byte wire name must encode"); + assert_eq!(query.len(), 12 + 255 + 4); + assert_eq!(query[12 + 254], 0); + + let invalid_name = format!( + "{}.{}.{}.{}", + labels[0], + labels[1], + labels[2], + "d".repeat(62) + ); + assert!(matches!( + build_dns_query_payload(0x1234, &invalid_name, DnsQuestionType::A), + Err(Error::DnsNameTooLong { .. }) + )); +} + #[test] fn sanitized_profile_contains_no_disallowed_domains() { let profile = @@ -320,6 +371,38 @@ fn profile_dir_loader_rejects_catalog_hash_mismatch() { fs::remove_dir_all(dir).expect("temp profile dir removed"); } +#[test] +fn profile_dir_loader_rejects_duplicate_max_below_first_retry_count() { + let dir = temp_profile_dir("duplicate-max-below-first-retry"); + write_profile_fixture(&dir); + + let fitted_path = dir.join("fitted-generator.toml"); + let fitted = fs::read_to_string(&fitted_path).expect("fitted profile read"); + fs::write( + &fitted_path, + fitted + .replace( + "retry_count = 1, weight = 800", + "retry_count = 2, weight = 800", + ) + .replace( + "retry_count = 2, weight = 200", + "retry_count = 3, weight = 200", + ) + .replace("duplicate_max = 2", "duplicate_max = 1"), + ) + .expect("fitted profile rewritten"); + + let error = load_profile_dir_artifact(&dir).expect_err("invalid retry cap must fail"); + assert!(matches!( + error, + Error::FittedProfileInvalid { message, .. } + if message.contains("duplicate_max must be at least") + )); + + fs::remove_dir_all(dir).expect("temp profile dir removed"); +} + #[test] fn runtime_profile_dir_loader_accepts_small_extracted_catalogs() { let dir = temp_profile_dir("runtime-small-catalog"); @@ -426,3 +509,90 @@ fn generated_pcap_round_trips_packet_count_and_timestamp_order() { fs::remove_dir_all(dir).expect("temp profile dir removed"); } + +#[test] +fn million_qps_capture_preserves_rate_and_timestamp_order() { + let mut config = test_config(); + config.transactions = 20_000; + config.qps = 1_000_000.0; + config.duplicate_rate = 0.0; + config.timeout_rate = 0.0; + + let dir = temp_profile_dir("million-qps"); + write_profile_fixture(&dir); + let profile = load_profile_dir_artifact(&dir).expect("fixture profile loads"); + let (writer, summary) = + write_capture(Cursor::new(Vec::new()), &config, &profile).expect("capture writes"); + + let mut reader = PcapReader::new(Cursor::new(writer.into_inner())).expect("pcap reader opens"); + let mut first_timestamp = None; + let mut previous_timestamp = Duration::ZERO; + let mut timestamp_ties = 0; + let mut packet_count = 0; + while let Some(packet) = reader.next_packet() { + let packet = packet.expect("packet decodes"); + first_timestamp.get_or_insert(packet.timestamp); + assert!(packet.timestamp >= previous_timestamp); + if packet.timestamp == previous_timestamp { + timestamp_ties += 1; + } + previous_timestamp = packet.timestamp; + packet_count += 1; + } + + assert_eq!(packet_count, summary.total_packets()); + assert!( + timestamp_ties > 0, + "microsecond PCAP must allow timestamp ties" + ); + let span = (previous_timestamp - first_timestamp.expect("capture is nonempty")).as_secs_f64(); + assert!( + (0.018..0.022).contains(&span), + "20,000 transactions at 1M QPS should span about 0.02s, got {span}s" + ); + + fs::remove_dir_all(dir).expect("temp profile dir removed"); +} + +#[test] +fn extremely_low_qps_reports_timestamp_range_error_without_panicking() { + let mut config = test_config(); + config.transactions = 1; + config.qps = f64::MIN_POSITIVE; + + let dir = temp_profile_dir("tiny-qps"); + write_profile_fixture(&dir); + let profile = load_profile_dir_artifact(&dir).expect("fixture profile loads"); + let error = write_capture(Cursor::new(Vec::new()), &config, &profile) + .expect_err("unrepresentable capture timestamp must fail"); + assert!(matches!(error, Error::TimestampOutOfRange)); + + fs::remove_dir_all(dir).expect("temp profile dir removed"); +} + +#[test] +fn delayed_response_beyond_classic_pcap_range_reports_timestamp_error() { + let mut config = test_config(); + config.transactions = 1; + config.qps = 1_000_000_000.0; + config.duplicate_rate = 0.0; + config.timeout_rate = 0.0; + config.start_epoch_seconds = u64::from(u32::MAX); + + let dir = temp_profile_dir("response-timestamp-range"); + write_profile_fixture(&dir); + let mut profile = load_profile_dir_artifact(&dir).expect("fixture profile loads"); + let one_second_delay = crate::model::DelayBucket { + weight: 1, + min_us: 1_000_000, + max_us: 1_000_000, + }; + profile.normal_response_delay_buckets = vec![one_second_delay]; + profile.servfail_response_delay_buckets = vec![one_second_delay]; + + let error = write_capture(Cursor::new(Vec::new()), &config, &profile) + .expect_err("response timestamp after 2106 must fail"); + assert!(matches!(error, Error::TimestampOutOfRange)); + + fs::remove_dir_all(dir).expect("temp profile dir removed"); +} From 4e4c5f4e8a130e43883c6f9ad2b324c28e333d1d Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Fri, 25 Sep 2026 18:29:26 +0300 Subject: [PATCH 02/28] Bump version and upgrade deps --- Cargo.lock | 160 ++++++++++++++-------------- Cargo.toml | 12 +-- tools/dns-pcap-generator/Cargo.toml | 2 +- 3 files changed, 87 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9147058..7505c28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", @@ -59,9 +59,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -142,9 +142,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -186,7 +186,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -239,15 +239,15 @@ dependencies = [ [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.64" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "jobserver", @@ -257,9 +257,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -332,7 +332,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -559,7 +559,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -570,7 +570,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -614,7 +614,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -628,7 +628,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -665,7 +665,7 @@ dependencies = [ [[package]] name = "dpp" -version = "0.3.2" +version = "0.3.3" dependencies = [ "aes", "anyhow", @@ -738,9 +738,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "flate2" @@ -1084,7 +1084,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1103,7 +1103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1135,9 +1135,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -1206,9 +1206,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mimalloc" @@ -1241,7 +1241,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1314,9 +1314,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1365,7 +1365,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -1382,7 +1382,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", ] @@ -1425,9 +1425,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "parquet" -version = "59.2.0" +version = "59.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7065842956a20c2a536924ce8e4d9955f7422451511b9eb7500d7bfe5077e59c" +checksum = "ff322f54b1a0f9288e614ed1f2d329b380af5476420db19f46ffb865e1163d73" dependencies = [ "ahash", "brotli", @@ -1540,23 +1540,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1664,9 +1664,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1728,7 +1728,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1849,7 +1849,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1865,9 +1865,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1882,14 +1882,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "sysinfo" -version = "0.39.3" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", @@ -1936,7 +1936,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1947,7 +1947,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1981,9 +1981,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -2003,9 +2003,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2047,9 +2047,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -2071,18 +2071,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -2103,7 +2103,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2154,9 +2154,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "untrusted" @@ -2196,9 +2196,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vergen" -version = "10.0.0" +version = "10.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bdf18a54cf91b4d98a8e8b67f6321606539fbcdcac02536286ad1de37b53fd2" +checksum = "d7081a999aada4e01c8ee04d32254e021e317dd051a250169305fbf2acf21824" dependencies = [ "anyhow", "bon", @@ -2211,9 +2211,9 @@ dependencies = [ [[package]] name = "vergen-gitcl" -version = "10.0.0" +version = "10.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4961429ed12888cb3c6dd20f7dc9508c821091a3ba5fec0156ed5a654c1c4572" +checksum = "55d813ef078c9e739ebe9a121b8319964e1a03cc9e3d2fe30c6cf812d4c57289" dependencies = [ "anyhow", "bon", @@ -2225,9 +2225,9 @@ dependencies = [ [[package]] name = "vergen-lib" -version = "10.0.0" +version = "10.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "910e8471e27130bbc019e9bfa6bda16dfc4c6dd7c5d0793da70a9256caeae984" +checksum = "1343066736be577e40cf72914ecd68b369f7033ce0f7db6b25a6d6f7fbb3ed70" dependencies = [ "anyhow", "bon", @@ -2297,7 +2297,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2394,7 +2394,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2405,7 +2405,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2613,28 +2613,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2654,7 +2654,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -2688,7 +2688,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3af7ad3..1707725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "dpp" -version = "0.3.2" +version = "0.3.3" edition = "2024" rust-version = "1.97.1" build = "build.rs" # Custom build script to include version information from git @@ -27,8 +27,8 @@ overflow-checks = false # Benchmark-focused profile for trusted perf runs on re # Build dependencies used during the compilation process [build-dependencies] -vergen-gitcl = { version = "10.0.0", features = ["build", "cargo", "rustc", "si"] } -anyhow = "1.0.103" # Error handling used in the build script +vergen-gitcl = { version = "10.0.3", features = ["build", "cargo", "rustc", "si"] } +anyhow = "1.0.104" # Error handling used in the build script # Development profile settings for faster iteration [profile.dev] @@ -57,8 +57,8 @@ anyhow = "1.0.104" # Error handling library num-format = "0.4" # Number formatting for logging and output sysinfo = "0.39" # System information retrieval (for memory monitoring) arrayvec = "0.7" # Fixed-size array wrapper for DNS field storage -bytes = "1.11" # Shared byte storage for zero-copy Parquet values -parquet = { version = "59.2.0", default-features = false, features = ["snap", "brotli", "flate2-zlib-rs", "lz4", "zstd"] } # Apache Parquet format support for exporting results +bytes = "1.12" # Shared byte storage for zero-copy Parquet values +parquet = { version = "59.3.0", default-features = false, features = ["snap", "brotli", "flate2-zlib-rs", "lz4", "zstd"] } # Apache Parquet format support for exporting results serde = { version = "1.0", features = ["derive"] } # Serialization and deserialization support clap = { version = "4.6", features = ["derive"] } # Command-line argument parsing tikv-jemallocator = { version = "0.7.0", optional = true } # Jemalloc successor for Rust global allocation @@ -68,7 +68,7 @@ core_affinity = "0.8.3" # To assign threads to specific CPU cores tracing = "0.1" tracing-subscriber = { version = "0.3.23", default-features = false, features = ["fmt", "ansi", "chrono"] } tracing-log = "0.2.0" # Bridges log-based dependencies into tracing -aes = "0.9.2" +aes = "0.9.3" pbkdf2 = "0.13.0" sha2 = "0.11.0" itoa = "1.0" diff --git a/tools/dns-pcap-generator/Cargo.toml b/tools/dns-pcap-generator/Cargo.toml index 4fa1ad5..9da2c49 100644 --- a/tools/dns-pcap-generator/Cargo.toml +++ b/tools/dns-pcap-generator/Cargo.toml @@ -11,4 +11,4 @@ seahash = "4.1.0" serde = { version = "1.0", features = ["derive"] } sha2 = "0.11.0" thiserror = "2.0" -toml = "1.1.2" +toml = "1.1.6" From 8fff36745fe6024ff49ad3824224f3936bd785e0 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Fri, 25 Sep 2026 19:25:51 +0300 Subject: [PATCH 03/28] Add opt-in IPv4 fragment matching and reassembly --- README.md | 17 +- docs/architecture.md | 25 +- docs/rfc/0005-dual-path-pcap-parsing.md | 19 +- src/app.rs | 61 +- src/cli.rs | 59 ++ src/config.rs | 4 + src/dns_processor.rs | 16 + src/dns_processor/matcher.rs | 23 +- src/dns_processor/parser.rs | 441 +++++++++++++-- src/dns_processor/pipeline.rs | 258 ++++++++- src/dns_processor/reassembly.rs | 710 ++++++++++++++++++++++++ src/dns_processor/tests.rs | 6 +- src/dns_processor/types.rs | 7 +- src/packet_parser.rs | 2 +- src/parquet_writer.rs | 12 + src/record.rs | 5 +- tests/end_to_end.rs | 225 ++++++++ 17 files changed, 1810 insertions(+), 80 deletions(-) create mode 100644 src/dns_processor/reassembly.rs diff --git a/README.md b/README.md index a34de34..40c41c9 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,8 @@ Notes: | `--v2` | Use Parquet Version 2 | | `-a, --affinity` | Apply CPU affinity to processing threads | | `--dns-wire-fast-path` | Enable the optional question-only DNS wire fast path with `hickory` fallback | +| `--allow-fragments` | Opt in to matching complete queries with first IPv4 response fragments when the DNS header and question are present; no reassembly | +| `--full-fragments` | Reassemble IPv4 fragments into complete UDP datagrams; also enables `--allow-fragments` for incomplete responses | | `--anonymize ` | Path to the pseudonymization key file | | `-h, --help` | Print help | | `-V, --version` | Print version | @@ -424,6 +426,8 @@ What this tells you: - the first query for example.com A was matched with a response about 20.736 ms later; - the second query for example.org A had no matching response within the timeout window; - empty response_timestamp and response_code mean timeout. +- a response_timestamp with an empty response_code means a first-fragment response prefix matched, + but the complete response code was unavailable. ### A simple AWK analysis to measure DNS traffic latency ```bash @@ -502,15 +506,22 @@ Additional notes: - DPP auto-sizes its execution budget from all available CPUs. - Historical `--threads` and `DPP_THREADS` inputs are accepted only as deprecated compatibility no-ops and emit a warning if used. - The parser fast path is opt-in through `--dns-wire-fast-path` or `DPP_DNS_WIRE_FAST_PATH=1`; without that flag DPP uses the legacy `hickory` question decoder. +- `--allow-fragments` or `DPP_ALLOW_FRAGMENTS=1` optionally pairs a query with the observable first IPv4 fragment of a response when its DNS header and complete question are present. `response_code` is populated when there are no additional records or every declared DNS record fits in the first fragment; otherwise it is empty. The JSON metric `fragmented_response_prefix_count` (text: `Accepted first IPv4 response fragments`) counts accepted prefixes, including ones without a matching query. +- `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. - For captures normalized with `reordercap`, `--monotonic-capture` can reduce in-flight matcher state by enabling batched timeout eviction. If a timestamp regression is detected, DPP fails the run instead of silently weakening matching semantics. - Global allocator choice is a build-time concern. See [docs/allocator-guide.md](docs/allocator-guide.md) for the supported allocator matrix and [benches/allocator-benchmarking.md](benches/allocator-benchmarking.md) for the comparison protocol. ## Limitations - **UDP/53 only:** DPP currently processes DNS traffic over UDP port 53 only. -- DPP does not reassemble IPv4 or IPv6 fragments. IPv6 Hop-by-Hop, Routing, Destination Options, - Authentication and atomic Fragment headers are traversed before UDP; fragments requiring - reassembly, ESP and IPv6 jumbograms are unsupported. Flow identity uses observed IP endpoints. +- By default, IPv4 fragments are skipped. `--allow-fragments` uses only an observed first IPv4 + response fragment when its DNS header and complete question are available. This heuristic cannot + confirm that later fragments arrived or validate the full UDP/DNS contents; the response code + may be unknown if needed records are beyond the first fragment. `--full-fragments` reconstructs + complete IPv4 datagrams before the existing UDP/DNS checks and falls back to the first-response + heuristic for incomplete sets. Neither mode reassembles IPv6 fragments. IPv6 Hop-by-Hop, Routing, + Destination Options, Authentication and atomic Fragment headers are traversed before UDP; ESP + and IPv6 jumbograms are unsupported. Flow identity uses observed IP endpoints. - If capture parsing fails after processing begins, DPP flushes valid partial (not atomic) output from complete accepted batches and exits with an error; pending queries are not emitted as timeouts. - **PCAPNG support level:** DPP supports PCAPNG on stream input and via `libpcap` on regular-file fallback paths, but the performance-critical pure-Rust fast path remains focused on classic PCAP. diff --git a/docs/architecture.md b/docs/architecture.md index 80f7f1a..62c2074 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,19 +156,36 @@ or second encoding of canonical addresses and ports. shard-local DNS decode, but that reuse must stay within the same ownership boundary so packet parsing does not gain a second source of truth for IP/port extraction. That metadata owns the exact DNS byte range validated against IPv4 Total Length or IPv6 Payload Length and then UDP - Length; capture padding and trailing IP payload cannot extend the DNS slice. A compact relative + Length; capture padding and trailing IP payload cannot extend the DNS slice. For an opted-in + first IPv4 response fragment, the DNS slice ends at that fragment's Total Length, and the UDP + Length is checked against the declared datagram size. A compact relative offset preserves DNS starts above 65535 without widening every packet's routing metadata: the offset is measured from the minimum Ethernet/IPv4/UDP header length, and construction checks that it fits. DNS QR determines direction and the canonical client/resolver flow, including exchanges with UDP port 53 on both ends. IPv6 Hop-by-Hop, Routing, Destination Options, AH and atomic Fragment headers are traversed - within the declared payload boundary. Non-atomic IPv6 and fragmented IPv4 datagrams are skipped - because this boundary has no IP reassembly stage. The optional runtime flag + within the declared payload boundary. Non-atomic IPv6 fragments and, by default, fragmented + IPv4 datagrams are skipped. With + `--allow-fragments`, only a first IPv4 response fragment with a complete DNS header and question + can enter the matcher as an inferred response; later fragments and fragmented queries remain + skipped. Its response code is present only when there are no additional records or all declared + DNS records fit in that prefix. + `--full-fragments` additionally enables IPv4 reassembly across packet batches and implies + `--allow-fragments`. The DNS processor owns the reassembly state: a complete datagram enters the + existing UDP/DNS validation path only after the first and last fragments establish its bounds + and every byte is present. Incomplete responses may use the first-fragment inference on capacity + eviction or end of input; with monotonic capture, entries can also expire after the match + timeout. Fragmented queries require a complete datagram. A complete datagram + uses the final fragment's (`MF=0`) capture timestamp, while inferred responses use the first + fragment's timestamp. Reassembly state must + remain bounded in memory, and IPv6 fragments remain unsupported. A fragmented UDP datagram must + have an IP payload length equal to its UDP Length; mismatched fragment sets are rejected. + The optional runtime flag `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains the semantic fallback for rare DNS messages that the fast path does not accept. Compression pointers must target prior, nonoverlapping names; enabling the fast path must not weaken that validation. For ordinary QUERY messages (OPCODE 0), more than one question is rejected under - RFC 9619. A response cannot claim answer or authority records that are absent from its DNS + RFC 9619. A complete response cannot claim answer or authority records that are absent from its DNS payload, whether or not it has additional records. TSIG status extraction consumes and bounds-checks the declared Other Data, including the six-byte server time in BADTIME responses. Both paths accept diff --git a/docs/rfc/0005-dual-path-pcap-parsing.md b/docs/rfc/0005-dual-path-pcap-parsing.md index 2ce22fd..b3c676a 100644 --- a/docs/rfc/0005-dual-path-pcap-parsing.md +++ b/docs/rfc/0005-dual-path-pcap-parsing.md @@ -83,8 +83,23 @@ validated DNS offset and length. IPv4 Total Length or IPv6 Payload Length first payload; UDP Length then bounds the DNS payload. Bytes outside either declared boundary, including Ethernet padding and trailing capture bytes, never reach a DNS decoder. -DPP has no IPv4 reassembly stage. IPv4 datagrams with the More Fragments flag or a non-zero fragment -offset are therefore skipped rather than interpreting a fragment body as a complete UDP datagram. +By default, IPv4 datagrams with the More Fragments flag or a non-zero fragment offset are skipped. +With `--allow-fragments`, a first IPv4 response fragment may be matched when its DNS header and +entire question fit in the fragment. The metadata bounds DNS bytes to the observed fragment and +marks the response as partial. This is an inference from the prefix, not validation of the full UDP +datagram. A response code is emitted only if there are no additional records or all declared DNS +records fit in the prefix. +With `--full-fragments`, DPP also reconstructs complete IPv4 datagrams across packet batches. This +mode implies `--allow-fragments`; incomplete responses may use the prefix heuristic on capacity +eviction or end of input, and on elapsed capture-time timeout with `--monotonic-capture`. +Fragmented queries require complete reassembly. The reassembler keys +fragments by source, destination, protocol, and IPv4 identification, places payload bytes at the +declared eight-octet offsets, and waits for the first fragment, final fragment, and contiguous +coverage before sending a datagram through the existing UDP/DNS parser. Fragmented IP payload +length must equal the declared UDP Length; mismatches are rejected. Complete datagrams carry +the final fragment's (`MF=0`) capture timestamp; prefix fallbacks retain the first fragment's +timestamp. The reassembler bounds incomplete state +to prevent unbounded capture-driven memory growth. Neither mode reassembles IPv6 fragments. IPv6 extraction traverses Hop-by-Hop, Routing, Destination Options, Authentication and atomic Fragment headers with per-header bounds checks. Non-atomic fragments require reassembly and are skipped. The DNS offset can exceed 65535 after a long valid extension chain. Internal metadata diff --git a/src/app.rs b/src/app.rs index 2f04873..81048f1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -151,6 +151,18 @@ fn display_parser_mode(args: &AppConfig) { } } +fn display_fragment_mode(args: &AppConfig) { + if args.full_fragments { + info!( + "IPv4 fragment reassembly: enabled (incomplete responses may use first-fragment matching)" + ); + } else if args.allow_fragments { + info!( + "IPv4 response fragment prefix matching: enabled (no reassembly; full UDP/DNS content unverified)" + ); + } +} + fn display_match_timeout(args: &AppConfig) { info!("DNS match timeout: {} ms", args.match_timeout_ms); } @@ -185,6 +197,8 @@ struct RunConfigSummary { match_timeout_ms: u64, monotonic_capture: bool, dns_wire_fast_path: bool, + allow_fragments: bool, + full_fragments: bool, anonymization_enabled: bool, anonymize_key_path: Option, zstd: bool, @@ -229,6 +243,7 @@ struct RunMetricsSummary { total_dns_queries_processed: usize, deduplicated_duplicate_queries: usize, total_dns_responses_processed: usize, + fragmented_response_prefix_count: usize, total_matched_query_response_pairs: usize, timed_out_queries: usize, timed_out_query_ratio: f64, @@ -359,6 +374,8 @@ fn build_run_summary( match_timeout_ms: args.match_timeout_ms, monotonic_capture: args.monotonic_capture, dns_wire_fast_path: args.dns_wire_fast_path, + allow_fragments: args.allow_fragments, + full_fragments: args.full_fragments, anonymization_enabled: args.anonymize_key_path().is_some(), anonymize_key_path: args.anonymize_key_path().map(canonical_path_string), zstd: args.zstd, @@ -383,6 +400,7 @@ fn build_run_summary( total_dns_queries_processed: counters.dns_query_count, deduplicated_duplicate_queries: counters.duplicated_query_count, total_dns_responses_processed: counters.dns_response_count, + fragmented_response_prefix_count: counters.fragmented_response_prefix_count, total_matched_query_response_pairs: counters.matched_query_response_count, timed_out_queries: counters.timeout_query_count, timed_out_query_ratio: timed_out_query_ratio( @@ -439,6 +457,15 @@ fn display_text_summary(summary: &RunSummary) { .total_dns_responses_processed .to_formatted_string(&Locale::en) ); + if summary.config.allow_fragments { + info!( + "Accepted first IPv4 response fragments: {}", + summary + .metrics + .fragmented_response_prefix_count + .to_formatted_string(&Locale::en) + ); + } info!( "{}", oversized_qname_rejection_summary_line( @@ -559,6 +586,7 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { display_parquet_format_information(&args); display_anonymization(&args); display_parser_mode(&args); + display_fragment_mode(&args); display_match_timeout(&args); display_monotonic_capture_mode(&args); @@ -569,7 +597,9 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { args.match_timeout_micros(), args.monotonic_capture, ) - .map_err(|source| AppRunError::DnsProcessorInit { source })?, + .map_err(|source| AppRunError::DnsProcessorInit { source })? + .with_allow_fragments(args.allow_fragments) + .with_full_fragments(args.full_fragments), ); let mut packet_parser = match PacketParser::new_with_shutdown( &args.input_source, @@ -728,6 +758,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, } } @@ -790,6 +822,31 @@ mod tests { ); } + #[test] + fn run_summary_reports_accepted_ipv4_response_prefixes() { + let mut config = test_config(); + config.allow_fragments = true; + config.full_fragments = true; + let summary = build_run_summary( + &config, + config.execution_budget(), + ProcessingCounters { + fragmented_response_prefix_count: 2, + ..ProcessingCounters::default() + }, + RunWarningsSummary::default(), + 0, + 1.0, + 0.5, + 1.5, + ); + + let serialized = serde_json::to_value(&summary).expect("summary serializes"); + assert_eq!(serialized["config"]["allow_fragments"], true); + assert_eq!(serialized["config"]["full_fragments"], true); + assert_eq!(serialized["metrics"]["fragmented_response_prefix_count"], 2); + } + #[test] fn finalize_output_shutdown_prefers_writer_failure() { let (tx, rx) = crossbeam::channel::bounded(1); @@ -924,6 +981,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, } } diff --git a/src/cli.rs b/src/cli.rs index ef75ba8..00a3a5b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,6 +30,8 @@ pub(crate) fn parse_args() -> Result { let env_v2 = parse_env_bool("DPP_V2"); let env_affinity = parse_env_bool("DPP_AFFINITY"); let env_dns_wire_fast_path = parse_env_bool("DPP_DNS_WIRE_FAST_PATH"); + let env_allow_fragments = parse_env_bool("DPP_ALLOW_FRAGMENTS"); + let env_full_fragments = parse_env_bool("DPP_FULL_FRAGMENTS"); let env_monotonic_capture = parse_env_bool("DPP_MONOTONIC_CAPTURE"); let env_threads = env::var("DPP_THREADS") .ok() @@ -90,6 +92,8 @@ pub(crate) fn parse_args() -> Result { let silent = resolve_silent_mode(&matches, env_silent, output_target); let affinity = matches.get_flag("affinity") || env_affinity; let dns_wire_fast_path = matches.get_flag("dns_wire_fast_path") || env_dns_wire_fast_path; + let (allow_fragments, full_fragments) = + resolve_fragment_flags(&matches, env_allow_fragments, env_full_fragments); let monotonic_capture = matches.get_flag("monotonic_capture") || env_monotonic_capture; validate_parquet_only_flags(format, zstd, v2)?; @@ -110,6 +114,8 @@ pub(crate) fn parse_args() -> Result { bonded, anonymize, dns_wire_fast_path, + allow_fragments, + full_fragments, }) } @@ -126,6 +132,8 @@ fn build_cli(version: &'static str) -> Command { DPP_AFFINITY Set to 'true' to apply CPU affinity to processing threads DPP_DNS_WIRE_FAST_PATH Set to 'true' to enable the optional question-only DNS wire fast path with hickory fallback + DPP_ALLOW_FRAGMENTS Set to 'true' to match queries with observable first IPv4 response fragments without reassembly + DPP_FULL_FRAGMENTS Set to 'true' to reassemble IPv4 fragments and enable first-fragment response matching DPP_MONOTONIC_CAPTURE Set to 'true' to assume globally monotonic packet timestamps, enable batched timeout eviction, and abort on timestamp regressions DPP_REPORT_FORMAT Final process report format: text or json (used if --report-format is not specified; json cannot be combined with stdout output) @@ -233,6 +241,18 @@ LICENSE INFORMATION: .help("Enable the optional question-only DNS wire fast path with hickory fallback") .action(ArgAction::SetTrue), ) + .arg( + Arg::new("allow_fragments") + .long("allow-fragments") + .help("Match queries with observable first IPv4 response fragments without reassembly; full UDP/DNS content remains unverified") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("full_fragments") + .long("full-fragments") + .help("Reassemble IPv4 fragments into complete datagrams; also enables --allow-fragments") + .action(ArgAction::SetTrue), + ) .arg( Arg::new("anonymize") .long("anonymize") @@ -274,6 +294,12 @@ fn parse_env_bool(key: &str) -> bool { }) } +fn resolve_fragment_flags(matches: &ArgMatches, env_allow: bool, env_full: bool) -> (bool, bool) { + let full_fragments = matches.get_flag("full_fragments") || env_full; + let allow_fragments = matches.get_flag("allow_fragments") || env_allow || full_fragments; + (allow_fragments, full_fragments) +} + fn resolve_match_timeout_ms(matches: &ArgMatches, env_value: Option<&str>) -> Result { match matches .get_one::("match_timeout_ms") @@ -649,6 +675,39 @@ mod tests { assert!(matches.get_flag("monotonic_capture")); } + #[test] + fn cli_allow_fragments_flag_is_opt_in() { + let default_matches = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert!(!default_matches.get_flag("allow_fragments")); + + let enabled_matches = build_cli("test") + .try_get_matches_from(["dpp", "--allow-fragments", "input.pcap"]) + .expect("cli parses"); + assert!(enabled_matches.get_flag("allow_fragments")); + } + + #[test] + fn full_fragments_implies_allow_fragments() { + let matches = build_cli("test") + .try_get_matches_from(["dpp", "--full-fragments", "input.pcap"]) + .expect("cli parses"); + assert_eq!(resolve_fragment_flags(&matches, false, false), (true, true)); + + let default_matches = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_fragment_flags(&default_matches, false, true), + (true, true) + ); + assert_eq!( + resolve_fragment_flags(&default_matches, false, false), + (false, false) + ); + } + #[test] fn zero_match_timeout_is_rejected() { let error = parse_match_timeout_ms("0").expect_err("zero timeout is invalid"); diff --git a/src/config.rs b/src/config.rs index aaeedb3..1374d80 100644 --- a/src/config.rs +++ b/src/config.rs @@ -166,6 +166,8 @@ pub(crate) struct AppConfig { pub(crate) bonded: usize, pub(crate) anonymize: Option, pub(crate) dns_wire_fast_path: bool, + pub(crate) allow_fragments: bool, + pub(crate) full_fragments: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] @@ -283,6 +285,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, } } diff --git a/src/dns_processor.rs b/src/dns_processor.rs index 9b25298..10df8ff 100644 --- a/src/dns_processor.rs +++ b/src/dns_processor.rs @@ -9,6 +9,7 @@ mod anonymizer; mod matcher; mod parser; mod pipeline; +mod reassembly; #[cfg(test)] mod tests; mod types; @@ -25,6 +26,8 @@ pub struct DnsProcessor { dns_wire_fast_path: bool, match_timeout_micros: i64, monotonic_capture: bool, + allow_fragments: bool, + full_fragments: bool, } impl DnsProcessor { @@ -65,9 +68,22 @@ impl DnsProcessor { dns_wire_fast_path, match_timeout_micros, monotonic_capture, + allow_fragments: false, + full_fragments: false, }) } + pub fn with_allow_fragments(mut self, allow_fragments: bool) -> Self { + self.allow_fragments = allow_fragments; + self + } + + pub fn with_full_fragments(mut self, full_fragments: bool) -> Self { + self.full_fragments = full_fragments; + self.allow_fragments |= full_fragments; + self + } + fn anonymize_ip(&self, ip: &IpAddr) -> IpAddr { self.anonymizer.anonymize_ip(ip) } diff --git a/src/dns_processor/matcher.rs b/src/dns_processor/matcher.rs index e2f4bd6..00f5a82 100644 --- a/src/dns_processor/matcher.rs +++ b/src/dns_processor/matcher.rs @@ -50,7 +50,7 @@ impl DnsProcessor { query_identity: &QueryIdentityKey, query_key: TimelineKey, response_timestamp_micros: i64, - response_code: ProtoResponseCode, + response_code: Option, ) -> DnsRecord { let (id, name, client_ip, src_port, query_type, ..) = query_identity; DnsRecord { @@ -61,7 +61,7 @@ impl DnsProcessor { id: *id, name: name.clone(), query_type: ProtoRecordType::from(*query_type), - response_code: Some(response_code), + response_code, } } @@ -238,6 +238,11 @@ impl DnsProcessor { ) { let response_identity = self.response_identity_from_record(record); let response_key = self.timeline_key_from_record(record); + let response_code = if record.partial_first_ipv4_fragment { + record.partial_response_code + } else { + Some(record.response_code) + }; *dns_response_count += 1; if let Some((_, query_handle)) = self.find_closest_query( @@ -255,7 +260,7 @@ impl DnsProcessor { &response_identity, query_handle, response_key.timestamp_micros, - record.response_code, + response_code, )); *matched_query_response_count += 1; *matched_rtt_sum_micros += response_key @@ -263,12 +268,7 @@ impl DnsProcessor { .saturating_sub(query_handle.timestamp_micros) .max(0) as u64; } else { - self.insert_response_entry( - state, - response_identity, - response_key, - record.response_code, - ); + self.insert_response_entry(state, response_identity, response_key, response_code); } } @@ -408,7 +408,7 @@ impl DnsProcessor { state: &mut MatcherShardState, identity: ResponseIdentityKey, timeline_key: TimelineKey, - response_code: ProtoResponseCode, + response_code: Option, ) { let payload = ResponseEventPayload { response_code }; @@ -557,6 +557,9 @@ impl DnsProcessor { }), ); if !record.is_query { + if record.partial_first_ipv4_fragment { + result.fragmented_response_prefix_count += 1; + } self.process_response( &record, state, diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index e221a78..1409598 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -35,6 +35,8 @@ const IP_AUTHENTICATION: u8 = 51; const IPV6_DESTINATION_OPTIONS: u8 = 60; const IPV6_FRAGMENT_OFFSET_AND_MORE: u16 = 0xfff9; const IPV4_MORE_FRAGMENTS: u16 = 0x2000; +const IPV4_DONT_FRAGMENT: u16 = 0x4000; +const IPV4_RESERVED_FLAG: u16 = 0x8000; const IPV4_FRAGMENT_OFFSET_MASK: u16 = 0x1fff; const DNS_PORT: u16 = 53; const DNS_POINTER_MASK: u8 = 0b1100_0000; @@ -60,6 +62,8 @@ pub(super) struct ParsedUdpDnsMeta { dns_offset_delta: u16, pub(super) dns_len: u16, pub(super) is_response: bool, + /// The first IPv4 fragment contains a complete question but not the complete UDP datagram. + pub(super) partial_first_ipv4_fragment: bool, } impl ParsedUdpDnsMeta { @@ -112,6 +116,7 @@ struct DecodedDnsHeader { id: u16, opcode: u8, response_code: ProtoResponseCode, + partial_response_code: Option, } struct DecodedDnsQuestion { @@ -162,8 +167,19 @@ struct AdditionalResponseCodeFields { impl DnsProcessor { #[inline] + #[cfg(test)] pub(super) fn packet_routing_meta(data: &[u8]) -> Option { - Self::extract_udp_dns_meta(data).ok().flatten() + Self::packet_routing_meta_with_fragments(data, false) + } + + #[inline] + pub(super) fn packet_routing_meta_with_fragments( + data: &[u8], + allow_first_ipv4_response_fragment: bool, + ) -> Option { + Self::extract_udp_dns_meta_with_fragments(data, allow_first_ipv4_response_fragment) + .ok() + .flatten() } #[inline] @@ -339,8 +355,11 @@ impl DnsProcessor { meta: ParsedUdpDnsMeta, records: &mut Vec, ) -> Result<(), DnsQuestionDecodeError> { - let (header, queries) = - self.decode_dns_questions(meta.dns_data(data)?, meta.is_response)?; + let (header, queries) = self.decode_dns_questions( + meta.dns_data(data)?, + meta.is_response, + meta.partial_first_ipv4_fragment, + )?; self.build_dns_records_into( &header, @@ -389,6 +408,8 @@ impl DnsProcessor { query_class: query.query_class, opcode: header.opcode, response_code, + partial_first_ipv4_fragment: meta.partial_first_ipv4_fragment, + partial_response_code: header.partial_response_code, }); } } @@ -397,12 +418,27 @@ impl DnsProcessor { &self, dns_data: &[u8], decode_extended_rcode: bool, + partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { if self.dns_wire_fast_path { - Self::decode_dns_questions_fast(dns_data, decode_extended_rcode) - .or_else(|_| Self::decode_dns_questions_hickory(dns_data, decode_extended_rcode)) + Self::decode_dns_questions_fast( + dns_data, + decode_extended_rcode, + partial_first_ipv4_fragment, + ) + .or_else(|_| { + Self::decode_dns_questions_hickory( + dns_data, + decode_extended_rcode, + partial_first_ipv4_fragment, + ) + }) } else { - Self::decode_dns_questions_hickory(dns_data, decode_extended_rcode) + Self::decode_dns_questions_hickory( + dns_data, + decode_extended_rcode, + partial_first_ipv4_fragment, + ) } } @@ -424,6 +460,7 @@ impl DnsProcessor { fn decode_dns_questions_fast( dns_data: &[u8], decode_extended_rcode: bool, + partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { if dns_data.len() < DNS_HEADER_LEN { return Err(DnsQuestionDecodeError::Invalid); @@ -463,6 +500,7 @@ impl DnsProcessor { } let mut response_code = ProtoResponseCode::from(HickoryResponseCode::from(0, low_response_code)); + let mut partial_response_code = None; if decode_extended_rcode { let section_counts = DnsSectionCounts { answers: usize::from(Self::parse_u16_at( @@ -481,7 +519,14 @@ impl DnsProcessor { "Failed to parse DNS additional count", )?), }; - if section_counts.answers > 0 + if partial_first_ipv4_fragment { + partial_response_code = Self::decode_partial_response_code_with_sections( + dns_data, + cursor, + section_counts, + low_response_code, + )?; + } else if section_counts.answers > 0 || section_counts.authorities > 0 || section_counts.additionals > 0 { @@ -497,6 +542,7 @@ impl DnsProcessor { id, opcode, response_code, + partial_response_code, }; Ok((header, queries)) @@ -505,6 +551,7 @@ impl DnsProcessor { fn decode_dns_questions_hickory( dns_data: &[u8], decode_extended_rcode: bool, + partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { let mut decoder = BinDecoder::new(dns_data); let header = Header::read(&mut decoder).map_err(|_| DnsQuestionDecodeError::Invalid)?; @@ -515,21 +562,31 @@ impl DnsProcessor { let queries = Message::read_queries(&mut decoder, header.counts.queries as usize) .map_err(Self::classify_hickory_question_error)?; let mut response_code = ProtoResponseCode::from(header.response_code); - if decode_extended_rcode - && (header.counts.answers > 0 - || header.counts.authorities > 0 - || header.counts.additionals > 0) - { - response_code = Self::decode_response_code_with_sections( - dns_data, - decoder.index(), - DnsSectionCounts { - answers: header.counts.answers as usize, - authorities: header.counts.authorities as usize, - additionals: header.counts.additionals as usize, - }, - header.response_code.low(), - )?; + let mut partial_response_code = None; + if decode_extended_rcode { + let section_counts = DnsSectionCounts { + answers: header.counts.answers as usize, + authorities: header.counts.authorities as usize, + additionals: header.counts.additionals as usize, + }; + if partial_first_ipv4_fragment { + partial_response_code = Self::decode_partial_response_code_with_sections( + dns_data, + decoder.index(), + section_counts, + header.response_code.low(), + )?; + } else if section_counts.answers > 0 + || section_counts.authorities > 0 + || section_counts.additionals > 0 + { + response_code = Self::decode_response_code_with_sections( + dns_data, + decoder.index(), + section_counts, + header.response_code.low(), + )?; + } } Ok(( @@ -537,6 +594,7 @@ impl DnsProcessor { id: header.id, opcode, response_code, + partial_response_code, }, queries .into_iter() @@ -585,6 +643,68 @@ impl DnsProcessor { } } + fn decode_partial_response_code_with_sections( + dns_data: &[u8], + cursor: usize, + section_counts: DnsSectionCounts, + low_response_code: u8, + ) -> Result, DnsQuestionDecodeError> { + let low_code = ProtoResponseCode::from(HickoryResponseCode::from(0, low_response_code)); + let mut boundary_cursor = cursor; + let record_count = + section_counts.answers + section_counts.authorities + section_counts.additionals; + let mut additional_fields = AdditionalResponseCodeFields::default(); + for index in 0..record_count { + match Self::read_dns_resource_record_meta(dns_data, &mut boundary_cursor) { + Ok(meta) => { + if index < section_counts.answers + section_counts.authorities { + if meta.record_type == DNS_OPT_RECORD_TYPE { + return Err(DnsQuestionDecodeError::Invalid); + } + } else { + let additional_index = + index - section_counts.answers - section_counts.authorities; + Self::include_additional_response_code_fields( + dns_data, + &mut additional_fields, + &meta, + additional_index, + section_counts.additionals, + ) + .map_err(|_| DnsQuestionDecodeError::Invalid)?; + } + } + Err(error) if Self::is_incomplete_fragment_section(error) => { + // OPT and TSIG are additional records. With no additionals, the header's + // low RCODE is final even when an answer spans later IP fragments. + return Ok((section_counts.additionals == 0).then_some(low_code)); + } + Err(_) => return Err(DnsQuestionDecodeError::Invalid), + } + } + + // All declared records fit in the first fragment. Decode their complete response-code + // context, including OPT and TSIG, using the same validation as full datagrams. + Self::decode_response_code_with_sections( + dns_data, + cursor, + section_counts, + low_response_code, + ) + .map(Some) + .map_err(|_| DnsQuestionDecodeError::Invalid) + } + + fn is_incomplete_fragment_section(error: &str) -> bool { + matches!( + error, + "DNS name truncated" + | "DNS compression pointer truncated" + | "DNS label truncated" + | "DNS resource record truncated" + ) + } + fn skip_dns_resource_records( dns_data: &[u8], cursor: &mut usize, @@ -609,33 +729,47 @@ impl DnsProcessor { let mut fields = AdditionalResponseCodeFields::default(); for index in 0..additional_count { let meta = Self::read_dns_resource_record_meta(dns_data, cursor)?; + Self::include_additional_response_code_fields( + dns_data, + &mut fields, + &meta, + index, + additional_count, + )?; + } - match meta.record_type { - DNS_OPT_RECORD_TYPE => { - if fields.edns_response_code_high.is_some() { - return Err("Multiple OPT records"); - } - if !meta.owner_is_root { - return Err("OPT record owner must be root"); - } + Ok(fields) + } - fields.edns_response_code_high = Some((meta.ttl >> 24) as u8); + fn include_additional_response_code_fields( + dns_data: &[u8], + fields: &mut AdditionalResponseCodeFields, + meta: &DnsResourceRecordMeta, + index: usize, + additional_count: usize, + ) -> Result<(), &'static str> { + match meta.record_type { + DNS_OPT_RECORD_TYPE => { + if fields.edns_response_code_high.is_some() { + return Err("Multiple OPT records"); } - DNS_TSIG_RECORD_TYPE => { - if fields.tsig_error.is_some() { - return Err("Multiple TSIG records"); - } - if index + 1 != additional_count { - return Err("TSIG record must be last additional"); - } - - fields.tsig_error = Some(Self::read_tsig_error(dns_data, &meta)?); + if !meta.owner_is_root { + return Err("OPT record owner must be root"); + } + fields.edns_response_code_high = Some((meta.ttl >> 24) as u8); + } + DNS_TSIG_RECORD_TYPE => { + if fields.tsig_error.is_some() { + return Err("Multiple TSIG records"); + } + if index + 1 != additional_count { + return Err("TSIG record must be last additional"); } - _ => {} + fields.tsig_error = Some(Self::read_tsig_error(dns_data, meta)?); } + _ => {} } - - Ok(fields) + Ok(()) } fn read_tsig_error(dns_data: &[u8], meta: &DnsResourceRecordMeta) -> Result { @@ -893,7 +1027,15 @@ impl DnsProcessor { Ok(u16::from_be_bytes([bytes[0], bytes[1]])) } + #[cfg(test)] fn extract_udp_dns_meta(data: &[u8]) -> Result, &'static str> { + Self::extract_udp_dns_meta_with_fragments(data, false) + } + + fn extract_udp_dns_meta_with_fragments( + data: &[u8], + allow_first_ipv4_response_fragment: bool, + ) -> Result, &'static str> { let ethernet = data .get(..ETHERNET_HEADER_LEN) .ok_or("Failed to parse Ethernet packet")?; @@ -901,7 +1043,11 @@ impl DnsProcessor { let payload = &data[ETHERNET_HEADER_LEN..]; match ethertype { - ETHER_TYPE_IPV4 => Self::extract_udp_dns_from_ipv4(payload, ETHERNET_HEADER_LEN), + ETHER_TYPE_IPV4 => Self::extract_udp_dns_from_ipv4( + payload, + ETHERNET_HEADER_LEN, + allow_first_ipv4_response_fragment, + ), ETHER_TYPE_IPV6 => Self::extract_udp_dns_from_ipv6(payload, ETHERNET_HEADER_LEN), _ => Ok(None), } @@ -910,6 +1056,7 @@ impl DnsProcessor { fn extract_udp_dns_from_ipv4( data: &[u8], l3_offset: usize, + allow_first_ipv4_response_fragment: bool, ) -> Result, &'static str> { let header = data .get(..IPV4_MIN_HEADER_LEN) @@ -938,10 +1085,21 @@ impl DnsProcessor { let flags_and_fragment_offset = Self::parse_u16_at(header, 6, "Failed to parse IPv4 packet")?; - if flags_and_fragment_offset & (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET_MASK) != 0 { - // DNS extraction has no IPv4 fragment reassembly stage. + let fragmented = + flags_and_fragment_offset & (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET_MASK) != 0; + let first_fragment = flags_and_fragment_offset & IPV4_MORE_FRAGMENTS != 0 + && flags_and_fragment_offset & IPV4_FRAGMENT_OFFSET_MASK == 0; + if fragmented && !(allow_first_ipv4_response_fragment && first_fragment) { return Ok(None); } + if first_fragment + && flags_and_fragment_offset & (IPV4_RESERVED_FLAG | IPV4_DONT_FRAGMENT) != 0 + { + return Err("Contradictory IPv4 fragmentation flags"); + } + if first_fragment && (total_length - header_len) % 8 != 0 { + return Err("Non-final IPv4 fragment payload length is not divisible by eight"); + } if header[9] != IP_PROTOCOL_UDP { return Ok(None); @@ -959,6 +1117,8 @@ impl DnsProcessor { src_ip, dst_ip, l3_offset + header_len, + first_fragment, + usize::from(u16::MAX) - header_len, ) } @@ -1002,6 +1162,8 @@ impl DnsProcessor { src_ip, dst_ip, l3_offset + IPV6_HEADER_LEN + cursor, + false, + usize::from(u16::MAX), ); } @@ -1054,6 +1216,8 @@ impl DnsProcessor { src_ip: IpAddr, dst_ip: IpAddr, l4_offset: usize, + partial_first_ipv4_fragment: bool, + maximum_udp_length: usize, ) -> Result, &'static str> { if data.len() < UDP_HEADER_LEN { return Err("Failed to parse UDP packet"); @@ -1069,7 +1233,17 @@ impl DnsProcessor { if udp_length < UDP_HEADER_LEN { return Err("Failed to parse UDP packet"); } - let datagram = data.get(..udp_length).ok_or("Failed to parse UDP packet")?; + if udp_length > maximum_udp_length { + return Err("UDP length exceeds the IP datagram limit"); + } + let datagram = if partial_first_ipv4_fragment { + if udp_length <= data.len() { + return Err("First IPv4 fragment does not contain a partial UDP datagram"); + } + data + } else { + data.get(..udp_length).ok_or("Failed to parse UDP packet")? + }; let dns_data = datagram .get(UDP_HEADER_LEN..) .ok_or("Failed to parse UDP packet")?; @@ -1080,6 +1254,10 @@ impl DnsProcessor { if (is_response && src_port != DNS_PORT) || (!is_response && dst_port != DNS_PORT) { return Ok(None); } + if partial_first_ipv4_fragment && !is_response { + // A first-fragment query is not useful for the response-only opt-in mode. + return Ok(None); + } let dns_offset = l4_offset .checked_add(UDP_HEADER_LEN) @@ -1096,6 +1274,7 @@ impl DnsProcessor { dns_len: u16::try_from(dns_data.len()) .map_err(|_| "UDP DNS payload exceeds supported range")?, is_response, + partial_first_ipv4_fragment, })) } @@ -1150,6 +1329,18 @@ mod protocol_regression_tests { make_udp_dns_packet_with_payload(src, dst, src_port, dst_port, dns) } + fn first_ipv4_fragment(mut packet: Vec, fragment_payload_len: usize) -> Vec { + assert_eq!(fragment_payload_len % 8, 0); + let fragment_len = IPV4_MIN_HEADER_LEN + fragment_payload_len; + assert!(packet.len() > ETHERNET_HEADER_LEN + fragment_len); + packet.truncate(ETHERNET_HEADER_LEN + fragment_len); + packet[ETHERNET_HEADER_LEN + 2..ETHERNET_HEADER_LEN + 4] + .copy_from_slice(&(fragment_len as u16).to_be_bytes()); + packet[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&IPV4_MORE_FRAGMENTS.to_be_bytes()); + packet + } + fn ipv6(dns: &[u8], extensions: &[(u8, Vec)]) -> Vec { let mut payload = Vec::new(); for (index, (_, extension)) in extensions.iter().enumerate() { @@ -1208,6 +1399,162 @@ mod protocol_regression_tests { } } + #[test] + fn opt_in_first_ipv4_response_fragment_requires_complete_question() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&1_u16.to_be_bytes()); + dns[10..12].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + dns.extend_from_slice(&[b'x'; 16]); + dns.extend_from_slice(&[0; 16]); + let complete = ipv4(&dns, true, 53000); + let first = first_ipv4_fragment(complete, 40); + + assert!(DnsProcessor::packet_routing_meta(&first).is_none()); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true) + .expect("first response fragment routes when enabled"); + assert!(meta.partial_first_ipv4_fragment); + assert_eq!(meta.dns_data(&first).unwrap().len(), 32); + + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records.len(), 1); + assert_eq!(records[0].id, 0xbeef); + assert_eq!(records[0].name.as_str(), "example.com"); + assert!(!records[0].is_query); + assert!(records[0].partial_first_ipv4_fragment); + assert!(records[0].partial_response_code.is_none()); + } + + let incomplete_question = first_ipv4_fragment(ipv4(&dns, true, 53000), 24); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&incomplete_question, true) + .expect("header-only first fragment routes for decode validation"); + for processor in processors() { + assert!(matches!( + processor.process_packet_batch_with_meta(&incomplete_question, 100, meta), + PacketProcessingOutcome::Invalid + )); + } + } + + #[test] + fn opt_in_does_not_accept_other_fragments_or_truncated_unfragmented_responses() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + dns.extend_from_slice(&[b'x'; 16]); + + let mut query_dns = dns.clone(); + query_dns[2..4].copy_from_slice(&0x0100_u16.to_be_bytes()); + let query_first = first_ipv4_fragment(ipv4(&query_dns, false, 53000), 40); + assert!(DnsProcessor::packet_routing_meta_with_fragments(&query_first, true).is_none()); + + let mut noninitial = first_ipv4_fragment(ipv4(&dns, true, 53000), 40); + noninitial[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&(IPV4_MORE_FRAGMENTS | 1).to_be_bytes()); + assert!(DnsProcessor::packet_routing_meta_with_fragments(&noninitial, true).is_none()); + + for flags in [ + IPV4_MORE_FRAGMENTS | IPV4_DONT_FRAGMENT, + IPV4_MORE_FRAGMENTS | IPV4_RESERVED_FLAG, + ] { + let mut invalid_flags = first_ipv4_fragment(ipv4(&dns, true, 53000), 40); + invalid_flags[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&flags.to_be_bytes()); + assert!( + DnsProcessor::packet_routing_meta_with_fragments(&invalid_flags, true).is_none() + ); + } + + let mut truncated_full = first_ipv4_fragment(ipv4(&dns, true, 53000), 40); + truncated_full[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&0_u16.to_be_bytes()); + assert!(DnsProcessor::packet_routing_meta_with_fragments(&truncated_full, true).is_none()); + + let mut missing_answer = question(0x8180, 1); + missing_answer[6..8].copy_from_slice(&1_u16.to_be_bytes()); + let full_packet = ipv4(&missing_answer, true, 53000); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&full_packet, true) + .expect("complete response routes for strict validation"); + assert!(!meta.partial_first_ipv4_fragment); + for processor in processors() { + assert!(matches!( + processor.process_packet_batch_with_meta(&full_packet, 100, meta), + PacketProcessingOutcome::Invalid + )); + } + } + + #[test] + fn partial_response_code_is_reported_only_when_complete_in_first_fragment() { + let mut no_additionals = question(0x8183, 1); + no_additionals[6..8].copy_from_slice(&1_u16.to_be_bytes()); + no_additionals.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + no_additionals.extend_from_slice(&[b'x'; 16]); + let first = first_ipv4_fragment(ipv4(&no_additionals, true, 53000), 40); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records[0].partial_response_code.unwrap().as_u16(), 3); + } + + let mut with_opt = question(0x8180, 1); + with_opt[10..12].copy_from_slice(&1_u16.to_be_bytes()); + with_opt.extend_from_slice(&[0, 0, 41, 4, 208, 1, 0, 0, 0, 0, 0]); + with_opt.extend_from_slice(&[0; 16]); + let first = first_ipv4_fragment(ipv4(&with_opt, true, 53000), 48); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records[0].partial_response_code.unwrap().as_u16(), 16); + } + + let mut with_tsig = tsig_response(16, 0, &[]); + let fragment_payload_len = (UDP_HEADER_LEN + with_tsig.len()).next_multiple_of(8); + with_tsig.extend_from_slice(&[0; 16]); + let first = first_ipv4_fragment(ipv4(&with_tsig, true, 53000), fragment_payload_len); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records[0].partial_response_code.unwrap().as_u16(), 16); + assert_eq!( + records[0].partial_response_code.unwrap().as_str(), + "TSIG Failure" + ); + } + } + + #[test] + fn partial_response_rejects_visible_opt_in_answer_even_if_later_record_is_incomplete() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&2_u16.to_be_bytes()); + // A complete OPT in the answer section is already invalid, although the + // following answer extends beyond the first IPv4 fragment. + dns.extend_from_slice(&[0, 0, 41, 4, 208, 0, 0, 0, 0, 0, 0]); + dns.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + dns.extend_from_slice(&[b'x'; 16]); + let first = first_ipv4_fragment(ipv4(&dns, true, 53000), 56); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + assert!(matches!( + processor.process_packet_batch_with_meta(&first, 100, meta), + PacketProcessingOutcome::Invalid + )); + } + } + #[test] fn both_decoders_preserve_query_class_and_every_opcode() { for opcode in 0..16 { diff --git a/src/dns_processor/pipeline.rs b/src/dns_processor/pipeline.rs index d43cb88..4ebcd0a 100644 --- a/src/dns_processor/pipeline.rs +++ b/src/dns_processor/pipeline.rs @@ -19,6 +19,7 @@ use std::thread; use super::DnsProcessor; use super::parser::{CanonicalFlowKey, PacketProcessingOutcome, ParsedUdpDnsMeta}; +use super::reassembly::Ipv4FragmentReassembler; use super::types::{MatcherShardState, ProcessedDnsRecord, ShardProcessingResult}; use crate::config::{ExecutionBudget, MATCHER_SHARD_FACTOR, PACKET_BATCH_SIZE}; use crate::output::{OutputMessage, OutputRecordBatches}; @@ -45,6 +46,7 @@ fn staged_aggregator_affinity_slot(worker_count: usize) -> usize { struct PipelineCounters { oversized_qname_message_count: usize, + fragmented_response_prefix_count: usize, dns_query_count: usize, duplicated_query_count: usize, dns_response_count: usize, @@ -59,6 +61,7 @@ impl Default for PipelineCounters { fn default() -> Self { Self { oversized_qname_message_count: 0, + fragmented_response_prefix_count: 0, dns_query_count: 0, duplicated_query_count: 0, dns_response_count: 0, @@ -76,6 +79,7 @@ pub(crate) struct ProcessingCounters { pub(crate) total_packets_processed: usize, /// DNS messages rejected because a decompressed QNAME exceeds the RFC 1035 255-octet limit. pub(crate) oversized_qname_message_count: usize, + pub(crate) fragmented_response_prefix_count: usize, pub(crate) dns_query_count: usize, pub(crate) duplicated_query_count: usize, pub(crate) dns_response_count: usize, @@ -92,6 +96,7 @@ impl PipelineCounters { output_closed: &AtomicBool, ) -> bool { self.oversized_qname_message_count += shard_result.oversized_qname_message_count; + self.fragmented_response_prefix_count += shard_result.fragmented_response_prefix_count; self.dns_query_count += shard_result.dns_query_count; self.duplicated_query_count += shard_result.duplicated_query_count; self.dns_response_count += shard_result.dns_response_count; @@ -118,6 +123,7 @@ impl PipelineCounters { ProcessingCounters { total_packets_processed, oversized_qname_message_count: self.oversized_qname_message_count, + fragmented_response_prefix_count: self.fragmented_response_prefix_count, dns_query_count: self.dns_query_count, duplicated_query_count: self.duplicated_query_count, dns_response_count: self.dns_response_count, @@ -395,6 +401,7 @@ impl ShardRoutingPlan { fn merge_shard_results(merged: &mut ShardProcessingResult, shard_result: ShardProcessingResult) { merged.oversized_qname_message_count += shard_result.oversized_qname_message_count; + merged.fragmented_response_prefix_count += shard_result.fragmented_response_prefix_count; merged.dns_query_count += shard_result.dns_query_count; merged.duplicated_query_count += shard_result.duplicated_query_count; merged.dns_response_count += shard_result.dns_response_count; @@ -519,6 +526,7 @@ fn shard_map_index(flow_key: CanonicalFlowKey, shard_count: usize) -> usize { fn route_batch_to_worker_batches( mut packet_batch: PacketBatch, routing_plan: &ShardRoutingPlan, + allow_fragments: bool, ) -> RoutedWorkerBatches { let shard_count = routing_plan.shard_count(); debug_assert!(shard_count > 0); @@ -541,8 +549,10 @@ fn route_batch_to_worker_batches( .collect(); for packet_data in packet_batch { - let Some(udp_dns_meta) = DnsProcessor::packet_routing_meta(packet_data.data.as_slice()) - else { + let Some(udp_dns_meta) = DnsProcessor::packet_routing_meta_with_fragments( + packet_data.data.as_slice(), + allow_fragments, + ) else { continue; }; @@ -558,6 +568,23 @@ fn route_batch_to_worker_batches( } } +fn route_batch_with_reassembly( + packet_batch: PacketBatch, + routing_plan: &ShardRoutingPlan, + reassembler: &mut Ipv4FragmentReassembler, +) -> RoutedWorkerBatches { + let input_max_timestamp_micros = packet_batch + .iter() + .map(|packet| packet.timestamp_micros) + .max(); + let (packets, oldest_pending_timestamp_micros) = reassembler.process_batch(packet_batch); + let mut routed = route_batch_to_worker_batches(packets, routing_plan, true); + routed.batch_max_timestamp_micros = input_max_timestamp_micros.map(|timestamp| { + oldest_pending_timestamp_micros.map_or(timestamp, |pending| timestamp.min(pending)) + }); + routed +} + fn parse_shard_packets( dns_processor: &DnsProcessor, shard_packets: RoutedDnsPackets, @@ -605,17 +632,49 @@ fn run_phase_processing_worker( vec![MatcherShardState::default()] }; let routing_plan = ShardRoutingPlan::new(shard_count, shard_count); + let mut reassembler = dns_processor.full_fragments.then(|| { + Ipv4FragmentReassembler::new( + dns_processor.match_timeout_micros, + dns_processor.monotonic_capture, + ) + }); let mut counters = PipelineCounters::default(); - while let Ok(packet_batch) = batch_rx.recv() { + loop { + let received = batch_rx.recv(); + let final_flush = received.is_err(); if output_closed.load(AtomicOrdering::Relaxed) { break; } + let routed = match received { + Ok(packet_batch) => { + if let Some(reassembler) = reassembler.as_mut() { + route_batch_with_reassembly(packet_batch, &routing_plan, reassembler) + } else { + route_batch_to_worker_batches( + packet_batch, + &routing_plan, + dns_processor.allow_fragments, + ) + } + } + Err(_) => { + let Some(reassembler) = reassembler.as_mut() else { + break; + }; + let remaining = reassembler.finish(); + if remaining.is_empty() { + break; + } + route_batch_to_worker_batches(remaining, &routing_plan, true) + } + }; + let RoutedWorkerBatches { batch_max_timestamp_micros, worker_batches: shard_batches, - } = route_batch_to_worker_batches(packet_batch, &routing_plan); + } = routed; let mut shard_results: Vec<(usize, ShardProcessingResult)> = shard_batches .into_par_iter() @@ -641,6 +700,9 @@ fn run_phase_processing_worker( return Ok(counters); } } + if final_flush { + break; + } } if !stop_requested(shutdown_requested.as_ref(), output_closed.as_ref()) @@ -669,18 +731,46 @@ fn run_parser_stage( worker_txs: Vec>, routing_plan: ShardRoutingPlan, output_closed: Arc, + allow_fragments: bool, + full_fragments: bool, + match_timeout_micros: i64, + monotonic_capture: bool, ) -> anyhow::Result<()> { let mut batch_seq = 0_u64; + let mut reassembler = full_fragments + .then(|| Ipv4FragmentReassembler::new(match_timeout_micros, monotonic_capture)); - while let Ok(packet_batch) = batch_rx.recv() { + loop { + let received = batch_rx.recv(); + let final_flush = received.is_err(); if output_closed.load(AtomicOrdering::Relaxed) { break; } + let routed = match received { + Ok(packet_batch) => { + if let Some(reassembler) = reassembler.as_mut() { + route_batch_with_reassembly(packet_batch, &routing_plan, reassembler) + } else { + route_batch_to_worker_batches(packet_batch, &routing_plan, allow_fragments) + } + } + Err(_) => { + let Some(reassembler) = reassembler.as_mut() else { + break; + }; + let remaining = reassembler.finish(); + if remaining.is_empty() { + break; + } + route_batch_to_worker_batches(remaining, &routing_plan, true) + } + }; + let RoutedWorkerBatches { batch_max_timestamp_micros, worker_batches, - } = route_batch_to_worker_batches(packet_batch, &routing_plan); + } = routed; for (worker_idx, shard_packets) in worker_batches.into_iter().enumerate() { worker_txs[worker_idx] @@ -706,6 +796,9 @@ fn run_parser_stage( } batch_seq = batch_seq.wrapping_add(1); + if final_flush { + break; + } } Ok(()) @@ -915,13 +1008,26 @@ fn run_staged_processing_pipeline( .name("DPP_Parser".to_string()) .spawn({ let output_closed = Arc::clone(&output_closed); + let allow_fragments = dns_processor.allow_fragments; + let full_fragments = dns_processor.full_fragments; + let match_timeout_micros = dns_processor.match_timeout_micros; + let monotonic_capture = dns_processor.monotonic_capture; let affinity_plan = affinity_plan.clone(); move || { affinity_plan.apply_to_current_thread( staged_parser_affinity_slot(worker_count), "staged parser", ); - run_parser_stage(batch_rx, worker_txs, routing_plan, output_closed) + run_parser_stage( + batch_rx, + worker_txs, + routing_plan, + output_closed, + allow_fragments, + full_fragments, + match_timeout_micros, + monotonic_capture, + ) } })?; @@ -1608,6 +1714,140 @@ mod tests { fs::remove_file(path).expect("test pcap removed"); } + #[test] + fn full_ipv4_reassembly_crosses_packet_batch_boundary_in_both_pipeline_models() { + let path = temp_test_path("pipeline-cross-batch-ipv4-fragments", "pcap"); + let mut query_payload = encode_dns_header(0x3456, 0x0100, 1); + query_payload.extend_from_slice(&[ + 7, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0, + ]); + query_payload.extend_from_slice(&1_u16.to_be_bytes()); + query_payload.extend_from_slice(&1_u16.to_be_bytes()); + let mut response_payload = query_payload.clone(); + response_payload[2..4].copy_from_slice(&0x8180_u16.to_be_bytes()); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + // The OPT record changes the full RCODE to EDNS_BADVERS and is split + // between the two IP fragments. + response_payload.extend_from_slice(&[0, 0, 41, 4, 208, 1, 0, 0, 0, 0, 0]); + + let query = make_udp_dns_packet_with_payload( + [10, 0, 0, 1], + [8, 8, 8, 8], + 53_000, + 53, + &query_payload, + ); + let response = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let padding = make_udp_dns_packet([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 123); + let first_end = 14 + 20 + 40; + let mut first_fragment = response[..first_end].to_vec(); + first_fragment[16..18].copy_from_slice(&60_u16.to_be_bytes()); + first_fragment[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + first_fragment[20..22].copy_from_slice(&0x2000_u16.to_be_bytes()); + let mut final_fragment = response[..34].to_vec(); + final_fragment[16..18] + .copy_from_slice(&(20_u16 + (response.len() - first_end) as u16).to_be_bytes()); + final_fragment[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + final_fragment[20..22].copy_from_slice(&5_u16.to_be_bytes()); + final_fragment.extend_from_slice(&response[first_end..]); + + let mut packets = Vec::with_capacity(PACKET_BATCH_SIZE + 1); + packets.push((1, 0, query.as_slice())); + packets.push((1, 100_000, first_fragment.as_slice())); + packets.extend(std::iter::repeat_n( + (1, 150_000, padding.as_slice()), + PACKET_BATCH_SIZE - packets.len(), + )); + packets.push((1, 200_000, final_fragment.as_slice())); + fs::write(&path, classic_pcap_bytes(&packets)).expect("test pcap written"); + + // A pending first fragment holds the monotonic matcher watermark at + // its timestamp even when later unrelated packets fill the batch. + let mut cap_parser = + PacketParser::new(&InputSource::File(path.clone()), false).expect("parser opens"); + let first_batch = cap_parser + .next_batch(PACKET_BATCH_SIZE) + .expect("batch reads") + .expect("first batch exists"); + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let routed = route_batch_with_reassembly( + first_batch, + &ShardRoutingPlan::new(1, 1), + &mut reassembler, + ); + assert_eq!(routed.batch_max_timestamp_micros, Some(1_100_000)); + + for available_cpus in [1, 5] { + let mut parser = + PacketParser::new(&InputSource::File(path.clone()), false).expect("parser opens"); + let packet_count = Arc::new(AtomicUsize::new(0)); + let (output_tx, output_rx) = crossbeam::channel::unbounded(); + let counters = DnsProcessor::dns_processing_loop( + Arc::new( + DnsProcessor::new_with_runtime_options(None, true, 1_200_000, true) + .expect("processor initializes") + .with_full_fragments(true), + ), + &mut parser, + &packet_count, + &output_tx, + ExecutionBudget::from_available_cpus(available_cpus), + AffinityPlan::disabled(), + true, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ) + .expect("pipeline completes"); + + assert_eq!( + counters.total_packets_processed, + PACKET_BATCH_SIZE + 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.dns_query_count, 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.dns_response_count, 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.matched_query_response_count, 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.timeout_query_count, 0, + "available_cpus={available_cpus}" + ); + assert_eq!(parser.non_monotonic_timestamp_count(), 0); + + drop(output_tx); + let records = output_rx + .into_iter() + .flat_map(|message| match message { + OutputMessage::Records(records) => records, + other => panic!("unexpected output message: {other:?}"), + }) + .collect::>(); + assert_eq!(records.len(), 1, "available_cpus={available_cpus}"); + assert_eq!(records[0].request_timestamp, 1_000_000); + assert_eq!(records[0].response_timestamp, Some(1_200_000)); + assert_eq!( + records[0].response_code.map(|code| code.to_string()), + Some("EDNS_BADVERS".to_string()) + ); + } + + fs::remove_file(path).expect("test pcap removed"); + } + #[test] fn routed_worker_batches_use_global_batch_max_timestamp() { let path = temp_test_path("pipeline-routed-batch-watermark", "pcap"); @@ -1630,7 +1870,7 @@ mod tests { let RoutedWorkerBatches { batch_max_timestamp_micros, worker_batches, - } = route_batch_to_worker_batches(batch, &ShardRoutingPlan::new(4, 1)); + } = route_batch_to_worker_batches(batch, &ShardRoutingPlan::new(4, 1), false); let ordered_packets = worker_batches[0] .iter() @@ -1764,6 +2004,7 @@ mod tests { } = route_batch_to_worker_batches( oversized_qname_batch("matcher-worker-oversized-qname"), &ShardRoutingPlan::new(1, 1), + false, ); let shard_packets = worker_batches.pop().expect("worker batch exists"); let (batch_tx, batch_rx) = crossbeam::channel::bounded(1); @@ -1813,6 +2054,7 @@ mod tests { } = route_batch_to_worker_batches( unresolved_query_batch("matcher-worker-signal-shutdown"), &ShardRoutingPlan::new(1, 1), + false, ); let shard_packets = worker_batches.pop().expect("worker batch exists"); let (batch_tx, batch_rx) = crossbeam::channel::bounded(1); diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs new file mode 100644 index 0000000..a7767d8 --- /dev/null +++ b/src/dns_processor/reassembly.rs @@ -0,0 +1,710 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +//! Bounded IPv4/UDP fragment assembly before DNS flow routing. +//! +//! Later fragments have no UDP ports, so this state has one owner in the routing stage. +//! A completed datagram replaces its first fragment with one ordinary IPv4 packet. An +//! incomplete first fragment is released only when it expires, is evicted, or input ends; +//! the existing `--allow-fragments` parser then decides whether its DNS prefix is usable. + +use std::collections::{BTreeMap, HashMap}; + +use crate::packet_parser::{PacketBatch, PacketData, PacketPayload}; + +const ETHERNET_HEADER_LEN: usize = 14; +const IPV4_MIN_HEADER_LEN: usize = 20; +const UDP_HEADER_LEN: usize = 8; +const ETHERTYPE_IPV4: u16 = 0x0800; +const IP_PROTOCOL_UDP: u8 = 17; +const DNS_PORT: u16 = 53; +const MORE_FRAGMENTS: u16 = 0x2000; +const DONT_FRAGMENT: u16 = 0x4000; +const RESERVED_FLAG: u16 = 0x8000; +const FRAGMENT_OFFSET_MASK: u16 = 0x1fff; + +/// Bounds are independent of capture timestamps, which may regress in normal mode. +const MAX_PENDING_DATAGRAMS: usize = 8_192; +const MAX_PENDING_BYTES: usize = 64 * 1024 * 1024; +const ENTRY_OVERHEAD_ESTIMATE: usize = 256; +const SEGMENT_OVERHEAD_ESTIMATE: usize = 64; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct FragmentKey { + source: [u8; 4], + destination: [u8; 4], + identification: u16, +} + +struct FragmentPart { + key: FragmentKey, + offset: usize, + payload: Box<[u8]>, + more_fragments: bool, + first_udp_length: Option, + first_header_len: Option, +} + +struct FragmentSegment { + payload: Box<[u8]>, + more_fragments: bool, +} + +enum FragmentInput { + Ordinary, + Skip, + Part(FragmentPart), +} + +/// Byte intervals must not overlap, except for an exact duplicate fragment. +struct PendingDatagram { + arrival_order: u64, + first_seen_timestamp: i64, + first: Option, + first_header_len: Option, + udp_length: Option, + final_payload_length: Option, + final_fragment_event: Option<(i64, u64)>, + segments: BTreeMap, + rejected: bool, +} + +impl PendingDatagram { + fn new(arrival_order: u64, timestamp: i64) -> Self { + Self { + arrival_order, + first_seen_timestamp: timestamp, + first: None, + first_header_len: None, + udp_length: None, + final_payload_length: None, + final_fragment_event: None, + segments: BTreeMap::new(), + rejected: false, + } + } + + fn memory_bytes(&self) -> usize { + ENTRY_OVERHEAD_ESTIMATE + + self.first.as_ref().map_or(0, |first| first.data.len()) + + self + .segments + .values() + .map(|segment| segment.payload.len() + SEGMENT_OVERHEAD_ESTIMATE) + .sum::() + } + + fn reject(&mut self) { + self.first = None; + self.first_header_len = None; + self.udp_length = None; + self.final_payload_length = None; + self.final_fragment_event = None; + self.segments.clear(); + self.rejected = true; + } + + fn insert(&mut self, part: FragmentPart, packet: PacketData) -> Result { + if self.rejected { + return Err(()); + } + let event = (packet.timestamp_micros, packet.packet_ordinal); + let end = part.offset.checked_add(part.payload.len()).ok_or(())?; + if self + .final_payload_length + .is_some_and(|final_end| end > final_end) + { + return Err(()); + } + if part.more_fragments + && (self.udp_length.is_some_and(|udp_length| end >= udp_length) + || self + .final_payload_length + .is_some_and(|final_end| end >= final_end)) + { + return Err(()); + } + if let Some(udp_length) = self.udp_length + && end > udp_length + { + return Err(()); + } + if part.offset == 0 { + if self.first.is_some() { + // A repeated first fragment may be a capture duplicate. A different + // first fragment with the same IPv4 ID is ambiguous, so reject it. + let existing = self.segments.get(&0).ok_or(())?; + return if existing.payload.as_ref() == part.payload.as_ref() + && existing.more_fragments == part.more_fragments + { + Ok(self.is_complete()) + } else { + Err(()) + }; + } + let udp_length = part.first_udp_length.ok_or(())?; + let header_len = part.first_header_len.ok_or(())?; + if end > udp_length + || self + .final_payload_length + .is_some_and(|last| last != udp_length) + { + return Err(()); + } + self.udp_length = Some(udp_length); + self.first_header_len = Some(header_len); + self.first = Some(packet); + } + + if let Some((&start, existing)) = self.segments.range(..=part.offset).next_back() { + if start == part.offset { + if existing.payload.as_ref() != part.payload.as_ref() + || existing.more_fragments != part.more_fragments + { + return Err(()); + } + if !part.more_fragments { + self.set_final_length(end, event)?; + } + return Ok(self.is_complete()); + } + if start + existing.payload.len() > part.offset { + return Err(()); + } + } + if let Some((&next_start, _)) = self.segments.range(part.offset..).next() + && end > next_start + { + return Err(()); + } + if let Some(udp_length) = self.udp_length + && end > udp_length + { + return Err(()); + } + if !part.more_fragments { + self.set_final_length(end, event)?; + } + self.segments.insert( + part.offset, + FragmentSegment { + payload: part.payload, + more_fragments: part.more_fragments, + }, + ); + Ok(self.is_complete()) + } + + fn set_final_length(&mut self, end: usize, event: (i64, u64)) -> Result<(), ()> { + if self + .final_payload_length + .is_some_and(|previous| previous != end) + || self.udp_length.is_some_and(|udp_length| udp_length != end) + || self + .segments + .iter() + .any(|(offset, segment)| offset + segment.payload.len() > end) + { + return Err(()); + } + self.final_payload_length = Some(end); + self.final_fragment_event = Some( + self.final_fragment_event + .map_or(event, |old| old.min(event)), + ); + Ok(()) + } + + fn is_complete(&self) -> bool { + let Some(expected) = self.udp_length else { + return false; + }; + if self.first.is_none() || self.final_payload_length != Some(expected) { + return false; + } + let mut cursor = 0; + for (offset, segment) in &self.segments { + if *offset != cursor { + return false; + } + cursor += segment.payload.len(); + } + cursor == expected + } + + fn into_packet(self) -> Option { + let first = self.first?; + let header_len = self.first_header_len?; + let udp_length = self.udp_length?; + let (timestamp_micros, packet_ordinal) = self.final_fragment_event?; + let total_length = header_len.checked_add(udp_length)?; + let total_length = u16::try_from(total_length).ok()?; + let header_end = ETHERNET_HEADER_LEN.checked_add(header_len)?; + let first_header = first.data.as_slice().get(..header_end)?; + + let mut bytes = Vec::with_capacity(ETHERNET_HEADER_LEN + usize::from(total_length)); + bytes.extend_from_slice(first_header); + bytes[ETHERNET_HEADER_LEN + 2..ETHERNET_HEADER_LEN + 4] + .copy_from_slice(&total_length.to_be_bytes()); + // It is now a complete synthetic IPv4 datagram. Recompute the header checksum. + bytes[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&0_u16.to_be_bytes()); + bytes[ETHERNET_HEADER_LEN + 10..ETHERNET_HEADER_LEN + 12] + .copy_from_slice(&0_u16.to_be_bytes()); + let checksum = ipv4_header_checksum(&bytes[ETHERNET_HEADER_LEN..header_end]); + bytes[ETHERNET_HEADER_LEN + 10..ETHERNET_HEADER_LEN + 12] + .copy_from_slice(&checksum.to_be_bytes()); + for (_, segment) in self.segments { + bytes.extend_from_slice(&segment.payload); + } + Some(PacketData { + data: PacketPayload::owned(bytes.into_boxed_slice()), + timestamp_micros, + packet_ordinal, + }) + } +} + +fn ipv4_header_checksum(header: &[u8]) -> u16 { + let mut sum = 0_u32; + for word in header.chunks_exact(2) { + sum += u32::from(u16::from_be_bytes([word[0], word[1]])); + } + while sum >> 16 != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + !(sum as u16) +} + +/// One instance must survive every batch of a capture, including across worker handoffs. +pub(super) struct Ipv4FragmentReassembler { + pending: HashMap, + arrival_order: BTreeMap, + pending_bytes: usize, + next_arrival_order: u64, + max_seen_timestamp: Option, + timeout_micros: i64, + monotonic_capture: bool, +} + +impl Ipv4FragmentReassembler { + pub(super) fn new(match_timeout_micros: i64, monotonic_capture: bool) -> Self { + Self { + pending: HashMap::new(), + arrival_order: BTreeMap::new(), + pending_bytes: 0, + next_arrival_order: 0, + max_seen_timestamp: None, + timeout_micros: match_timeout_micros.max(1), + monotonic_capture, + } + } + + /// Returns decodable packets and the oldest unresolved fragment timestamp. + /// The caller must cap monotonic matcher eviction at that timestamp until resolution. + pub(super) fn process_batch(&mut self, input: PacketBatch) -> (PacketBatch, Option) { + let mut output = Vec::with_capacity(input.len()); + for packet in input { + self.max_seen_timestamp = Some( + self.max_seen_timestamp + .map_or(packet.timestamp_micros, |current| { + current.max(packet.timestamp_micros) + }), + ); + match classify_fragment(&packet) { + FragmentInput::Ordinary => output.push(packet), + FragmentInput::Skip => {} + FragmentInput::Part(part) => { + self.expire_key_at(&part.key, packet.timestamp_micros, &mut output); + self.accept_part(part, packet, &mut output); + // One input batch may contain many large fragments. Enforce the + // memory bound per fragment, not only after the whole batch. + self.enforce_limits(&mut output); + } + } + } + self.expire_old(&mut output); + self.enforce_limits(&mut output); + let oldest_pending_timestamp = self + .pending + .values() + .filter(|entry| !entry.rejected) + .map(|entry| entry.first_seen_timestamp) + .min(); + (output, oldest_pending_timestamp) + } + + /// At EOF, incomplete datagrams can only contribute an observable first prefix. + pub(super) fn finish(&mut self) -> PacketBatch { + let mut output = Vec::new(); + while let Some((&order, &key)) = self.arrival_order.first_key_value() { + debug_assert_eq!( + self.pending.get(&key).map(|entry| entry.arrival_order), + Some(order) + ); + if let Some(entry) = self.take_entry(&key) + && let Some(first) = entry.first + { + output.push(first); + } + } + output + } + + fn accept_part(&mut self, part: FragmentPart, packet: PacketData, output: &mut PacketBatch) { + let key = part.key; + let mut entry = self.take_entry(&key).unwrap_or_else(|| { + let arrival_order = self.next_arrival_order; + self.next_arrival_order = self.next_arrival_order.wrapping_add(1); + PendingDatagram::new(arrival_order, packet.timestamp_micros) + }); + match entry.insert(part, packet) { + Ok(true) => { + if let Some(packet) = entry.into_packet() { + output.push(packet); + } + } + Ok(false) => self.insert_entry(key, entry), + Err(()) => { + // Keep a bounded tombstone so later fragments with the same IPv4 ID + // cannot turn a rejected datagram into a prefix match. + entry.reject(); + self.insert_entry(key, entry); + } + } + } + + fn insert_entry(&mut self, key: FragmentKey, entry: PendingDatagram) { + self.pending_bytes += entry.memory_bytes(); + self.arrival_order.insert(entry.arrival_order, key); + self.pending.insert(key, entry); + } + + fn take_entry(&mut self, key: &FragmentKey) -> Option { + let entry = self.pending.remove(key)?; + self.pending_bytes -= entry.memory_bytes(); + self.arrival_order.remove(&entry.arrival_order); + Some(entry) + } + + fn expire_old(&mut self, output: &mut PacketBatch) { + if !self.monotonic_capture { + // A timestamp regression can otherwise expire adjacent capture fragments. + // Entry limits and EOF still bound incomplete state in this mode. + return; + } + let Some(frontier) = self.max_seen_timestamp else { + return; + }; + let stale: Vec = self + .pending + .iter() + .filter_map(|(key, entry)| { + (frontier.saturating_sub(entry.first_seen_timestamp) > self.timeout_micros) + .then_some(*key) + }) + .collect(); + // Hash iteration does not define output order; sorting is always explicit. + for key in stale { + if let Some(entry) = self.take_entry(&key) + && let Some(first) = entry.first + { + output.push(first); + } + } + } + + fn expire_key_at(&mut self, key: &FragmentKey, timestamp: i64, output: &mut PacketBatch) { + let expired = self.pending.get(key).is_some_and(|entry| { + timestamp.saturating_sub(entry.first_seen_timestamp) > self.timeout_micros + }); + if expired + && let Some(entry) = self.take_entry(key) + && let Some(first) = entry.first + { + output.push(first); + } + } + + fn enforce_limits(&mut self, output: &mut PacketBatch) { + while self.pending.len() > MAX_PENDING_DATAGRAMS || self.pending_bytes > MAX_PENDING_BYTES { + let Some((_, &key)) = self.arrival_order.first_key_value() else { + break; + }; + if let Some(entry) = self.take_entry(&key) + && let Some(first) = entry.first + { + output.push(first); + } + } + } +} + +fn classify_fragment(packet: &PacketData) -> FragmentInput { + let bytes = packet.data.as_slice(); + if bytes.len() < ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + || u16::from_be_bytes([bytes[12], bytes[13]]) != ETHERTYPE_IPV4 + { + return FragmentInput::Ordinary; + } + let ip = &bytes[ETHERNET_HEADER_LEN..]; + if ip[0] >> 4 != 4 { + return FragmentInput::Ordinary; + } + let flags = u16::from_be_bytes([ip[6], ip[7]]); + let offset = usize::from(flags & FRAGMENT_OFFSET_MASK) * 8; + let more_fragments = flags & MORE_FRAGMENTS != 0; + if !more_fragments && offset == 0 { + return FragmentInput::Ordinary; + } + if ip[9] != IP_PROTOCOL_UDP { + return FragmentInput::Skip; + } + let header_len = usize::from(ip[0] & 0x0f) * 4; + let total_length = usize::from(u16::from_be_bytes([ip[2], ip[3]])); + if header_len < IPV4_MIN_HEADER_LEN + || header_len > ip.len() + || total_length < header_len + || total_length > ip.len() + || flags & RESERVED_FLAG != 0 + || flags & DONT_FRAGMENT != 0 + { + return FragmentInput::Skip; + } + let payload = &ip[header_len..total_length]; + let Some(end) = offset.checked_add(payload.len()) else { + return FragmentInput::Skip; + }; + if payload.is_empty() + || end > usize::from(u16::MAX) - IPV4_MIN_HEADER_LEN + || (more_fragments && payload.len() % 8 != 0) + { + return FragmentInput::Skip; + } + let key = FragmentKey { + source: ip[12..16].try_into().expect("IPv4 minimum header checked"), + destination: ip[16..20].try_into().expect("IPv4 minimum header checked"), + identification: u16::from_be_bytes([ip[4], ip[5]]), + }; + let first_udp_length = if offset == 0 { + if payload.len() < UDP_HEADER_LEN { + return FragmentInput::Skip; + } + let source_port = u16::from_be_bytes([payload[0], payload[1]]); + let destination_port = u16::from_be_bytes([payload[2], payload[3]]); + if source_port != DNS_PORT && destination_port != DNS_PORT { + return FragmentInput::Skip; + } + let udp_length = usize::from(u16::from_be_bytes([payload[4], payload[5]])); + if udp_length < UDP_HEADER_LEN + || udp_length <= payload.len() + || udp_length > usize::from(u16::MAX) - header_len + { + return FragmentInput::Skip; + } + Some(udp_length) + } else { + None + }; + FragmentInput::Part(FragmentPart { + key, + offset, + payload: payload.into(), + more_fragments, + first_udp_length, + first_header_len: (offset == 0).then_some(header_len), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn packet(offset: usize, more: bool, ordinal: u64, timestamp: i64) -> PacketData { + let dns: [u8; 32] = [ + 0x12, 0x34, 0x80, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, 1, b'a', 4, b't', b'e', b's', b't', 0, + 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, + ]; + let mut udp = Vec::from([0_u8; UDP_HEADER_LEN]); + udp[0..2].copy_from_slice(&DNS_PORT.to_be_bytes()); + udp[2..4].copy_from_slice(&53000_u16.to_be_bytes()); + udp.extend_from_slice(&dns); + let udp_length = udp.len() as u16; + udp[4..6].copy_from_slice(&udp_length.to_be_bytes()); + let fragment_len = if more { 24 } else { udp.len() - offset }; + let payload = &udp[offset..offset + fragment_len]; + let mut frame = vec![0_u8; ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN]; + frame[12..14].copy_from_slice(ÐERTYPE_IPV4.to_be_bytes()); + frame[14] = 0x45; + frame[16..18] + .copy_from_slice(&((IPV4_MIN_HEADER_LEN + payload.len()) as u16).to_be_bytes()); + frame[18..20].copy_from_slice(&0x1234_u16.to_be_bytes()); + let flags = ((offset / 8) as u16) | if more { MORE_FRAGMENTS } else { 0 }; + frame[20..22].copy_from_slice(&flags.to_be_bytes()); + frame[23] = IP_PROTOCOL_UDP; + frame[26..30].copy_from_slice(&[192, 0, 2, 53]); + frame[30..34].copy_from_slice(&[192, 0, 2, 1]); + frame.extend_from_slice(payload); + PacketData { + data: PacketPayload::owned(frame.into_boxed_slice()), + timestamp_micros: timestamp, + packet_ordinal: ordinal, + } + } + + #[test] + fn assembles_across_batches_once_at_final_fragment_time() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (first, pending) = reassembler.process_batch(vec![packet(0, true, 4, 100)]); + assert!(first.is_empty()); + assert_eq!(pending, Some(100)); + let (complete, pending) = reassembler.process_batch(vec![packet(24, false, 5, 101)]); + assert_eq!(pending, None); + assert_eq!(complete.len(), 1); + let frame = &complete[0]; + assert_eq!((frame.timestamp_micros, frame.packet_ordinal), (101, 5)); + assert_eq!(&frame.data[20..22], &[0, 0]); + assert_eq!(ipv4_header_checksum(&frame.data[14..34]), 0); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn orphan_later_fragment_can_precede_first() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (orphan, pending) = reassembler.process_batch(vec![packet(24, false, 4, 100)]); + assert!(orphan.is_empty()); + assert_eq!(pending, Some(100)); + let (complete, pending) = reassembler.process_batch(vec![packet(0, true, 5, 101)]); + assert_eq!(complete.len(), 1); + assert_eq!(pending, None); + assert_eq!( + (complete[0].timestamp_micros, complete[0].packet_ordinal), + (100, 4) + ); + } + + #[test] + fn incomplete_first_is_released_once_at_finish() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, pending) = reassembler.process_batch(vec![packet(0, true, 4, 100)]); + assert!(output.is_empty()); + assert_eq!(pending, Some(100)); + let fallback = reassembler.finish(); + assert_eq!(fallback.len(), 1); + assert_eq!(fallback[0].packet_ordinal, 4); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn overlapping_segments_discard_datagram_without_prefix_fallback() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + reassembler.process_batch(vec![packet(0, true, 4, 100)]); + let (output, pending) = reassembler.process_batch(vec![packet(16, false, 5, 101)]); + assert!(output.is_empty()); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn incomplete_first_expires_to_one_prefix() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + reassembler.process_batch(vec![packet(0, true, 4, 100)]); + let ordinary = PacketData { + data: PacketPayload::owned(vec![0_u8; 14].into_boxed_slice()), + timestamp_micros: 201, + packet_ordinal: 6, + }; + let (output, pending) = reassembler.process_batch(vec![ordinary]); + assert_eq!(output.len(), 2); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn fragments_too_far_apart_in_same_batch_do_not_assemble() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let (output, pending) = + reassembler.process_batch(vec![packet(0, true, 4, 100), packet(24, false, 5, 201)]); + assert_eq!(output.len(), 1); + assert_eq!( + (output[0].timestamp_micros, output[0].packet_ordinal), + (100, 4) + ); + assert_eq!(pending, Some(201)); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn timestamp_regression_does_not_expire_adjacent_fragments() { + let mut reassembler = Ipv4FragmentReassembler::new(100, false); + let unrelated = PacketData { + data: PacketPayload::owned(vec![0_u8; 14].into_boxed_slice()), + timestamp_micros: 10_000, + packet_ordinal: 1, + }; + let (output, pending) = reassembler.process_batch(vec![ + unrelated, + packet(0, true, 2, 100), + packet(24, false, 3, 101), + ]); + assert_eq!(output.len(), 2); + assert_eq!(output[1].timestamp_micros, 101); + assert_eq!(pending, None); + } + + #[test] + fn orphan_final_fragment_holds_monotonic_watermark_until_first_arrives() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let (_, pending) = reassembler.process_batch(vec![packet(24, false, 1, 500)]); + assert_eq!(pending, Some(500)); + let unrelated = PacketData { + data: PacketPayload::owned(vec![0_u8; 14].into_boxed_slice()), + timestamp_micros: 1_300, + packet_ordinal: 2, + }; + let (_, pending) = reassembler.process_batch(vec![unrelated]); + assert_eq!(pending, Some(500)); + let (assembled, pending) = reassembler.process_batch(vec![packet(0, true, 3, 1_400)]); + assert_eq!(assembled.len(), 1); + assert_eq!(assembled[0].timestamp_micros, 500); + assert_eq!(pending, None); + } + + #[test] + fn conflicting_fragment_flags_leave_a_tombstone_without_prefix_fallback() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let mut nonfinal = packet(24, false, 2, 101); + let mut bytes = nonfinal.data.as_slice().to_vec(); + bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | 3).to_be_bytes()); + nonfinal.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (output, _) = reassembler.process_batch(vec![ + nonfinal, + packet(24, false, 3, 102), + packet(0, true, 4, 103), + ]); + assert!(output.is_empty()); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn payload_beyond_final_fragment_is_rejected() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let mut extra = packet(24, false, 2, 101); + let mut bytes = extra.data.as_slice().to_vec(); + bytes.truncate(ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + 8); + bytes[16..18].copy_from_slice(&(IPV4_MIN_HEADER_LEN as u16 + 8).to_be_bytes()); + bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | 5).to_be_bytes()); + extra.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (output, _) = reassembler.process_batch(vec![ + packet(24, false, 1, 100), + extra, + packet(0, true, 3, 102), + ]); + assert!(output.is_empty()); + assert!(reassembler.finish().is_empty()); + } +} diff --git a/src/dns_processor/tests.rs b/src/dns_processor/tests.rs index 867161b..0c8cbc8 100644 --- a/src/dns_processor/tests.rs +++ b/src/dns_processor/tests.rs @@ -465,7 +465,7 @@ fn insert_response( response.packet_ordinal, response.record_ordinal, ); - processor.insert_response_entry(state, identity, key, response.response_code); + processor.insert_response_entry(state, identity, key, Some(response.response_code)); } fn make_query_record(packet_ordinal: u64, record_ordinal: u32) -> ProcessedDnsRecord { @@ -492,6 +492,8 @@ fn make_query_record_with_timestamp( query_class: 1, opcode: 0, response_code: HickoryResponseCode::ServFail.into(), + partial_first_ipv4_fragment: false, + partial_response_code: None, } } @@ -515,6 +517,8 @@ fn make_response_record_with_timestamp( query_class: 1, opcode: 0, response_code: HickoryResponseCode::NoError.into(), + partial_first_ipv4_fragment: false, + partial_response_code: None, } } diff --git a/src/dns_processor/types.rs b/src/dns_processor/types.rs index 7d3c490..b62d0d1 100644 --- a/src/dns_processor/types.rs +++ b/src/dns_processor/types.rs @@ -108,7 +108,8 @@ impl QueryEventPayload { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct ResponseEventPayload { - pub(super) response_code: ProtoResponseCode, + /// None means a response was inferred from its first IPv4 fragment. + pub(super) response_code: Option, } pub(super) enum Timeline { @@ -401,6 +402,7 @@ pub(super) struct ShardProcessingResult { pub(super) dns_query_count: usize, pub(super) duplicated_query_count: usize, pub(super) dns_response_count: usize, + pub(super) fragmented_response_prefix_count: usize, pub(super) matched_query_response_count: usize, pub(super) timeout_query_count: usize, pub(super) matched_rtt_sum_micros: u64, @@ -423,6 +425,9 @@ pub(super) struct ProcessedDnsRecord { pub(super) query_class: u16, pub(super) opcode: u8, pub(super) response_code: ProtoResponseCode, + pub(super) partial_first_ipv4_fragment: bool, + /// Known only when all records needed to determine the response code are in the prefix. + pub(super) partial_response_code: Option, } #[cfg(test)] diff --git a/src/packet_parser.rs b/src/packet_parser.rs index c673703..41f0319 100644 --- a/src/packet_parser.rs +++ b/src/packet_parser.rs @@ -35,7 +35,7 @@ use std::os::fd::AsFd; pub struct PacketPayload(Box<[u8]>); impl PacketPayload { - fn owned(data: Box<[u8]>) -> Self { + pub(crate) fn owned(data: Box<[u8]>) -> Self { Self(data) } diff --git a/src/parquet_writer.rs b/src/parquet_writer.rs index 280846a..d73960d 100644 --- a/src/parquet_writer.rs +++ b/src/parquet_writer.rs @@ -351,6 +351,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -389,6 +391,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -427,6 +431,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, }; let writer = create_parquet_writer( SharedSink { @@ -467,6 +473,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -506,6 +514,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -570,6 +580,8 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let mut writer = create_parquet_writer(file, &config).expect("creates parquet writer"); diff --git a/src/record.rs b/src/record.rs index ff0b8a9..263cd05 100644 --- a/src/record.rs +++ b/src/record.rs @@ -11,8 +11,9 @@ use std::net::IpAddr; /// Canonical exported DNS record contract shared by CSV and Parquet writers. /// -/// Timeout records leave response fields absent, which encodes "no matching response was observed -/// inside the configured timeout window". +/// Timeout records leave both response fields absent. A response timestamp with no response code +/// means a query was paired with an observed first IPv4 response fragment, but the DNS response +/// code could not be determined from the available prefix. #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct DnsRecord { pub(crate) request_timestamp: i64, diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 29811cb..a4d67b8 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -65,6 +65,35 @@ fn append_opt_record(dns_payload: &mut Vec, extended_high: u8, edns_version: dns_payload.extend_from_slice(&0_u16.to_be_bytes()); } +fn fragment_ipv4_udp_packet(packet: &[u8], first_payload_len: usize) -> (Vec, Vec) { + const IPV4_START: usize = 14; + const IPV4_HEADER_LEN: usize = 20; + assert_eq!( + first_payload_len % 8, + 0, + "nonfinal fragment must be 8-byte aligned" + ); + let first_end = IPV4_START + IPV4_HEADER_LEN + first_payload_len; + assert!(first_end < packet.len(), "fixture needs a later fragment"); + + let mut first_fragment = packet[..first_end].to_vec(); + first_fragment[IPV4_START + 2..IPV4_START + 4] + .copy_from_slice(&((IPV4_HEADER_LEN + first_payload_len) as u16).to_be_bytes()); + first_fragment[IPV4_START + 4..IPV4_START + 6].copy_from_slice(&0x4321_u16.to_be_bytes()); + first_fragment[IPV4_START + 6..IPV4_START + 8].copy_from_slice(&0x2000_u16.to_be_bytes()); + + let remaining_payload = &packet[first_end..]; + let mut later_fragment = packet[..IPV4_START + IPV4_HEADER_LEN].to_vec(); + later_fragment[IPV4_START + 2..IPV4_START + 4] + .copy_from_slice(&((IPV4_HEADER_LEN + remaining_payload.len()) as u16).to_be_bytes()); + later_fragment[IPV4_START + 4..IPV4_START + 6].copy_from_slice(&0x4321_u16.to_be_bytes()); + later_fragment[IPV4_START + 6..IPV4_START + 8] + .copy_from_slice(&((first_payload_len / 8) as u16).to_be_bytes()); + later_fragment.extend_from_slice(remaining_payload); + + (first_fragment, later_fragment) +} + #[cfg(unix)] fn wait_for_child_exit(child: &mut Child) { let deadline = Instant::now() + Duration::from_secs(5); @@ -269,6 +298,202 @@ fn matched_query_response_pair_round_trips_to_exact_csv_record() { fs::remove_file(&output_path).expect("remove output csv"); } +#[test] +fn ipv4_first_fragment_response_requires_opt_in_and_later_fragment_is_ignored() { + let input_path = temp_test_path("first-fragment-response", "pcap"); + let default_output_path = temp_test_path("first-fragment-default", "csv"); + let fragment_output_path = temp_test_path("first-fragment-enabled", "csv"); + + let mut query_payload = encode_dns_header(0x1234, 0x0100, 1); + append_example_a_query(&mut query_payload); + + let mut response_payload = encode_dns_header(0x1234, 0x8180, 1); + response_payload[6..8].copy_from_slice(&1_u16.to_be_bytes()); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + // The answer starts in the first fragment and ends in the second one. + response_payload.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 192, 0, 2, 1]); + // An OPT record beyond the first fragment can change the full RCODE. + append_opt_record(&mut response_payload, 0, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let (first_fragment, later_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + + fs::write( + &input_path, + classic_pcap_bytes(&[ + (1, 0, &query_packet), + (1, 200_000, &first_fragment), + (1, 200_001, &later_fragment), + ]), + ) + .expect("fragmented test pcap written"); + + let default_result = Command::new(dpp_binary()) + .arg("-s") + .arg(&input_path) + .arg(&default_output_path) + .output() + .expect("dpp executed without fragment mode"); + assert!( + default_result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&default_result.stderr) + ); + assert_eq!( + fs::read_to_string(&default_output_path).expect("default CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,,10.0.0.1,53000,4660,example.com,A,\n" + ) + ); + + let fragment_result = Command::new(dpp_binary()) + .arg("-s") + .arg("--allow-fragments") + .arg(&input_path) + .arg(&fragment_output_path) + .output() + .expect("dpp executed with fragment mode"); + assert!( + fragment_result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&fragment_result.stderr) + ); + assert_eq!( + fs::read_to_string(&fragment_output_path).expect("fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200000,10.0.0.1,53000,4660,example.com,A,\n" + ) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(default_output_path).expect("remove default output csv"); + fs::remove_file(fragment_output_path).expect("remove fragment output csv"); +} + +#[test] +fn full_ipv4_fragment_reassembly_recovers_extended_response_code_once() { + let input_path = temp_test_path("full-fragments-edns", "pcap"); + let output_path = temp_test_path("full-fragments-edns", "csv"); + + let mut query_payload = encode_dns_header(0x2345, 0x0100, 1); + query_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut query_payload); + append_opt_record(&mut query_payload, 0, 0); + + let mut response_payload = encode_dns_header(0x2345, 0x8180, 1); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + append_opt_record(&mut response_payload, 1, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + // The first fragment contains the DNS header and question, but only the + // beginning of the OPT record that carries the extended response code. + let (first_fragment, later_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + fs::write( + &input_path, + classic_pcap_bytes(&[ + (1, 0, &query_packet), + (1, 200_000, &first_fragment), + (1, 200_001, &later_fragment), + ]), + ) + .expect("fragmented EDNS test pcap written"); + + let result = Command::new(dpp_binary()) + .arg("-s") + .arg("--full-fragments") + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed with full fragment reassembly"); + assert!( + result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + fs::read_to_string(&output_path).expect("full fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200001,10.0.0.1,53000,9029,example.com,A,EDNS_BADVERS\n" + ) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(output_path).expect("remove output csv"); +} + +#[test] +fn full_ipv4_fragment_mode_falls_back_to_first_fragment_at_eof() { + let input_path = temp_test_path("full-fragments-incomplete", "pcap"); + let output_path = temp_test_path("full-fragments-incomplete", "csv"); + + let mut query_payload = encode_dns_header(0x2345, 0x0100, 1); + append_example_a_query(&mut query_payload); + let mut response_payload = encode_dns_header(0x2345, 0x8180, 1); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + append_opt_record(&mut response_payload, 1, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let (first_fragment, _missing_later_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + fs::write( + &input_path, + classic_pcap_bytes(&[(1, 0, &query_packet), (1, 200_000, &first_fragment)]), + ) + .expect("incomplete fragment pcap written"); + + let result = Command::new(dpp_binary()) + .arg("-s") + .arg("--full-fragments") + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed with full fragment mode"); + assert!( + result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + fs::read_to_string(&output_path).expect("incomplete fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200000,10.0.0.1,53000,9029,example.com,A,\n" + ) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(output_path).expect("remove output csv"); +} + #[test] fn maximum_wire_qname_round_trips_with_full_escaped_presentation() { let input_path = temp_test_path("maximum-wire-qname", "pcap"); From c31ab516c655d97f8f64d5ca191a80253df20af9 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 22:44:48 +0300 Subject: [PATCH 04/28] Suppress duplicate IPv4 fragment responses after reassembly --- README.md | 2 +- docs/architecture.md | 9 +- src/dns_processor/reassembly.rs | 323 +++++++++++++++++++++++++++++++- tests/end_to_end.rs | 66 +++++++ 4 files changed, 389 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 40c41c9..d61b281 100644 --- a/README.md +++ b/README.md @@ -507,7 +507,7 @@ Additional notes: - Historical `--threads` and `DPP_THREADS` inputs are accepted only as deprecated compatibility no-ops and emit a warning if used. - The parser fast path is opt-in through `--dns-wire-fast-path` or `DPP_DNS_WIRE_FAST_PATH=1`; without that flag DPP uses the legacy `hickory` question decoder. - `--allow-fragments` or `DPP_ALLOW_FRAGMENTS=1` optionally pairs a query with the observable first IPv4 fragment of a response when its DNS header and complete question are present. `response_code` is populated when there are no additional records or every declared DNS record fits in the first fragment; otherwise it is empty. The JSON metric `fragmented_response_prefix_count` (text: `Accepted first IPv4 response fragments`) counts accepted prefixes, including ones without a matching query. -- `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. +- `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. A bounded history of completed datagrams compares the fragment key and UDP payload bytes to suppress repeated complete datagrams and prefix fallback when all observed fragments match a recent completion. A different datagram can still use the same IPv4 ID, including one with an identical first fragment but different later bytes. A fully identical new datagram, or an incomplete one whose observed fragments match a recent completion, cannot be distinguished from a duplicate and may be suppressed. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. - For captures normalized with `reordercap`, `--monotonic-capture` can reduce in-flight matcher state by enabling batched timeout eviction. If a timestamp regression is detected, DPP fails the run instead of silently weakening matching semantics. - Global allocator choice is a build-time concern. See [docs/allocator-guide.md](docs/allocator-guide.md) for the supported allocator matrix and [benches/allocator-benchmarking.md](benches/allocator-benchmarking.md) for the comparison protocol. diff --git a/docs/architecture.md b/docs/architecture.md index 62c2074..c99c2f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -177,8 +177,13 @@ or second encoding of canonical addresses and ports. eviction or end of input; with monotonic capture, entries can also expire after the match timeout. Fragmented queries require a complete datagram. A complete datagram uses the final fragment's (`MF=0`) capture timestamp, while inferred responses use the first - fragment's timestamp. Reassembly state must - remain bounded in memory, and IPv6 fragments remain unsupported. A fragmented UDP datagram must + fragment's timestamp. The reassembler retains a bounded history of completed datagrams and + compares the fragment key and UDP payload bytes before suppressing repeated complete datagrams + or prefix fallback from fragments matching a recent completion. A reused IPv4 ID with different + content can still produce a new datagram, even when its first fragment is identical. An incomplete + new datagram whose observed fragments match a recent completion is indistinguishable from a + duplicate and may be suppressed; the same holds for a fully identical new datagram. Reassembly + state must remain bounded in memory, and IPv6 fragments remain unsupported. A fragmented UDP datagram must have an IP payload length equal to its UDP Length; mismatched fragment sets are rejected. The optional runtime flag `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index a7767d8..6935749 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -13,6 +13,7 @@ //! the existing `--allow-fragments` parser then decides whether its DNS prefix is usable. use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; use crate::packet_parser::{PacketBatch, PacketData, PacketPayload}; @@ -32,6 +33,10 @@ const MAX_PENDING_DATAGRAMS: usize = 8_192; const MAX_PENDING_BYTES: usize = 64 * 1024 * 1024; const ENTRY_OVERHEAD_ESTIMATE: usize = 256; const SEGMENT_OVERHEAD_ESTIMATE: usize = 64; +const MAX_RECENT_COMPLETIONS: usize = 4_096; +const MAX_RECENT_BYTES: usize = 16 * 1024 * 1024; +const RECENT_ENTRY_OVERHEAD_ESTIMATE: usize = 128; +const MIN_RECENT_RETENTION_MICROS: i64 = 5_000_000; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] struct FragmentKey { @@ -54,6 +59,18 @@ struct FragmentSegment { more_fragments: bool, } +struct CompletedDatagram { + payload: Arc<[u8]>, + observed_timestamp: i64, + arrival_order: u64, +} + +impl CompletedDatagram { + fn memory_bytes(&self) -> usize { + RECENT_ENTRY_OVERHEAD_ESTIMATE + self.payload.len() + } +} + enum FragmentInput { Ordinary, Skip, @@ -70,11 +87,13 @@ struct PendingDatagram { final_payload_length: Option, final_fragment_event: Option<(i64, u64)>, segments: BTreeMap, + /// Present while every observed fragment matches a recently completed datagram. + recent_payload: Option>, rejected: bool, } impl PendingDatagram { - fn new(arrival_order: u64, timestamp: i64) -> Self { + fn new(arrival_order: u64, timestamp: i64, recent_payload: Option>) -> Self { Self { arrival_order, first_seen_timestamp: timestamp, @@ -84,6 +103,7 @@ impl PendingDatagram { final_payload_length: None, final_fragment_event: None, segments: BTreeMap::new(), + recent_payload, rejected: false, } } @@ -96,6 +116,10 @@ impl PendingDatagram { .values() .map(|segment| segment.payload.len() + SEGMENT_OVERHEAD_ESTIMATE) .sum::() + + self + .recent_payload + .as_ref() + .map_or(0, |payload| payload.len()) } fn reject(&mut self) { @@ -105,13 +129,31 @@ impl PendingDatagram { self.final_payload_length = None; self.final_fragment_event = None; self.segments.clear(); + self.recent_payload = None; self.rejected = true; } + fn into_fallback(self) -> Option { + // A prefix whose observed bytes still match a completed datagram is a + // possible capture duplicate, not a second inferred DNS response. + if self.recent_payload.is_some() { + None + } else { + self.first + } + } + fn insert(&mut self, part: FragmentPart, packet: PacketData) -> Result { if self.rejected { return Err(()); } + if self + .recent_payload + .as_ref() + .is_some_and(|payload| !fragment_matches_payload(&part, payload)) + { + self.recent_payload = None; + } let event = (packet.timestamp_micros, packet.packet_ordinal); let end = part.offset.checked_add(part.payload.len()).ok_or(())?; if self @@ -269,6 +311,15 @@ impl PendingDatagram { } } +fn fragment_matches_payload(part: &FragmentPart, payload: &[u8]) -> bool { + let Some(end) = part.offset.checked_add(part.payload.len()) else { + return false; + }; + end <= payload.len() + && part.more_fragments == (end < payload.len()) + && part.payload.as_ref() == &payload[part.offset..end] +} + fn ipv4_header_checksum(header: &[u8]) -> u16 { let mut sum = 0_u32; for word in header.chunks_exact(2) { @@ -286,6 +337,10 @@ pub(super) struct Ipv4FragmentReassembler { arrival_order: BTreeMap, pending_bytes: usize, next_arrival_order: u64, + recently_completed: HashMap, + completed_order: BTreeMap, + completed_bytes: usize, + next_completed_order: u64, max_seen_timestamp: Option, timeout_micros: i64, monotonic_capture: bool, @@ -298,6 +353,10 @@ impl Ipv4FragmentReassembler { arrival_order: BTreeMap::new(), pending_bytes: 0, next_arrival_order: 0, + recently_completed: HashMap::new(), + completed_order: BTreeMap::new(), + completed_bytes: 0, + next_completed_order: 0, max_seen_timestamp: None, timeout_micros: match_timeout_micros.max(1), monotonic_capture, @@ -319,6 +378,7 @@ impl Ipv4FragmentReassembler { FragmentInput::Ordinary => output.push(packet), FragmentInput::Skip => {} FragmentInput::Part(part) => { + self.expire_completed_key_at(&part.key, packet.timestamp_micros); self.expire_key_at(&part.key, packet.timestamp_micros, &mut output); self.accept_part(part, packet, &mut output); // One input batch may contain many large fragments. Enforce the @@ -328,6 +388,7 @@ impl Ipv4FragmentReassembler { } } self.expire_old(&mut output); + self.expire_recent_completions(); self.enforce_limits(&mut output); let oldest_pending_timestamp = self .pending @@ -347,7 +408,7 @@ impl Ipv4FragmentReassembler { Some(order) ); if let Some(entry) = self.take_entry(&key) - && let Some(first) = entry.first + && let Some(first) = entry.into_fallback() { output.push(first); } @@ -357,15 +418,49 @@ impl Ipv4FragmentReassembler { fn accept_part(&mut self, part: FragmentPart, packet: PacketData, output: &mut PacketBatch) { let key = part.key; - let mut entry = self.take_entry(&key).unwrap_or_else(|| { + let observed_timestamp = packet.timestamp_micros; + let mut existing = self.take_entry(&key); + if existing.as_ref().is_some_and(|entry| { + entry.recent_payload.is_some() + && entry.first.is_some() + && part.offset == 0 + && !fragment_matches_payload( + &part, + entry.recent_payload.as_ref().expect("checked above"), + ) + }) { + // A different first fragment starts a new generation of this IPv4 ID. + // The old candidate matched a completed datagram and needs no fallback. + existing = None; + } + let recent_payload = self + .recently_completed + .get(&key) + .map(|entry| Arc::clone(&entry.payload)); + let mut entry = existing.unwrap_or_else(|| { let arrival_order = self.next_arrival_order; self.next_arrival_order = self.next_arrival_order.wrapping_add(1); - PendingDatagram::new(arrival_order, packet.timestamp_micros) + PendingDatagram::new(arrival_order, observed_timestamp, recent_payload) }); match entry.insert(part, packet) { Ok(true) => { + let previous_payload = entry.recent_payload.clone(); + let header_len = entry + .first_header_len + .expect("complete datagram has first header"); if let Some(packet) = entry.into_packet() { - output.push(packet); + let payload = &packet.data[ETHERNET_HEADER_LEN + header_len..]; + if !previous_payload + .as_ref() + .is_some_and(|previous| previous.as_ref() == payload) + { + self.remember_completion( + key, + Arc::<[u8]>::from(payload), + observed_timestamp, + ); + output.push(packet); + } } } Ok(false) => self.insert_entry(key, entry), @@ -391,6 +486,79 @@ impl Ipv4FragmentReassembler { Some(entry) } + fn remember_completion( + &mut self, + key: FragmentKey, + payload: Arc<[u8]>, + observed_timestamp: i64, + ) { + self.take_completion(&key); + let arrival_order = self.next_completed_order; + self.next_completed_order = self.next_completed_order.wrapping_add(1); + let completed = CompletedDatagram { + payload, + observed_timestamp, + arrival_order, + }; + self.completed_bytes += completed.memory_bytes(); + self.completed_order.insert(arrival_order, key); + self.recently_completed.insert(key, completed); + while self.recently_completed.len() > MAX_RECENT_COMPLETIONS + || self.completed_bytes > MAX_RECENT_BYTES + { + let Some((_, &oldest_key)) = self.completed_order.first_key_value() else { + break; + }; + self.take_completion(&oldest_key); + } + } + + fn take_completion(&mut self, key: &FragmentKey) -> Option { + let completed = self.recently_completed.remove(key)?; + self.completed_bytes -= completed.memory_bytes(); + self.completed_order.remove(&completed.arrival_order); + Some(completed) + } + + fn recent_retention_micros(&self) -> i64 { + self.timeout_micros.max(MIN_RECENT_RETENTION_MICROS) + } + + fn expire_completed_key_at(&mut self, key: &FragmentKey, timestamp: i64) { + if !self.monotonic_capture { + // Capture timestamps may regress arbitrarily in normal mode. The + // entry and byte caps bound this history instead. + return; + } + let expired = self.recently_completed.get(key).is_some_and(|entry| { + timestamp.saturating_sub(entry.observed_timestamp) > self.recent_retention_micros() + }); + if expired { + self.take_completion(key); + } + } + + fn expire_recent_completions(&mut self) { + if !self.monotonic_capture { + return; + } + let Some(frontier) = self.max_seen_timestamp else { + return; + }; + while let Some((_, &key)) = self.completed_order.first_key_value() { + let completed = self + .recently_completed + .get(&key) + .expect("completion order references a cached datagram"); + if frontier.saturating_sub(completed.observed_timestamp) + <= self.recent_retention_micros() + { + break; + } + self.take_completion(&key); + } + } + fn expire_old(&mut self, output: &mut PacketBatch) { if !self.monotonic_capture { // A timestamp regression can otherwise expire adjacent capture fragments. @@ -411,7 +579,7 @@ impl Ipv4FragmentReassembler { // Hash iteration does not define output order; sorting is always explicit. for key in stale { if let Some(entry) = self.take_entry(&key) - && let Some(first) = entry.first + && let Some(first) = entry.into_fallback() { output.push(first); } @@ -424,7 +592,7 @@ impl Ipv4FragmentReassembler { }); if expired && let Some(entry) = self.take_entry(key) - && let Some(first) = entry.first + && let Some(first) = entry.into_fallback() { output.push(first); } @@ -436,7 +604,7 @@ impl Ipv4FragmentReassembler { break; }; if let Some(entry) = self.take_entry(&key) - && let Some(first) = entry.first + && let Some(first) = entry.into_fallback() { output.push(first); } @@ -572,6 +740,145 @@ mod tests { assert!(reassembler.finish().is_empty()); } + #[test] + fn completed_datagram_does_not_fallback_duplicate_first_fragment() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (complete, pending) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + assert_eq!(pending, None); + + let (duplicate, pending) = reassembler.process_batch(vec![packet(0, true, 3, 102)]); + assert!(duplicate.is_empty()); + assert_eq!(pending, Some(102)); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn completed_datagram_is_not_emitted_again_from_duplicate_fragments() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, _) = reassembler.process_batch(vec![ + packet(0, true, 1, 100), + packet(24, false, 2, 101), + packet(24, false, 3, 102), + packet(0, true, 4, 103), + packet(0, true, 5, 104), + packet(24, false, 6, 105), + ]); + assert_eq!(output.len(), 1); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn reused_ipv4_id_with_different_payload_still_assembles() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (original, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(original.len(), 1); + + let mut changed_last = packet(24, false, 4, 103); + let mut bytes = changed_last.data.as_slice().to_vec(); + *bytes.last_mut().expect("fragment has payload") ^= 1; + changed_last.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (different_last, _) = + reassembler.process_batch(vec![packet(0, true, 3, 102), changed_last]); + assert_eq!(different_last.len(), 1); + assert_ne!( + different_last[0].data.as_slice(), + original[0].data.as_slice() + ); + + let mut changed_first = packet(0, true, 5, 104); + let mut bytes = changed_first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + UDP_HEADER_LEN] ^= 1; + changed_first.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (different_first, _) = + reassembler.process_batch(vec![changed_first, packet(24, false, 6, 105)]); + assert_eq!(different_first.len(), 1); + assert_ne!( + different_first[0].data.as_slice(), + different_last[0].data.as_slice() + ); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn reused_ipv4_id_with_new_first_assembles_after_identical_orphan_last() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (original, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(original.len(), 1); + + let mut changed_first = packet(0, true, 4, 103); + let mut bytes = changed_first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + UDP_HEADER_LEN] ^= 1; + changed_first.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (new_datagram, pending) = + reassembler.process_batch(vec![packet(24, false, 3, 102), changed_first]); + assert_eq!(new_datagram.len(), 1); + assert_eq!(new_datagram[0].timestamp_micros, 102); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn timestamp_regression_does_not_forget_recent_completion() { + let mut reassembler = Ipv4FragmentReassembler::new(100, false); + let (complete, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + let (duplicate, _) = reassembler.process_batch(vec![packet(0, true, 3, -10_000_000)]); + assert!(duplicate.is_empty()); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn duplicate_first_fragment_does_not_fallback_on_timeout() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let (complete, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + reassembler.process_batch(vec![packet(0, true, 3, 102)]); + + let ordinary = PacketData { + data: PacketPayload::owned(vec![0_u8; ETHERNET_HEADER_LEN].into_boxed_slice()), + timestamp_micros: 203, + packet_ordinal: 4, + }; + let (output, pending) = reassembler.process_batch(vec![ordinary]); + assert_eq!(output.len(), 1); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn recent_completion_window_expires_and_is_bounded() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let (first, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(first.len(), 1); + + let (later, _) = reassembler.process_batch(vec![ + packet(0, true, 3, 5_000_102), + packet(24, false, 4, 5_000_103), + ]); + assert_eq!(later.len(), 1); + + for id in 0..=MAX_RECENT_COMPLETIONS { + reassembler.remember_completion( + FragmentKey { + source: [192, 0, 2, 1], + destination: [192, 0, 2, 53], + identification: id as u16, + }, + Arc::<[u8]>::from(&b"payload"[..]), + 5_000_103, + ); + } + assert!(reassembler.recently_completed.len() <= MAX_RECENT_COMPLETIONS); + assert!(reassembler.completed_bytes <= MAX_RECENT_BYTES); + } + #[test] fn orphan_later_fragment_can_precede_first() { let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index a4d67b8..641d6c4 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -442,6 +442,72 @@ fn full_ipv4_fragment_reassembly_recovers_extended_response_code_once() { fs::remove_file(output_path).expect("remove output csv"); } +#[test] +fn full_ipv4_reassembly_ignores_first_fragment_duplicate_after_completion() { + let input_path = temp_test_path("full-fragments-duplicate-first", "pcap"); + let output_path = temp_test_path("full-fragments-duplicate-first", "csv"); + + let mut query_payload = encode_dns_header(0x2345, 0x0100, 1); + append_example_a_query(&mut query_payload); + let mut response_payload = encode_dns_header(0x2345, 0x8180, 1); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + append_opt_record(&mut response_payload, 1, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let (first_fragment, final_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + fs::write( + &input_path, + classic_pcap_bytes(&[ + (1, 0, &query_packet), + (1, 200_000, &first_fragment), + (1, 200_001, &final_fragment), + // The first transaction is already matched. A second query makes + // an inferred response from the duplicate visible in the CSV. + (1, 300_000, &query_packet), + (1, 400_000, &first_fragment), + ]), + ) + .expect("fragmented duplicate test pcap written"); + + let result = Command::new(dpp_binary()) + .args(["-s", "--full-fragments", "--report-format", "json"]) + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed with full fragment reassembly"); + assert!( + result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + fs::read_to_string(&output_path).expect("duplicate fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200001,10.0.0.1,53000,9029,example.com,A,EDNS_BADVERS\n", + "1300000,,10.0.0.1,53000,9029,example.com,A,\n" + ) + ); + let report: serde_json::Value = + serde_json::from_slice(&result.stdout).expect("JSON report parses"); + assert_eq!(report["metrics"]["total_dns_responses_processed"], 1); + assert_eq!(report["metrics"]["fragmented_response_prefix_count"], 0); + assert_eq!(report["metrics"]["total_matched_query_response_pairs"], 1); + assert_eq!(report["metrics"]["timed_out_queries"], 1); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(output_path).expect("remove output csv"); +} + #[test] fn full_ipv4_fragment_mode_falls_back_to_first_fragment_at_eof() { let input_path = temp_test_path("full-fragments-incomplete", "pcap"); From e6bf9f4ae45c673946ec60ee688ff6664216f1b1 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 22:54:19 +0300 Subject: [PATCH 05/28] Make fragment expiry order deterministic --- src/dns_processor/reassembly.rs | 42 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index 6935749..3d2ed6f 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -568,16 +568,17 @@ impl Ipv4FragmentReassembler { let Some(frontier) = self.max_seen_timestamp else { return; }; - let stale: Vec = self + let mut stale: Vec<(u64, FragmentKey)> = self .pending .iter() .filter_map(|(key, entry)| { (frontier.saturating_sub(entry.first_seen_timestamp) > self.timeout_micros) - .then_some(*key) + .then_some((entry.arrival_order, *key)) }) .collect(); - // Hash iteration does not define output order; sorting is always explicit. - for key in stale { + stale.sort_unstable_by_key(|(arrival_order, _)| *arrival_order); + // Match the arrival order used by EOF and capacity eviction. + for (_, key) in stale { if let Some(entry) = self.take_entry(&key) && let Some(first) = entry.into_fallback() { @@ -931,6 +932,39 @@ mod tests { assert!(reassembler.finish().is_empty()); } + #[test] + fn expired_first_fragments_follow_arrival_order() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let first_fragments = (1_u16..=8) + .map(|id| { + let mut first = packet(0, true, u64::from(id), 100 + i64::from(id)); + let mut bytes = first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + 4..ETHERNET_HEADER_LEN + 6] + .copy_from_slice(&id.to_be_bytes()); + first.data = PacketPayload::owned(bytes.into_boxed_slice()); + first + }) + .collect(); + let (output, pending) = reassembler.process_batch(first_fragments); + assert!(output.is_empty()); + assert_eq!(pending, Some(101)); + + let ordinary = PacketData { + data: PacketPayload::owned(vec![0_u8; ETHERNET_HEADER_LEN].into_boxed_slice()), + timestamp_micros: 300, + packet_ordinal: 9, + }; + let (output, pending) = reassembler.process_batch(vec![ordinary]); + assert_eq!(pending, None); + assert_eq!( + output + .iter() + .map(|packet| packet.packet_ordinal) + .collect::>(), + vec![9, 1, 2, 3, 4, 5, 6, 7, 8] + ); + } + #[test] fn fragments_too_far_apart_in_same_batch_do_not_assemble() { let mut reassembler = Ipv4FragmentReassembler::new(100, true); From c82a18134ca7467d160eb962dc57fa4f6c6b4ddf Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 22:54:37 +0300 Subject: [PATCH 06/28] Use is_multiple_of for fragment alignment --- src/dns_processor/reassembly.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index 3d2ed6f..f602c21 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -650,7 +650,7 @@ fn classify_fragment(packet: &PacketData) -> FragmentInput { }; if payload.is_empty() || end > usize::from(u16::MAX) - IPV4_MIN_HEADER_LEN - || (more_fragments && payload.len() % 8 != 0) + || (more_fragments && !payload.len().is_multiple_of(8)) { return FragmentInput::Skip; } From 39e7381afafa0c8af5808c70aa3c4934507561d1 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 22:59:52 +0300 Subject: [PATCH 07/28] Wake memory monitor promptly on stop --- docs/architecture.md | 5 +-- src/monitor_memory.rs | 76 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c99c2f3..c346b4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,8 +142,9 @@ or second encoding of canonical addresses and ports. as a downstream-close outcome; regular file-write failures remain fatal. - `src/monitor_memory.rs` - Optional RSS tracking helper with an explicit stop/join lifecycle. The monitor must not detach - indefinitely from top-level process shutdown. + Optional RSS tracking helper with an explicit stop/join lifecycle. The monitor's 100 ms sampling + wait is interruptible by stop, so join need not wait for the next sample. An in-progress RSS + refresh must still finish before the monitor thread exits. - `src/dns_processor.rs` and `src/dns_processor/*` The DNS processor facade owns packet-to-matcher orchestration and delegates to focused diff --git a/src/monitor_memory.rs b/src/monitor_memory.rs index 3b44262..a02a905 100644 --- a/src/monitor_memory.rs +++ b/src/monitor_memory.rs @@ -6,10 +6,10 @@ */ use std::error::Error; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering}; - +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; use std::thread::JoinHandle; +use std::time::Duration; use sysinfo::{ProcessesToUpdate, System}; use tracing::info; @@ -39,13 +39,48 @@ use tracing::info; /// memory_thread.join().expect("Memory monitoring thread panicked"); /// ``` pub struct MemoryMonitorHandle { - stop: Arc, + stop: Arc, join_handle: Option>, } +struct StopSignal { + stopped: Mutex, + wake: Condvar, +} + +impl StopSignal { + fn new() -> Self { + Self { + stopped: Mutex::new(false), + wake: Condvar::new(), + } + } + + fn lock_stopped(&self) -> MutexGuard<'_, bool> { + self.stopped.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn stop(&self) { + *self.lock_stopped() = true; + self.wake.notify_one(); + } + + fn is_stopped(&self) -> bool { + *self.lock_stopped() + } + + fn wait_for_next_sample(&self, interval: Duration) -> bool { + let (stopped, _) = self + .wake + .wait_timeout_while(self.lock_stopped(), interval, |stopped| !*stopped) + .unwrap_or_else(PoisonError::into_inner); + *stopped + } +} + impl MemoryMonitorHandle { pub fn stop(&self) { - self.stop.store(true, AtomicOrdering::SeqCst); + self.stop.stop(); } pub fn join(mut self) -> Result<(), Box> { @@ -64,7 +99,7 @@ impl MemoryMonitorHandle { impl Drop for MemoryMonitorHandle { fn drop(&mut self) { - self.stop.store(true, AtomicOrdering::SeqCst); + self.stop(); if let Some(join_handle) = self.join_handle.take() && let Err(err) = join_handle.join() @@ -81,7 +116,7 @@ pub fn start_tracking() // Clone the Arc to move into the monitoring thread. let memory_usage_clone = Arc::clone(&max_memory_usage); - let stop = Arc::new(AtomicBool::new(false)); + let stop = Arc::new(StopSignal::new()); let stop_clone = Arc::clone(&stop); // Spawn a new thread dedicated to monitoring memory usage. @@ -106,7 +141,7 @@ pub fn start_tracking() // Create a list containing only the current PID to limit monitoring scope. let pid_list = vec![pid]; - while !stop_clone.load(AtomicOrdering::SeqCst) { + while !stop_clone.is_stopped() { // Specify that only the processes in `pid_list` should be refreshed. let processes_to_update = ProcessesToUpdate::Some(&pid_list); @@ -122,8 +157,10 @@ pub fn start_tracking() memory_usage_clone.fetch_max(current_rss_kib, AtomicOrdering::SeqCst); } - // Sleep for 100 milliseconds before the next check to reduce CPU usage. - std::thread::sleep(std::time::Duration::from_millis(100)); + // Wait between samples, but wake immediately when shutdown is requested. + if stop_clone.wait_for_next_sample(Duration::from_millis(100)) { + break; + } } })?; @@ -140,6 +177,8 @@ pub fn start_tracking() #[cfg(test)] mod tests { use super::*; + use std::sync::mpsc; + use std::time::Instant; #[test] fn memory_monitor_can_stop_and_join() { @@ -147,4 +186,21 @@ mod tests { monitor.stop(); monitor.join().expect("monitor joins"); } + + #[test] + fn stop_wakes_waiter_before_sampling_timeout() { + let stop = Arc::new(StopSignal::new()); + let waiter_stop = Arc::clone(&stop); + let (ready_tx, ready_rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + ready_tx.send(()).expect("waiter is ready"); + waiter_stop.wait_for_next_sample(Duration::from_secs(5)) + }); + + ready_rx.recv().expect("waiter starts"); + let started = Instant::now(); + stop.stop(); + assert!(waiter.join().expect("waiter joins")); + assert!(started.elapsed() < Duration::from_secs(2)); + } } From db3e52fff9c080b57e18e5e9f729b3499ce43faf Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:05:34 +0300 Subject: [PATCH 08/28] Make IPv4 pseudonymization collision-free --- README.md | 6 ++- docs/architecture.md | 5 +- src/dns_processor/anonymizer.rs | 95 +++++++++++++++++++++++++-------- src/dns_processor/tests.rs | 6 +-- 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d61b281..aaf729e 100644 --- a/README.md +++ b/README.md @@ -177,9 +177,13 @@ Notes: - The key file must contain a non-empty UTF-8 passphrase. Leading and trailing whitespace is ignored. - Rotating the key changes the resulting pseudonymized IP addresses for the same input capture. - Matching and deduplication use the original client IP. Pseudonymization is applied only to the - `source_ip` field of finalized output records, so pseudonym collisions cannot merge clients. + `source_ip` field of finalized output records. IPv4 pseudonymization is a keyed permutation, so + distinct IPv4 client addresses remain distinct in the output; network prefixes are not preserved. - DPP intentionally uses a fixed PBKDF2 salt for deterministic pseudonymization. The passphrase is still the operator-controlled secret; changing it rotates the derived pseudonyms. +- The IPv4 Feistel mapping replaces the previous truncated-AES mapping. Even with the same + passphrase, exports produced before and after this change cannot be joined by pseudonymized IPv4 + `source_ip`. IPv6 pseudonyms remain unchanged. - If `--anonymize` or `DPP_ANONYMIZE` is configured and the key file is missing, unreadable, or invalid, DPP exits with an error. It does not silently fall back to pass-through IP addresses. diff --git a/docs/architecture.md b/docs/architecture.md index c346b4d..012548f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -388,7 +388,10 @@ The required boundary is: - Writers remain asynchronous. Logical record order can still be deterministic even when output container layout, such as Parquet row-group boundaries, is not byte-identical across runs. - IP rewriting is deterministic pseudonymization. It reduces direct exposure of source addresses, - but identical inputs still map to identical outputs. + but identical inputs still map to identical outputs. IPv4 uses an eight-round, 32-bit Feistel + permutation with an independently derived AES key, so distinct IPv4 addresses cannot collide. + IPv6 retains its original AES block mapping. The IPv4 mapping changed with this algorithm; + exports from before and after the change cannot be joined by pseudonymized IPv4 address. - The pseudonymization key derivation uses a fixed PBKDF2 salt by design so the same passphrase yields stable output across runs and hosts. The operator-provided passphrase remains the secret rotation boundary. diff --git a/src/dns_processor/anonymizer.rs b/src/dns_processor/anonymizer.rs index 7c215c5..47d4019 100644 --- a/src/dns_processor/anonymizer.rs +++ b/src/dns_processor/anonymizer.rs @@ -21,32 +21,39 @@ static SALT: [u8; 32] = [ 199, 76, 160, 70, 220, 85, 167, 75, 67, 93, 117, 51, 223, 17, 109, 52, 125, 192, 43, 44, 172, 36, 193, 95, 137, 81, 216, 92, 201, 141, 252, 241, ]; +const IPV4_FEISTEL_ROUNDS: u8 = 8; +const IPV4_KEY_LABEL: &[u8] = b"dpp-ipv4-feistel-v1"; + +struct AnonymizationCiphers { + ipv4: Aes256, + ipv6: Aes256, +} pub(super) struct Anonymizer { - cipher: Option, + ciphers: Option, } impl Anonymizer { pub(super) fn new(anonymize_key_path: Option<&Path>) -> io::Result { - let cipher = match anonymize_key_path { + let ciphers = match anonymize_key_path { Some(path) => { let passphrase = Self::read_key_from_file(path)?; - Some(Self::derive_cipher(&passphrase)?) + Some(Self::derive_ciphers(&passphrase)?) } None => None, }; - Ok(Self { cipher }) + Ok(Self { ciphers }) } pub(super) fn anonymize_ip(&self, ip: &IpAddr) -> IpAddr { - let Some(cipher) = &self.cipher else { + let Some(ciphers) = &self.ciphers else { return *ip; }; match ip { - IpAddr::V4(ipv4) => IpAddr::V4(Self::encrypt_ipv4(cipher, ipv4)), - IpAddr::V6(ipv6) => IpAddr::V6(Self::encrypt_ipv6(cipher, ipv6)), + IpAddr::V4(ipv4) => IpAddr::V4(Self::encrypt_ipv4(&ciphers.ipv4, ipv4)), + IpAddr::V6(ipv6) => IpAddr::V6(Self::encrypt_ipv6(&ciphers.ipv6, ipv6)), } } @@ -65,9 +72,17 @@ impl Anonymizer { Ok(passphrase.into()) } - fn derive_cipher(passphrase: &str) -> Result { - let key = Self::derive_key_from_passphrase(passphrase, &SALT)?; - Ok(Aes256::new((&key).into())) + fn derive_ciphers(passphrase: &str) -> Result { + // Keep the original key for IPv6 so existing IPv6 pseudonyms remain stable. + let ipv6_key = Self::derive_key_from_passphrase(passphrase, &SALT)?; + let mut ipv4_salt = SALT.to_vec(); + ipv4_salt.extend_from_slice(IPV4_KEY_LABEL); + let ipv4_key = Self::derive_key_from_passphrase(passphrase, &ipv4_salt)?; + + Ok(AnonymizationCiphers { + ipv4: Aes256::new((&ipv4_key).into()), + ipv6: Aes256::new((&ipv6_key).into()), + }) } fn derive_key_from_passphrase(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], io::Error> { @@ -79,19 +94,28 @@ impl Anonymizer { } fn encrypt_ipv4(cipher: &Aes256, ipv4: &Ipv4Addr) -> Ipv4Addr { - let mut block = [0u8; 16]; - block[..4].copy_from_slice(&ipv4.octets()); - - let mut encrypted_block = Block::::default(); - encrypted_block.copy_from_slice(&block); - cipher.encrypt_block(&mut encrypted_block); - - Ipv4Addr::new( - encrypted_block[0], - encrypted_block[1], - encrypted_block[2], - encrypted_block[3], - ) + let octets = ipv4.octets(); + let mut left = u16::from_be_bytes([octets[0], octets[1]]); + let mut right = u16::from_be_bytes([octets[2], octets[3]]); + + // Each Feistel round is invertible regardless of collisions in its round function. + for round in 0..IPV4_FEISTEL_ROUNDS { + (left, right) = (right, left ^ Self::ipv4_round(cipher, round, right)); + } + + let mut output = [0u8; 4]; + output[..2].copy_from_slice(&left.to_be_bytes()); + output[2..].copy_from_slice(&right.to_be_bytes()); + Ipv4Addr::from(output) + } + + fn ipv4_round(cipher: &Aes256, round: u8, right: u16) -> u16 { + let mut block = Block::::default(); + block[..12].copy_from_slice(b"dpp-ipv4-v1:"); + block[12] = round; + block[14..].copy_from_slice(&right.to_be_bytes()); + cipher.encrypt_block(&mut block); + u16::from_be_bytes([block[0], block[1]]) } fn encrypt_ipv6(cipher: &Aes256, ipv6: &Ipv6Addr) -> Ipv6Addr { @@ -108,6 +132,7 @@ impl Anonymizer { #[cfg(test)] mod tests { use super::Anonymizer; + use std::collections::HashSet; use std::fs; use std::io::ErrorKind; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -145,10 +170,34 @@ mod tests { assert_eq!(first, second); assert_ne!(first, ip); + assert_eq!( + first, + IpAddr::V6(Ipv6Addr::new( + 0x4733, 0xfbc0, 0xdf1c, 0xdc0e, 0x97e5, 0x4929, 0x9f74, 0x5421 + )), + "existing IPv6 pseudonyms must remain stable" + ); + assert_eq!( + anonymizer.anonymize_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + IpAddr::V4(Ipv4Addr::new(54, 5, 4, 44)), + "IPv4 pseudonyms must remain stable across runs and hosts" + ); fs::remove_file(key_path).expect("removes temp key file"); } + #[test] + fn ipv4_pseudonyms_are_unique_for_distinct_addresses() { + let ciphers = Anonymizer::derive_ciphers("secret").expect("derives ciphers"); + let mut seen = HashSet::with_capacity(100_000); + + for address in 0..100_000u32 { + let input = Ipv4Addr::from(address.to_be_bytes()); + let output = Anonymizer::encrypt_ipv4(&ciphers.ipv4, &input); + assert!(seen.insert(output), "IPv4 collision for {input}"); + } + } + #[test] fn missing_key_file_is_an_error() { let mut path = std::env::temp_dir(); diff --git a/src/dns_processor/tests.rs b/src/dns_processor/tests.rs index 0c8cbc8..d3b9097 100644 --- a/src/dns_processor/tests.rs +++ b/src/dns_processor/tests.rs @@ -1243,16 +1243,16 @@ fn resolver_flows_in_the_same_shard_match_independently() { } #[test] -fn anonymization_collision_does_not_merge_client_identities() { +fn anonymization_preserves_distinct_client_identities() { let processor = test_processor_with_anonymization_key("secret\n"); let query_client = IpAddr::V4(Ipv4Addr::new(0, 0, 56, 87)); let other_client = IpAddr::V4(Ipv4Addr::new(0, 0, 201, 251)); let pseudonymized_client = processor.anonymize_ip(&query_client); - assert_eq!( + assert_ne!( pseudonymized_client, processor.anonymize_ip(&other_client), - "fixture must retain its known IPv4 pseudonym collision" + "distinct IPv4 clients must have distinct pseudonyms" ); let mut query = make_query_record_with_timestamp(1_000, 1, 0); From 5658fd7005c20b211a0e278a513910bf66424406 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:12:20 +0300 Subject: [PATCH 09/28] Preserve partial stdin packet batch on shutdown --- docs/architecture.md | 4 +- src/dns_processor/pipeline.rs | 2 +- src/packet_parser.rs | 86 +++++++++++++++++++++++++++++++++-- tests/end_to_end.rs | 49 +++++++++++++++++--- 4 files changed, 128 insertions(+), 13 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 012548f..116bc88 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,9 @@ or second encoding of canonical addresses and ports. Buffers grow with received block bytes rather than untrusted declared lengths. Stdin probing needs no temp-file or second ingest owner. Unsupported stdin stream magic is rejected explicitly. Signal-driven shutdown can interrupt a blocked stdin read without waiting for the producer to - close the stream; capture reading remains parser-owned. + close the stream; capture reading remains parser-owned. Packets already read into a nonempty + batch are handed to the processing pipeline before intake stops. The signal shutdown policy can + still discard records buffered by the output writer. The pure-Rust classic-PCAP reader still relies on the upstream `pcap-file` `3.0.0-rc1` release candidate until a stable line with the required functionality is available. The parser can also enforce globally monotonic capture timestamps for the optional batched diff --git a/src/dns_processor/pipeline.rs b/src/dns_processor/pipeline.rs index 4ebcd0a..52fde54 100644 --- a/src/dns_processor/pipeline.rs +++ b/src/dns_processor/pipeline.rs @@ -1141,7 +1141,7 @@ impl DnsProcessor { break; }; - if stop_requested(shutdown_requested.as_ref(), output_closed.as_ref()) { + if output_closed.load(AtomicOrdering::Relaxed) { break; } diff --git a/src/packet_parser.rs b/src/packet_parser.rs index 41f0319..e84f62d 100644 --- a/src/packet_parser.rs +++ b/src/packet_parser.rs @@ -236,14 +236,14 @@ impl PacketParser { while packet_buffer.len() < chunk_size { if self.stdin_shutdown_requested() { - return Ok(None); + break; } let next_packet = match self.backend.next_packet_data() { Err(error) if self.stdin_shutdown_requested() && is_stdin_shutdown_error(&error) => { - return Ok(None); + break; } result => result?, }; @@ -762,6 +762,48 @@ mod tests { use std::fs; use std::io::Cursor; + #[derive(Clone, Copy)] + enum ShutdownTiming { + AfterPacket, + OnNextRead, + } + + struct CancellingStream { + bytes: Vec, + offset: usize, + shutdown: Arc, + timing: ShutdownTiming, + } + + impl Read for CancellingStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + if self.offset == self.bytes.len() { + self.shutdown.store(true, AtomicOrdering::SeqCst); + return Err(io::Error::other(StdinReadCancelled)); + } + + // Keep the capture header separate so opening the parser cannot prefetch the packet. + let chunk_end = if self.offset < 4 { + 4 + } else if self.offset < 24 { + 24 + } else { + self.bytes.len() + }; + let len = (chunk_end - self.offset).min(buf.len()); + buf[..len].copy_from_slice(&self.bytes[self.offset..self.offset + len]); + self.offset += len; + if self.offset == self.bytes.len() && matches!(self.timing, ShutdownTiming::AfterPacket) + { + self.shutdown.store(true, AtomicOrdering::SeqCst); + } + Ok(len) + } + } + fn packet(timestamp_micros: i64, sequence: u64) -> PacketData { PacketData { data: PacketPayload::owned(Box::from([])), @@ -771,10 +813,17 @@ mod tests { } fn parser_from_stream(bytes: Vec) -> PacketParser { + parser_from_reader(Box::new(Cursor::new(bytes)), None) + } + + fn parser_from_reader( + reader: StreamReader, + stdin_shutdown: Option>, + ) -> PacketParser { PacketParser { - backend: PacketBackend::from_stream(Box::new(Cursor::new(bytes)), "test-stream") + backend: PacketBackend::from_stream(reader, "test-stream") .expect("stream parser opens"), - stdin_shutdown: None, + stdin_shutdown, enforce_monotonic_timestamps: false, packet_ordinal: 0, last_timestamp_micros: None, @@ -875,6 +924,35 @@ mod tests { assert_eq!(packet.data.as_slice(), &[1, 2, 3, 4]); } + #[test] + fn stdin_shutdown_returns_packets_already_read_into_batch() { + for timing in [ShutdownTiming::AfterPacket, ShutdownTiming::OnNextRead] { + let shutdown = Arc::new(AtomicBool::new(false)); + let reader = CancellingStream { + bytes: classic_pcap_bytes(&[(1, 2, &[1, 2, 3, 4])]), + offset: 0, + shutdown: Arc::clone(&shutdown), + timing, + }; + let mut parser = parser_from_reader(Box::new(reader), Some(Arc::clone(&shutdown))); + + let batch = parser + .next_batch(8) + .expect("cancelled read is not a parse error") + .expect("already-read packet is returned"); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].data.as_slice(), &[1, 2, 3, 4]); + assert_eq!(batch[0].packet_ordinal, 0); + assert!(shutdown.load(AtomicOrdering::SeqCst)); + assert!( + parser + .next_batch(8) + .expect("shutdown is complete") + .is_none() + ); + } + } + #[test] fn rejects_non_ethernet_classic_pcap_file_at_open() { let path = temp_test_path("packet-parser-classic-raw", "pcap"); diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 641d6c4..df6a7cd 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -167,7 +167,12 @@ fn wait_for_stdin_start_log(child: &mut Child) { } #[cfg(unix)] -fn run_open_stdin_signal_test(name: &str, bytes: &[u8], signal: Signal, has_header: bool) { +fn run_open_stdin_signal_test( + name: &str, + bytes: &[u8], + signal: Signal, + has_header: bool, +) -> Option { let output_path = temp_test_path(name, "csv"); let mut child = Command::new(dpp_binary()) .args([ @@ -212,7 +217,7 @@ fn run_open_stdin_signal_test(name: &str, bytes: &[u8], signal: Signal, has_head String::from_utf8_lossy(&output.stderr) ); - if has_header { + let report = if has_header { let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("JSON report parses"); assert_eq!( @@ -224,17 +229,20 @@ fn run_open_stdin_signal_test(name: &str, bytes: &[u8], signal: Signal, has_head "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n" ); fs::remove_file(&output_path).expect("remove output csv"); + Some(report) } else { assert!( output.stdout.is_empty(), "no summary exists before capture setup" ); assert!(!output_path.exists(), "writer was not initialized"); - } + None + }; // Keep the parent end of the pipe open until after DPP exits. A closed pipe // would test EOF handling instead of signal cancellation. drop(stdin); + report } #[test] @@ -961,7 +969,34 @@ fn sigint_stops_classic_pcap_stdin_without_eof() { let packet = make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &dns_payload); let bytes = classic_pcap_bytes(&[(1, 0, &packet)]); - run_open_stdin_signal_test("sigint-classic-open-pipe", &bytes, Signal::SIGINT, true); + let _ = run_open_stdin_signal_test("sigint-classic-open-pipe", &bytes, Signal::SIGINT, true); +} + +#[cfg(unix)] +#[test] +fn sigint_processes_completed_packets_in_partial_stdin_batch() { + let mut query_payload = encode_dns_header(0x1234, 0x0100, 1); + append_example_a_query(&mut query_payload); + let mut response_payload = encode_dns_header(0x1234, 0x8180, 1); + append_example_a_query(&mut response_payload); + let query = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let bytes = classic_pcap_bytes(&[(1, 0, &query), (1, 200_000, &response)]); + + let report = + run_open_stdin_signal_test("sigint-partial-stdin-batch", &bytes, Signal::SIGINT, true) + .expect("signal report exists"); + assert_eq!(report["metrics"]["total_packets_processed"], 2); + assert_eq!(report["metrics"]["total_dns_queries_processed"], 1); + assert_eq!(report["metrics"]["total_dns_responses_processed"], 1); + assert_eq!(report["metrics"]["total_matched_query_response_pairs"], 1); } #[cfg(unix)] @@ -975,7 +1010,7 @@ fn sigint_stops_pcapng_stdin_mid_block_without_eof() { // A complete next block prefix declares a body that never arrives. This // exercises read_exact/read_to_end cancellation while stdin stays open. bytes.extend_from_within(..12); - run_open_stdin_signal_test("sigint-pcapng-mid-block", &bytes, Signal::SIGINT, true); + let _ = run_open_stdin_signal_test("sigint-pcapng-mid-block", &bytes, Signal::SIGINT, true); } #[cfg(unix)] @@ -991,13 +1026,13 @@ fn sigterm_stops_classic_pcap_stdin_mid_record_without_eof() { bytes.extend_from_slice(&64_u32.to_le_bytes()); bytes.extend_from_slice(&64_u32.to_le_bytes()); bytes.extend_from_slice(&[0_u8; 3]); - run_open_stdin_signal_test("sigterm-classic-mid-record", &bytes, Signal::SIGTERM, true); + let _ = run_open_stdin_signal_test("sigterm-classic-mid-record", &bytes, Signal::SIGTERM, true); } #[cfg(unix)] #[test] fn sigint_stops_stdin_probe_before_capture_header_without_eof() { - run_open_stdin_signal_test( + let _ = run_open_stdin_signal_test( "sigint-before-capture-header", &[0xd4, 0xc3, 0xb2], Signal::SIGINT, From f2f6e108b9403e8ea03ced448fdf355e1ac5db0f Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:26:53 +0300 Subject: [PATCH 10/28] Reuse Parquet column scratch buffers across row groups --- src/parquet_writer.rs | 120 ++++++++++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 29 deletions(-) diff --git a/src/parquet_writer.rs b/src/parquet_writer.rs index d73960d..1147ec9 100644 --- a/src/parquet_writer.rs +++ b/src/parquet_writer.rs @@ -183,22 +183,49 @@ where Ok(()) } +#[derive(Default)] +struct ParquetWriterScratch { + request_timestamps: Vec, + response_timestamps: Vec, + response_timestamp_definition_levels: Vec, + source_ports: Vec, + ids: Vec, + query_types: Vec, + response_codes: Vec, + response_code_definition_levels: Vec, +} + +impl ParquetWriterScratch { + fn prepare(&mut self, len: usize) { + self.request_timestamps.clear(); + self.request_timestamps.reserve(len); + self.response_timestamps.clear(); + self.response_timestamps.reserve(len); + self.response_timestamp_definition_levels.clear(); + self.response_timestamp_definition_levels.reserve(len); + self.source_ports.clear(); + self.source_ports.reserve(len); + self.ids.clear(); + self.ids.reserve(len); + self.query_types.clear(); + self.query_types.reserve(len); + self.response_codes.clear(); + self.response_codes.reserve(len); + self.response_code_definition_levels.clear(); + self.response_code_definition_levels.reserve(len); + } +} + fn flush_buffer_async_parquet( parquet_writer: &mut SerializedFileWriter, buffer: &mut Vec, + scratch: &mut ParquetWriterScratch, ) -> Result<(), Box> where W: Write + Send, { let len = buffer.len(); - let mut request_timestamps = Vec::with_capacity(len); - let mut response_timestamps = Vec::with_capacity(len); - let mut response_timestamp_definition_levels = Vec::with_capacity(len); - let mut source_ports = Vec::with_capacity(len); - let mut ids = Vec::with_capacity(len); - let mut query_types = Vec::with_capacity(len); - let mut response_codes = Vec::with_capacity(len); - let mut response_code_definition_levels = Vec::with_capacity(len); + scratch.prepare(len); let (source_ip_byte_capacity, name_byte_capacity) = buffer .iter() @@ -212,24 +239,28 @@ where let mut name_builder = PackedByteArrayBuilder::with_capacity(len, name_byte_capacity); for record in buffer.iter() { - request_timestamps.push(record.request_timestamp); + scratch.request_timestamps.push(record.request_timestamp); if let Some(response_timestamp) = record.response_timestamp { - response_timestamps.push(response_timestamp); - response_timestamp_definition_levels.push(1); + scratch.response_timestamps.push(response_timestamp); + scratch.response_timestamp_definition_levels.push(1); } else { - response_timestamp_definition_levels.push(0); + scratch.response_timestamp_definition_levels.push(0); } - source_ports.push(i32::from(record.source_port)); - ids.push(i32::from(record.id)); + scratch.source_ports.push(i32::from(record.source_port)); + scratch.ids.push(i32::from(record.id)); source_ip_builder.push_display(record.source_ip); name_builder.push_bytes(record.name.as_bytes()); - query_types.push(protocol_text_bytes(record.query_type.as_str())); + scratch + .query_types + .push(protocol_text_bytes(record.query_type.as_str())); if let Some(response_code) = &record.response_code { - response_codes.push(protocol_text_bytes(response_code.as_str())); - response_code_definition_levels.push(1); + scratch + .response_codes + .push(protocol_text_bytes(response_code.as_str())); + scratch.response_code_definition_levels.push(1); } else { - response_code_definition_levels.push(0); + scratch.response_code_definition_levels.push(0); } } @@ -241,13 +272,13 @@ where let mut row_group_writer = parquet_writer.next_row_group()?; write_column::( &mut row_group_writer, - &request_timestamps, + &scratch.request_timestamps, "Missing Parquet INT64 column", )?; write_optional_column::( &mut row_group_writer, - &response_timestamps, - &response_timestamp_definition_levels, + &scratch.response_timestamps, + &scratch.response_timestamp_definition_levels, "Missing Parquet INT64 column", )?; write_column::( @@ -257,10 +288,14 @@ where )?; write_column::( &mut row_group_writer, - &source_ports, + &scratch.source_ports, + "Missing Parquet INT32 column", + )?; + write_column::( + &mut row_group_writer, + &scratch.ids, "Missing Parquet INT32 column", )?; - write_column::(&mut row_group_writer, &ids, "Missing Parquet INT32 column")?; write_column::( &mut row_group_writer, &names, @@ -268,13 +303,13 @@ where )?; write_column::( &mut row_group_writer, - &query_types, + &scratch.query_types, "Missing Parquet BYTE_ARRAY column", )?; write_optional_column::( &mut row_group_writer, - &response_codes, - &response_code_definition_levels, + &scratch.response_codes, + &scratch.response_code_definition_levels, "Missing Parquet BYTE_ARRAY column", )?; row_group_writer.close()?; @@ -289,9 +324,10 @@ pub(crate) fn parquet_writer( rx: Receiver, ) -> Result<(), Box> { let mut buffer = Vec::with_capacity(OUTPUT_FLUSH_THRESHOLD); + let mut scratch = ParquetWriterScratch::default(); drain_output_messages(rx, &mut buffer, |buffer| { - flush_buffer_async_parquet(&mut parquet_writer, buffer) + flush_buffer_async_parquet(&mut parquet_writer, buffer, &mut scratch) })?; parquet_writer.close()?; @@ -585,6 +621,7 @@ mod tests { }; let file = File::create(&filename).expect("creates parquet file"); let mut writer = create_parquet_writer(file, &config).expect("creates parquet writer"); + let mut scratch = ParquetWriterScratch::default(); let mut empty_name = test_dns_record(); empty_name.source_ip = "192.0.2.1".parse().expect("IPv4 address parses"); @@ -598,15 +635,40 @@ mod tests { long_ipv6.name = DnsNameBuf::new(&escaped_zero).expect("maximum presentation name fits"); let mut buffer = vec![empty_name, long_ipv6]; - flush_buffer_async_parquet(&mut writer, &mut buffer).expect("first row group is written"); + flush_buffer_async_parquet(&mut writer, &mut buffer, &mut scratch) + .expect("first row group is written"); assert!(buffer.is_empty()); + let retained_capacities = [ + scratch.request_timestamps.capacity(), + scratch.response_timestamps.capacity(), + scratch.response_timestamp_definition_levels.capacity(), + scratch.source_ports.capacity(), + scratch.ids.capacity(), + scratch.query_types.capacity(), + scratch.response_codes.capacity(), + scratch.response_code_definition_levels.capacity(), + ]; let escaped_one = max_escaped_name(1); let mut second_group = test_dns_record(); second_group.source_ip = "2001:db8:ffff::ffff".parse().expect("IPv6 address parses"); second_group.name = DnsNameBuf::new(&escaped_one).expect("maximum presentation name fits"); buffer.push(second_group); - flush_buffer_async_parquet(&mut writer, &mut buffer).expect("second row group is written"); + flush_buffer_async_parquet(&mut writer, &mut buffer, &mut scratch) + .expect("second row group is written"); + assert_eq!( + [ + scratch.request_timestamps.capacity(), + scratch.response_timestamps.capacity(), + scratch.response_timestamp_definition_levels.capacity(), + scratch.source_ports.capacity(), + scratch.ids.capacity(), + scratch.query_types.capacity(), + scratch.response_codes.capacity(), + scratch.response_code_definition_levels.capacity(), + ], + retained_capacities + ); writer.close().expect("parquet writer closes"); let reader = SerializedFileReader::new(File::open(&filename).expect("opens output")) From e594cb4e7123807cd962f8d625049df4ac04285c Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:27:14 +0300 Subject: [PATCH 11/28] Handle VLAN fragments and bound PCAPNG stream blocks --- docs/encapsulation-playbook.md | 32 +--- src/dns_processor/parser.rs | 111 +++++++++++- src/dns_processor/reassembly.rs | 299 ++++++++++++++++++++++++++++---- src/packet_parser.rs | 61 ++++++- 4 files changed, 439 insertions(+), 64 deletions(-) diff --git a/docs/encapsulation-playbook.md b/docs/encapsulation-playbook.md index dde45e6..01fbf8d 100644 --- a/docs/encapsulation-playbook.md +++ b/docs/encapsulation-playbook.md @@ -2,9 +2,9 @@ ## Goal -This document explains how to convert a capture that contains VLAN, QinQ, MPLS, or similar outer -encapsulation layers into a flat Ethernet plus IPv4 or IPv6 capture that the current community -edition DPP can read directly. +This document explains how to convert a capture that contains MPLS or another unsupported outer +encapsulation layer into a flat Ethernet plus IPv4 or IPv6 capture that DPP can read directly. +Ethernet VLAN and QinQ stacks with TPIDs `0x8100`, `0x88a8`, or `0x9100` are decoded natively. Target output shape: @@ -19,14 +19,12 @@ If the input capture already has that shape, no conversion is needed. Use this guide when the original capture contains any of the following before the IP header: -- 802.1Q VLAN -- 802.1ad QinQ - MPLS label stacks -- similar shim layers between Ethernet and IP +- other unsupported shim layers between Ethernet and IP Typical symptom: -- DPP runs, but query or response counts are lower than expected because tagged or labeled packets +- DPP runs, but query or response counts are lower than expected because unsupported labeled packets are not being extracted by the current fast path. ## Linux Workflow @@ -39,8 +37,8 @@ Use `tshark` to confirm whether the capture contains VLAN, QinQ, MPLS, or other tshark -r input.pcap -q -z io,phs ``` -If you see protocol hierarchy entries such as `vlan`, `mpls`, or provider-bridging layers before -`ip` or `ipv6`, continue with normalization. +If you see `mpls` or another unsupported layer before `ip` or `ipv6`, continue with normalization. +VLAN and provider-bridging layers alone do not require this step. ### 2. Install the Required Tools @@ -188,18 +186,6 @@ Or for Parquet: target/release/dpp normalized.pcap normalized.pq --format pq ``` -## Optional Shortcut for Simple VLAN-Only Captures - -If the capture contains only simple VLAN tagging and you already use `tcprewrite`, you can strip -VLAN tags with a smaller workflow such as: - -```bash -tcprewrite --enet-vlan=del --infile=input.pcap --outfile=normalized.pcap -``` - -That shortcut is convenient, but it is not the preferred general-purpose recipe here because it is -less explicit for mixed or MPLS-tagged captures. - ## Validation Checklist After conversion, validate all of the following: @@ -220,9 +206,7 @@ After conversion, validate all of the following: ## If Native Support Is Needed If normalization is not acceptable operationally and DPP must read those captures directly, native -support should be added only inside: - -- [parser.rs](/Users/kam/RustroverProjects/dpp-public/src/dns_processor/parser.rs) +support belongs in `src/dns_processor/parser.rs` and, for fragmented IPv4, `reassembly.rs`. That change must be accompanied by: diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index 1409598..b0dab4e 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -27,6 +27,9 @@ const DNS_HEADER_LEN: usize = 12; const MIN_DNS_OFFSET: usize = ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + UDP_HEADER_LEN; const ETHER_TYPE_IPV4: u16 = 0x0800; const ETHER_TYPE_IPV6: u16 = 0x86dd; +const ETHER_TYPE_VLAN: u16 = 0x8100; +const ETHER_TYPE_PROVIDER_BRIDGING: u16 = 0x88a8; +const ETHER_TYPE_LEGACY_QINQ: u16 = 0x9100; const IP_PROTOCOL_UDP: u8 = 17; const IPV6_HOP_BY_HOP: u8 = 0; const IPV6_ROUTING: u8 = 43; @@ -47,6 +50,31 @@ const DNS_RESOURCE_RECORD_FIXED_LEN: usize = 10; const DNS_OPT_RECORD_TYPE: u16 = 41; const DNS_TSIG_RECORD_TYPE: u16 = 250; +/// Return the encapsulated EtherType and the start of its payload. Reassembly uses the +/// same VLAN traversal so fragment classification and DNS routing agree on the L3 offset. +pub(super) fn ethernet_ethertype_and_payload_offset( + data: &[u8], +) -> Result<(u16, usize), &'static str> { + let ethernet = data + .get(..ETHERNET_HEADER_LEN) + .ok_or("Failed to parse Ethernet packet")?; + let mut ethertype = u16::from_be_bytes([ethernet[12], ethernet[13]]); + let mut payload_offset = ETHERNET_HEADER_LEN; + + while matches!( + ethertype, + ETHER_TYPE_VLAN | ETHER_TYPE_PROVIDER_BRIDGING | ETHER_TYPE_LEGACY_QINQ + ) { + let tag = data + .get(payload_offset..payload_offset + 4) + .ok_or("Failed to parse Ethernet VLAN tag")?; + ethertype = u16::from_be_bytes([tag[2], tag[3]]); + payload_offset += 4; + } + + Ok((ethertype, payload_offset)) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct CanonicalFlowKey { pub(super) client_ip: IpAddr, @@ -1036,19 +1064,16 @@ impl DnsProcessor { data: &[u8], allow_first_ipv4_response_fragment: bool, ) -> Result, &'static str> { - let ethernet = data - .get(..ETHERNET_HEADER_LEN) - .ok_or("Failed to parse Ethernet packet")?; - let ethertype = u16::from_be_bytes([ethernet[12], ethernet[13]]); - let payload = &data[ETHERNET_HEADER_LEN..]; + let (ethertype, l3_offset) = ethernet_ethertype_and_payload_offset(data)?; + let payload = &data[l3_offset..]; match ethertype { ETHER_TYPE_IPV4 => Self::extract_udp_dns_from_ipv4( payload, - ETHERNET_HEADER_LEN, + l3_offset, allow_first_ipv4_response_fragment, ), - ETHER_TYPE_IPV6 => Self::extract_udp_dns_from_ipv6(payload, ETHERNET_HEADER_LEN), + ETHER_TYPE_IPV6 => Self::extract_udp_dns_from_ipv6(payload, l3_offset), _ => Ok(None), } } @@ -1329,6 +1354,17 @@ mod protocol_regression_tests { make_udp_dns_packet_with_payload(src, dst, src_port, dst_port, dns) } + fn with_vlan_tags(packet: &[u8], tags: &[(u16, u16)]) -> Vec { + let mut tagged = Vec::with_capacity(packet.len() + tags.len() * 4); + tagged.extend_from_slice(&packet[..12]); + for &(ethertype, tci) in tags { + tagged.extend_from_slice(ðertype.to_be_bytes()); + tagged.extend_from_slice(&tci.to_be_bytes()); + } + tagged.extend_from_slice(&packet[12..]); + tagged + } + fn first_ipv4_fragment(mut packet: Vec, fragment_payload_len: usize) -> Vec { assert_eq!(fragment_payload_len % 8, 0); let fragment_len = IPV4_MIN_HEADER_LEN + fragment_payload_len; @@ -1389,6 +1425,67 @@ mod protocol_regression_tests { } } + #[test] + fn vlan_and_qinq_frames_route_and_decode_dns() { + let query_dns = question(0x0100, 1); + let response_dns = question(0x8180, 1); + let tags = [ + vec![(ETHER_TYPE_VLAN, 100)], + vec![(ETHER_TYPE_PROVIDER_BRIDGING, 200), (ETHER_TYPE_VLAN, 100)], + vec![(ETHER_TYPE_LEGACY_QINQ, 200), (ETHER_TYPE_VLAN, 100)], + ]; + + for tag_stack in &tags { + let query = with_vlan_tags(&ipv4(&query_dns, false, 53000), tag_stack); + let response = with_vlan_tags(&ipv4(&response_dns, true, 53000), tag_stack); + let query_meta = DnsProcessor::packet_routing_meta(&query).expect("tagged query"); + let response_meta = + DnsProcessor::packet_routing_meta(&response).expect("tagged response"); + assert_eq!(query_meta.flow_key, response_meta.flow_key); + assert_eq!(query_meta.dns_data(&query), Ok(query_dns.as_slice())); + assert_eq!( + response_meta.dns_data(&response), + Ok(response_dns.as_slice()) + ); + + for processor in processors() { + assert_eq!(processor.process_packet_batch(&query, 0).unwrap().len(), 1); + assert_eq!( + processor + .process_packet_batch(&response, 100) + .unwrap() + .len(), + 1 + ); + } + } + + let ipv6_query = with_vlan_tags( + &ipv6(&query_dns, &[]), + &[(ETHER_TYPE_PROVIDER_BRIDGING, 200), (ETHER_TYPE_VLAN, 100)], + ); + let ipv6_meta = DnsProcessor::packet_routing_meta(&ipv6_query).expect("tagged IPv6"); + assert_eq!(ipv6_meta.dns_data(&ipv6_query), Ok(query_dns.as_slice())); + } + + #[test] + fn truncated_vlan_tag_is_rejected() { + let mut frame = vec![0; ETHERNET_HEADER_LEN + 4]; + frame[12..14].copy_from_slice(ÐER_TYPE_VLAN.to_be_bytes()); + for len in ETHERNET_HEADER_LEN..ETHERNET_HEADER_LEN + 4 { + assert_eq!( + ethernet_ethertype_and_payload_offset(&frame[..len]), + Err("Failed to parse Ethernet VLAN tag") + ); + } + + frame[16..18].copy_from_slice(ÐER_TYPE_VLAN.to_be_bytes()); + assert_eq!( + ethernet_ethertype_and_payload_offset(&frame), + Err("Failed to parse Ethernet VLAN tag") + ); + } + #[test] fn qr_does_not_create_candidates_on_wrong_server_port() { for packet in [ diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index f602c21..83e952d 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -36,13 +36,16 @@ const SEGMENT_OVERHEAD_ESTIMATE: usize = 64; const MAX_RECENT_COMPLETIONS: usize = 4_096; const MAX_RECENT_BYTES: usize = 16 * 1024 * 1024; const RECENT_ENTRY_OVERHEAD_ESTIMATE: usize = 128; +const MAX_NON_DNS_KEYS: usize = 4_096; +const MAX_NON_DNS_BYTES: usize = 1024 * 1024; const MIN_RECENT_RETENTION_MICROS: i64 = 5_000_000; -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] struct FragmentKey { source: [u8; 4], destination: [u8; 4], identification: u16, + l2_tags: Option>, } struct FragmentPart { @@ -52,6 +55,7 @@ struct FragmentPart { more_fragments: bool, first_udp_length: Option, first_header_len: Option, + first_l3_offset: Option, } struct FragmentSegment { @@ -71,9 +75,15 @@ impl CompletedDatagram { } } +struct NonDnsKey { + observed_timestamp: i64, + arrival_order: u64, +} + enum FragmentInput { Ordinary, Skip, + NonDnsFirst(FragmentKey), Part(FragmentPart), } @@ -83,6 +93,7 @@ struct PendingDatagram { first_seen_timestamp: i64, first: Option, first_header_len: Option, + first_l3_offset: Option, udp_length: Option, final_payload_length: Option, final_fragment_event: Option<(i64, u64)>, @@ -99,6 +110,7 @@ impl PendingDatagram { first_seen_timestamp: timestamp, first: None, first_header_len: None, + first_l3_offset: None, udp_length: None, final_payload_length: None, final_fragment_event: None, @@ -125,6 +137,7 @@ impl PendingDatagram { fn reject(&mut self) { self.first = None; self.first_header_len = None; + self.first_l3_offset = None; self.udp_length = None; self.final_payload_length = None; self.final_fragment_event = None; @@ -190,6 +203,7 @@ impl PendingDatagram { } let udp_length = part.first_udp_length.ok_or(())?; let header_len = part.first_header_len.ok_or(())?; + let l3_offset = part.first_l3_offset.ok_or(())?; if end > udp_length || self .final_payload_length @@ -199,6 +213,7 @@ impl PendingDatagram { } self.udp_length = Some(udp_length); self.first_header_len = Some(header_len); + self.first_l3_offset = Some(l3_offset); self.first = Some(packet); } @@ -281,25 +296,22 @@ impl PendingDatagram { fn into_packet(self) -> Option { let first = self.first?; let header_len = self.first_header_len?; + let l3_offset = self.first_l3_offset?; let udp_length = self.udp_length?; let (timestamp_micros, packet_ordinal) = self.final_fragment_event?; let total_length = header_len.checked_add(udp_length)?; let total_length = u16::try_from(total_length).ok()?; - let header_end = ETHERNET_HEADER_LEN.checked_add(header_len)?; + let header_end = l3_offset.checked_add(header_len)?; let first_header = first.data.as_slice().get(..header_end)?; - let mut bytes = Vec::with_capacity(ETHERNET_HEADER_LEN + usize::from(total_length)); + let mut bytes = Vec::with_capacity(l3_offset + usize::from(total_length)); bytes.extend_from_slice(first_header); - bytes[ETHERNET_HEADER_LEN + 2..ETHERNET_HEADER_LEN + 4] - .copy_from_slice(&total_length.to_be_bytes()); + bytes[l3_offset + 2..l3_offset + 4].copy_from_slice(&total_length.to_be_bytes()); // It is now a complete synthetic IPv4 datagram. Recompute the header checksum. - bytes[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] - .copy_from_slice(&0_u16.to_be_bytes()); - bytes[ETHERNET_HEADER_LEN + 10..ETHERNET_HEADER_LEN + 12] - .copy_from_slice(&0_u16.to_be_bytes()); - let checksum = ipv4_header_checksum(&bytes[ETHERNET_HEADER_LEN..header_end]); - bytes[ETHERNET_HEADER_LEN + 10..ETHERNET_HEADER_LEN + 12] - .copy_from_slice(&checksum.to_be_bytes()); + bytes[l3_offset + 6..l3_offset + 8].copy_from_slice(&0_u16.to_be_bytes()); + bytes[l3_offset + 10..l3_offset + 12].copy_from_slice(&0_u16.to_be_bytes()); + let checksum = ipv4_header_checksum(&bytes[l3_offset..header_end]); + bytes[l3_offset + 10..l3_offset + 12].copy_from_slice(&checksum.to_be_bytes()); for (_, segment) in self.segments { bytes.extend_from_slice(&segment.payload); } @@ -341,6 +353,10 @@ pub(super) struct Ipv4FragmentReassembler { completed_order: BTreeMap, completed_bytes: usize, next_completed_order: u64, + known_non_dns: HashMap, + non_dns_order: BTreeMap, + non_dns_bytes: usize, + next_non_dns_order: u64, max_seen_timestamp: Option, timeout_micros: i64, monotonic_capture: bool, @@ -357,6 +373,10 @@ impl Ipv4FragmentReassembler { completed_order: BTreeMap::new(), completed_bytes: 0, next_completed_order: 0, + known_non_dns: HashMap::new(), + non_dns_order: BTreeMap::new(), + non_dns_bytes: 0, + next_non_dns_order: 0, max_seen_timestamp: None, timeout_micros: match_timeout_micros.max(1), monotonic_capture, @@ -377,9 +397,25 @@ impl Ipv4FragmentReassembler { match classify_fragment(&packet) { FragmentInput::Ordinary => output.push(packet), FragmentInput::Skip => {} + FragmentInput::NonDnsFirst(key) => { + // Later fragments have no ports. Drop any tails retained before + // this first fragment established that the datagram is not DNS. + self.take_entry(&key); + self.remember_non_dns(key, packet.timestamp_micros); + } FragmentInput::Part(part) => { self.expire_completed_key_at(&part.key, packet.timestamp_micros); + self.expire_non_dns_key_at(&part.key, packet.timestamp_micros); self.expire_key_at(&part.key, packet.timestamp_micros, &mut output); + if self.known_non_dns.contains_key(&part.key) { + if part.offset != 0 { + // The known non-DNS first fragment owns this IPv4 ID. + continue; + } + // A new DNS first fragment reuses the IPv4 ID; its later + // fragments may now be assembled again. + self.take_non_dns(&part.key); + } self.accept_part(part, packet, &mut output); // One input batch may contain many large fragments. Enforce the // memory bound per fragment, not only after the whole batch. @@ -389,6 +425,7 @@ impl Ipv4FragmentReassembler { } self.expire_old(&mut output); self.expire_recent_completions(); + self.expire_non_dns_keys(); self.enforce_limits(&mut output); let oldest_pending_timestamp = self .pending @@ -402,7 +439,11 @@ impl Ipv4FragmentReassembler { /// At EOF, incomplete datagrams can only contribute an observable first prefix. pub(super) fn finish(&mut self) -> PacketBatch { let mut output = Vec::new(); - while let Some((&order, &key)) = self.arrival_order.first_key_value() { + while let Some((order, key)) = self + .arrival_order + .first_key_value() + .map(|(order, key)| (*order, key.clone())) + { debug_assert_eq!( self.pending.get(&key).map(|entry| entry.arrival_order), Some(order) @@ -417,7 +458,7 @@ impl Ipv4FragmentReassembler { } fn accept_part(&mut self, part: FragmentPart, packet: PacketData, output: &mut PacketBatch) { - let key = part.key; + let key = part.key.clone(); let observed_timestamp = packet.timestamp_micros; let mut existing = self.take_entry(&key); if existing.as_ref().is_some_and(|entry| { @@ -448,14 +489,17 @@ impl Ipv4FragmentReassembler { let header_len = entry .first_header_len .expect("complete datagram has first header"); + let l3_offset = entry + .first_l3_offset + .expect("complete datagram has first Ethernet header"); if let Some(packet) = entry.into_packet() { - let payload = &packet.data[ETHERNET_HEADER_LEN + header_len..]; + let payload = &packet.data[l3_offset + header_len..]; if !previous_payload .as_ref() .is_some_and(|previous| previous.as_ref() == payload) { self.remember_completion( - key, + key.clone(), Arc::<[u8]>::from(payload), observed_timestamp, ); @@ -474,14 +518,16 @@ impl Ipv4FragmentReassembler { } fn insert_entry(&mut self, key: FragmentKey, entry: PendingDatagram) { - self.pending_bytes += entry.memory_bytes(); - self.arrival_order.insert(entry.arrival_order, key); + self.pending_bytes += + entry.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.arrival_order.insert(entry.arrival_order, key.clone()); self.pending.insert(key, entry); } fn take_entry(&mut self, key: &FragmentKey) -> Option { let entry = self.pending.remove(key)?; - self.pending_bytes -= entry.memory_bytes(); + self.pending_bytes -= + entry.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); self.arrival_order.remove(&entry.arrival_order); Some(entry) } @@ -500,26 +546,61 @@ impl Ipv4FragmentReassembler { observed_timestamp, arrival_order, }; - self.completed_bytes += completed.memory_bytes(); - self.completed_order.insert(arrival_order, key); + self.completed_bytes += + completed.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.completed_order.insert(arrival_order, key.clone()); self.recently_completed.insert(key, completed); while self.recently_completed.len() > MAX_RECENT_COMPLETIONS || self.completed_bytes > MAX_RECENT_BYTES { - let Some((_, &oldest_key)) = self.completed_order.first_key_value() else { + let Some((_, oldest_key)) = self.completed_order.first_key_value() else { break; }; + let oldest_key = oldest_key.clone(); self.take_completion(&oldest_key); } } fn take_completion(&mut self, key: &FragmentKey) -> Option { let completed = self.recently_completed.remove(key)?; - self.completed_bytes -= completed.memory_bytes(); + self.completed_bytes -= + completed.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); self.completed_order.remove(&completed.arrival_order); Some(completed) } + fn remember_non_dns(&mut self, key: FragmentKey, observed_timestamp: i64) { + self.take_non_dns(&key); + let arrival_order = self.next_non_dns_order; + self.next_non_dns_order = self.next_non_dns_order.wrapping_add(1); + self.non_dns_bytes += + RECENT_ENTRY_OVERHEAD_ESTIMATE + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.non_dns_order.insert(arrival_order, key.clone()); + self.known_non_dns.insert( + key, + NonDnsKey { + observed_timestamp, + arrival_order, + }, + ); + while self.known_non_dns.len() > MAX_NON_DNS_KEYS || self.non_dns_bytes > MAX_NON_DNS_BYTES + { + let Some((_, oldest_key)) = self.non_dns_order.first_key_value() else { + break; + }; + let oldest_key = oldest_key.clone(); + self.take_non_dns(&oldest_key); + } + } + + fn take_non_dns(&mut self, key: &FragmentKey) -> Option { + let known = self.known_non_dns.remove(key)?; + self.non_dns_bytes -= + RECENT_ENTRY_OVERHEAD_ESTIMATE + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.non_dns_order.remove(&known.arrival_order); + Some(known) + } + fn recent_retention_micros(&self) -> i64 { self.timeout_micros.max(MIN_RECENT_RETENTION_MICROS) } @@ -538,6 +619,18 @@ impl Ipv4FragmentReassembler { } } + fn expire_non_dns_key_at(&mut self, key: &FragmentKey, timestamp: i64) { + if !self.monotonic_capture { + return; + } + let expired = self.known_non_dns.get(key).is_some_and(|entry| { + timestamp.saturating_sub(entry.observed_timestamp) > self.recent_retention_micros() + }); + if expired { + self.take_non_dns(key); + } + } + fn expire_recent_completions(&mut self) { if !self.monotonic_capture { return; @@ -545,7 +638,8 @@ impl Ipv4FragmentReassembler { let Some(frontier) = self.max_seen_timestamp else { return; }; - while let Some((_, &key)) = self.completed_order.first_key_value() { + while let Some((_, key)) = self.completed_order.first_key_value() { + let key = key.clone(); let completed = self .recently_completed .get(&key) @@ -559,6 +653,26 @@ impl Ipv4FragmentReassembler { } } + fn expire_non_dns_keys(&mut self) { + if !self.monotonic_capture { + return; + } + let Some(frontier) = self.max_seen_timestamp else { + return; + }; + while let Some((_, key)) = self.non_dns_order.first_key_value() { + let key = key.clone(); + let known = self + .known_non_dns + .get(&key) + .expect("non-DNS order references a cached key"); + if frontier.saturating_sub(known.observed_timestamp) <= self.recent_retention_micros() { + break; + } + self.take_non_dns(&key); + } + } + fn expire_old(&mut self, output: &mut PacketBatch) { if !self.monotonic_capture { // A timestamp regression can otherwise expire adjacent capture fragments. @@ -573,7 +687,7 @@ impl Ipv4FragmentReassembler { .iter() .filter_map(|(key, entry)| { (frontier.saturating_sub(entry.first_seen_timestamp) > self.timeout_micros) - .then_some((entry.arrival_order, *key)) + .then_some((entry.arrival_order, key.clone())) }) .collect(); stale.sort_unstable_by_key(|(arrival_order, _)| *arrival_order); @@ -601,9 +715,10 @@ impl Ipv4FragmentReassembler { fn enforce_limits(&mut self, output: &mut PacketBatch) { while self.pending.len() > MAX_PENDING_DATAGRAMS || self.pending_bytes > MAX_PENDING_BYTES { - let Some((_, &key)) = self.arrival_order.first_key_value() else { + let Some((_, key)) = self.arrival_order.first_key_value() else { break; }; + let key = key.clone(); if let Some(entry) = self.take_entry(&key) && let Some(first) = entry.into_fallback() { @@ -615,12 +730,14 @@ impl Ipv4FragmentReassembler { fn classify_fragment(packet: &PacketData) -> FragmentInput { let bytes = packet.data.as_slice(); - if bytes.len() < ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN - || u16::from_be_bytes([bytes[12], bytes[13]]) != ETHERTYPE_IPV4 - { + let Ok((ethertype, l3_offset)) = super::parser::ethernet_ethertype_and_payload_offset(bytes) + else { + return FragmentInput::Ordinary; + }; + if ethertype != ETHERTYPE_IPV4 || bytes.len() < l3_offset + IPV4_MIN_HEADER_LEN { return FragmentInput::Ordinary; } - let ip = &bytes[ETHERNET_HEADER_LEN..]; + let ip = &bytes[l3_offset..]; if ip[0] >> 4 != 4 { return FragmentInput::Ordinary; } @@ -658,6 +775,8 @@ fn classify_fragment(packet: &PacketData) -> FragmentInput { source: ip[12..16].try_into().expect("IPv4 minimum header checked"), destination: ip[16..20].try_into().expect("IPv4 minimum header checked"), identification: u16::from_be_bytes([ip[4], ip[5]]), + l2_tags: (l3_offset > ETHERNET_HEADER_LEN) + .then(|| Arc::<[u8]>::from(&bytes[12..l3_offset])), }; let first_udp_length = if offset == 0 { if payload.len() < UDP_HEADER_LEN { @@ -666,7 +785,7 @@ fn classify_fragment(packet: &PacketData) -> FragmentInput { let source_port = u16::from_be_bytes([payload[0], payload[1]]); let destination_port = u16::from_be_bytes([payload[2], payload[3]]); if source_port != DNS_PORT && destination_port != DNS_PORT { - return FragmentInput::Skip; + return FragmentInput::NonDnsFirst(key); } let udp_length = usize::from(u16::from_be_bytes([payload[4], payload[5]])); if udp_length < UDP_HEADER_LEN @@ -686,6 +805,7 @@ fn classify_fragment(packet: &PacketData) -> FragmentInput { more_fragments, first_udp_length, first_header_len: (offset == 0).then_some(header_len), + first_l3_offset: (offset == 0).then_some(l3_offset), }) } @@ -725,6 +845,122 @@ mod tests { } } + fn non_dns_first(ordinal: u64, timestamp: i64) -> PacketData { + let mut packet = packet(0, true, ordinal, timestamp); + let mut bytes = packet.data.as_slice().to_vec(); + let udp_offset = ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN; + bytes[udp_offset..udp_offset + 4].copy_from_slice(&[0x04, 0xd2, 0x10, 0xe1]); + packet.data = PacketPayload::owned(bytes.into_boxed_slice()); + packet + } + + fn tagged_packet(mut packet: PacketData, tags: &[(u16, u16)]) -> PacketData { + let bytes = packet.data.as_slice(); + let mut tagged = Vec::with_capacity(bytes.len() + tags.len() * 4); + tagged.extend_from_slice(&bytes[..12]); + for &(ethertype, vlan_id) in tags { + tagged.extend_from_slice(ðertype.to_be_bytes()); + tagged.extend_from_slice(&vlan_id.to_be_bytes()); + } + tagged.extend_from_slice(&bytes[12..]); + packet.data = PacketPayload::owned(tagged.into_boxed_slice()); + packet + } + + #[test] + fn non_dns_first_discards_tails_before_and_after_it() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, pending) = reassembler.process_batch(vec![packet(24, false, 1, 100)]); + assert!(output.is_empty()); + assert_eq!(pending, Some(100)); + + let (output, pending) = reassembler.process_batch(vec![non_dns_first(2, 101)]); + assert!(output.is_empty()); + assert_eq!(pending, None); + assert_eq!(reassembler.pending_bytes, 0); + + let (output, pending) = reassembler.process_batch(vec![packet(24, false, 3, 102)]); + assert!(output.is_empty()); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn dns_first_with_reused_id_assembles_after_non_dns_first() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, pending) = reassembler.process_batch(vec![ + non_dns_first(1, 100), + packet(24, false, 2, 101), + packet(0, true, 3, 102), + packet(24, false, 4, 103), + ]); + assert_eq!(output.len(), 1); + assert_eq!(output[0].timestamp_micros, 103); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn non_dns_history_is_bounded_without_eviction_of_completed_dns() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (complete, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + let completed_key = reassembler + .recently_completed + .keys() + .next() + .expect("DNS completion is remembered") + .clone(); + + for id in 0..=MAX_NON_DNS_KEYS { + let mut first = non_dns_first(id as u64 + 3, id as i64 + 102); + let mut bytes = first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + 4..ETHERNET_HEADER_LEN + 6] + .copy_from_slice(&(id as u16).to_be_bytes()); + first.data = PacketPayload::owned(bytes.into_boxed_slice()); + reassembler.process_batch(vec![first]); + } + assert!(reassembler.known_non_dns.len() <= MAX_NON_DNS_KEYS); + assert!(reassembler.non_dns_bytes <= MAX_NON_DNS_BYTES); + assert!(reassembler.recently_completed.contains_key(&completed_key)); + + let (duplicate, _) = reassembler.process_batch(vec![packet(0, true, 5_000, 5_000)]); + assert!(duplicate.is_empty()); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn tagged_fragments_assemble_without_cross_vlan_coalescence() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let vlan_one = &[(0x8100, 100)]; + let vlan_two = &[(0x88a8, 200), (0x8100, 100)]; + let (output, pending) = reassembler.process_batch(vec![ + tagged_packet(packet(0, true, 1, 100), vlan_one), + tagged_packet(packet(24, false, 2, 101), vlan_two), + ]); + assert!(output.is_empty()); + assert_eq!(pending, Some(100)); + + let (output, pending) = reassembler.process_batch(vec![ + tagged_packet(packet(0, true, 3, 102), vlan_two), + tagged_packet(packet(24, false, 4, 103), vlan_one), + ]); + assert_eq!(output.len(), 2); + assert_eq!(pending, None); + for (packet, (tags, l3_offset)) in output.iter().zip([ + (vlan_two.as_slice(), ETHERNET_HEADER_LEN + 8), + (vlan_one.as_slice(), ETHERNET_HEADER_LEN + 4), + ]) { + let bytes = packet.data.as_slice(); + assert_eq!(bytes.len(), l3_offset + IPV4_MIN_HEADER_LEN + 40); + assert_eq!(ipv4_header_checksum(&bytes[l3_offset..l3_offset + 20]), 0); + assert_eq!(&bytes[l3_offset + 6..l3_offset + 8], &[0, 0]); + let expected = tagged_packet(self::packet(0, true, 0, 0), tags); + assert_eq!(&bytes[..l3_offset], &expected.data[..l3_offset]); + } + } + #[test] fn assembles_across_batches_once_at_final_fragment_time() { let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); @@ -871,6 +1107,7 @@ mod tests { source: [192, 0, 2, 1], destination: [192, 0, 2, 53], identification: id as u16, + l2_tags: None, }, Arc::<[u8]>::from(&b"payload"[..]), 5_000_103, diff --git a/src/packet_parser.rs b/src/packet_parser.rs index e84f62d..285c78e 100644 --- a/src/packet_parser.rs +++ b/src/packet_parser.rs @@ -538,6 +538,8 @@ fn ensure_libpcap_ethernet_linktype( Ok(()) } +const MAX_PCAPNG_BLOCK_LEN: u32 = 16 * 1024 * 1024; + /// Owns stdin PCAPNG framing and its sole section-local interface table. The dependency's /// stateful reader converts timestamps incorrectly; even `next_raw_block` rejects valid /// resolutions while updating interfaces. Only its stateless block validation is used here. @@ -570,8 +572,7 @@ impl PcapNgStreamReader { } // Read only the fixed prefix before trusting the declared block length. The body - // grows with bytes actually received, so a huge length in a truncated stream cannot - // force an equally huge allocation. Memory remains bounded by the largest read block. + // grows with bytes actually received, while the bound also covers nontruncated streams. let mut prefix = [0_u8; 12]; self.input.read_exact(&mut prefix)?; if is_pcapng_magic(prefix[..4].try_into().expect("four-byte block type")) { @@ -586,6 +587,10 @@ impl PcapNgStreamReader { block_len >= 12 && block_len.is_multiple_of(4), "Invalid pcapng block length {block_len}: expected a multiple of four, at least 12" ); + anyhow::ensure!( + block_len <= MAX_PCAPNG_BLOCK_LEN, + "Pcapng block length {block_len} exceeds maximum {MAX_PCAPNG_BLOCK_LEN} bytes" + ); self.block_bytes.clear(); self.block_bytes.extend_from_slice(&prefix); self.input @@ -1051,6 +1056,58 @@ mod tests { assert_eq!(packet.data.as_slice(), &[1, 2, 3, 4]); } + #[test] + fn rejects_oversized_pcapng_section_before_reading_body() { + for (byte_order_magic, block_len) in [ + ( + [0x1a, 0x2b, 0x3c, 0x4d], + (MAX_PCAPNG_BLOCK_LEN + 4).to_be_bytes(), + ), + ( + [0x4d, 0x3c, 0x2b, 0x1a], + (MAX_PCAPNG_BLOCK_LEN + 4).to_le_bytes(), + ), + ] { + let mut prefix = Vec::from([0x0a, 0x0d, 0x0d, 0x0a]); + prefix.extend_from_slice(&block_len); + prefix.extend_from_slice(&byte_order_magic); + let stream: StreamReader = Box::new(Cursor::new(prefix)); + let error = PcapNgStreamReader::new(BufReader::new(CaptureInputReader::Stream( + ReplayReader::new(Vec::new(), stream), + ))) + .err() + .expect("oversized section must fail"); + assert!( + error.to_string().contains("exceeds maximum"), + "unexpected error: {error}" + ); + } + } + + #[test] + fn rejects_oversized_pcapng_block_after_valid_section() { + let mut bytes = pcapng_bytes(&[]); + let first_block_len = match &bytes[8..12] { + [0x1a, 0x2b, 0x3c, 0x4d] => u32::from_be_bytes(bytes[4..8].try_into().unwrap()), + [0x4d, 0x3c, 0x2b, 0x1a] => u32::from_le_bytes(bytes[4..8].try_into().unwrap()), + _ => panic!("invalid test pcapng byte order"), + } as usize; + let oversized_len = match &bytes[8..12] { + [0x1a, 0x2b, 0x3c, 0x4d] => (MAX_PCAPNG_BLOCK_LEN + 4).to_be_bytes(), + _ => (MAX_PCAPNG_BLOCK_LEN + 4).to_le_bytes(), + }; + bytes[first_block_len + 4..first_block_len + 8].copy_from_slice(&oversized_len); + + let mut parser = parser_from_stream(bytes); + let error = parser + .next_batch(1) + .expect_err("oversized interface block must fail"); + assert!( + error.to_string().contains("exceeds maximum"), + "unexpected error: {error}" + ); + } + #[test] fn rejects_non_ethernet_pcapng_enhanced_packet() { let mut parser = parser_from_stream(enhanced_pcapng_bytes( From 72988fd2b206c82c2c94f1b29faaf94d87de301a Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:33:28 +0300 Subject: [PATCH 12/28] Harden anonymization keys and honor CPU thread caps --- README.md | 51 +++++++-- docs/architecture.md | 22 ++-- docs/rfc/0006-adaptive-pipeline.md | 7 +- src/app.rs | 90 +++++++-------- src/cli.rs | 120 ++++++++++++++++++-- src/config.rs | 23 +++- src/dns_processor.rs | 2 +- src/dns_processor/anonymizer.rs | 148 ++++++++++++++++++++++--- src/dns_processor/pipeline.rs | 172 +++++++++++++++++++---------- src/packet_parser_wire_tests.rs | 39 ++++--- src/runtime.rs | 10 +- tests/end_to_end.rs | 72 ++++++++++++ 12 files changed, 582 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index aaf729e..b2e089b 100644 --- a/README.md +++ b/README.md @@ -152,11 +152,12 @@ If `output_filename` is omitted, DPP chooses the default file name from the reso `dns_output.csv` for `csv` and `dns_output.parquet` for `parquet` or `pq`. Use `-` only when you want CSV records on stdout. DPP refuses to start when the input and output paths refer to the same file, including hard-link aliases, so the input capture cannot be overwritten. +The same protection applies when the output path refers to the anonymization key file. ### Create an anonymization key -`--anonymize` expects a text file. DPP reads the file contents as a passphrase and derives the -internal pseudonymization key from that value. +`--anonymize` accepts a legacy text key file or a salted v2 key file. DPP derives the internal +pseudonymization keys from the file's passphrase and salt. One practical way to create a key file on Linux or macOS is: @@ -171,16 +172,40 @@ Then use it like this: dpp --anonymize /tmp/anon.key input.pcap output.csv ``` +To create a salted v2 key file instead, use Python 3: + +```bash +umask 077 +python3 - <<'PY' +import os +from secrets import token_bytes, token_hex + +contents = ( + b"\x89DPP-ANON-KEY-v2\n" + + token_bytes(32).hex().encode("ascii") + b"\n" + + token_hex(32).encode("ascii") + b"\n" +) +with os.fdopen(os.open("anon-v2.key", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as key: + key.write(contents) +PY +dpp --anonymize anon-v2.key input.pcap output.csv +``` + Notes: - Keep the key file private. Anyone with the same file can reproduce the same pseudonymized output. -- The key file must contain a non-empty UTF-8 passphrase. Leading and trailing whitespace is ignored. +- A legacy key file contains a non-empty UTF-8 passphrase. A salted v2 file contains the exact + binary header `\x89DPP-ANON-KEY-v2\n`, a line of exactly 64 hexadecimal digits representing + a 32-byte salt, then a non-empty UTF-8 passphrase. Leading and trailing passphrase whitespace is + ignored. Malformed salted files are rejected. - Rotating the key changes the resulting pseudonymized IP addresses for the same input capture. - Matching and deduplication use the original client IP. Pseudonymization is applied only to the `source_ip` field of finalized output records. IPv4 pseudonymization is a keyed permutation, so distinct IPv4 client addresses remain distinct in the output; network prefixes are not preserved. -- DPP intentionally uses a fixed PBKDF2 salt for deterministic pseudonymization. The passphrase is - still the operator-controlled secret; changing it rotates the derived pseudonyms. +- Legacy key files retain the original fixed PBKDF2 salt and produce the same pseudonyms as before. + The salted v2 format limits precomputation across files that use weak passphrases; a random + passphrase remains essential. Reuse the entire v2 key file across runs and hosts to keep output + stable. Changing either its salt or passphrase rotates both IPv4 and IPv6 pseudonyms. - The IPv4 Feistel mapping replaces the previous truncated-AES mapping. Even with the same passphrase, exports produced before and after this change cannot be joined by pseudonymized IPv4 `source_ip`. IPv6 pseudonyms remain unchanged. @@ -203,6 +228,7 @@ Notes: | `--report-format ` | Select the final process report format; defaults to `text`; `json` cannot be combined with `output_filename = -` | | `--match-timeout-ms ` | Set the DNS query-response match timeout in milliseconds; allowed range is `1..=5000`, default is `1200` | | `--monotonic-capture` | Assume globally monotonic packet timestamps, enable batched timeout eviction, and abort if a regression is detected | +| `-t, --threads ` | Cap the CPU execution budget at a positive `N`, never above available CPUs | | `-b, --bonded ` | Set I/O channel capacity in records; internally rounded up to batched messages of up to `1024` records; `0` uses the safe default bounded capacity | | `-z, --zstd` | Enable Zstd compression for Parquet output | | `--v2` | Use Parquet Version 2 | @@ -227,6 +253,7 @@ provide supported per-core thread pinning through the affinity backend. | `DPP_REPORT_FORMAT` | Final process report format: `text` or `json`; defaults to `text`; `json` cannot be combined with `DPP_OUTPUT_FILENAME=-` | | `DPP_MATCH_TIMEOUT_MS` | DNS query-response match timeout in milliseconds; allowed range is `1..=5000`, default is `1200` | | `DPP_MONOTONIC_CAPTURE` | Assume globally monotonic packet timestamps, enable batched timeout eviction, and abort if a regression is detected | +| `DPP_THREADS` | Maximum CPU execution budget when `--threads` is not set; must be a positive integer | | `DPP_BONDED` | I/O channel capacity in records; internally rounded up to batched messages of up to `1024` records; `0` uses the default bounded capacity | | `DPP_ZSTD` | Enable Zstd compression for Parquet output | | `DPP_V2` | Enable Parquet Version 2 | @@ -389,7 +416,7 @@ $ DPP_FILENAME=server1_jul_2024.pcap DPP_FORMAT=csv target/release/dpp --dns-wir 04:15:01.046 INFO > Commercial licensing options: carrier-support@dnstele.com 04:15:01.046 INFO > Nameto Oy (c) 2026. All rights reserved. 04:15:01.062 INFO OS: Linux, ARCH: x86_64 -04:15:01.062 INFO Available parallelism: 4, execution budget: auto (all available CPUs), affinity requested: false, effective: false +04:15:01.062 INFO Available parallelism: 4, execution budget: 4 CPUs (auto), affinity requested: false, effective: false 04:15:01.062 INFO Available memory (system reported): 2,857 MB 04:15:01.062 INFO Starting to process PCAP file: /mnt/mirror/src/dpp/server1_jul_2024.pcap 04:15:01.062 INFO Processing mode: forward sorting with response-query matching @@ -507,8 +534,8 @@ Additional notes: - See [benches/README.md](benches/README.md) for the benchmark contract and data-handling notes. - For apples-to-apples performance testing, prefer the dedicated `perf` build profile: `cargo build --profile perf` or `DPP_BENCH_PROFILE=perf bash benches/benchmark.sh ...`. -- DPP auto-sizes its execution budget from all available CPUs. -- Historical `--threads` and `DPP_THREADS` inputs are accepted only as deprecated compatibility no-ops and emit a warning if used. +- DPP auto-sizes its execution budget from available CPUs unless `--threads` or `DPP_THREADS` + sets a lower ceiling. The pipeline model is still selected automatically from that budget. - The parser fast path is opt-in through `--dns-wire-fast-path` or `DPP_DNS_WIRE_FAST_PATH=1`; without that flag DPP uses the legacy `hickory` question decoder. - `--allow-fragments` or `DPP_ALLOW_FRAGMENTS=1` optionally pairs a query with the observable first IPv4 fragment of a response when its DNS header and complete question are present. `response_code` is populated when there are no additional records or every declared DNS record fits in the first fragment; otherwise it is empty. The JSON metric `fragmented_response_prefix_count` (text: `Accepted first IPv4 response fragments`) counts accepted prefixes, including ones without a matching query. - `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. A bounded history of completed datagrams compares the fragment key and UDP payload bytes to suppress repeated complete datagrams and prefix fallback when all observed fragments match a recent completion. A different datagram can still use the same IPv4 ID, including one with an identical first fragment but different later bytes. A fully identical new datagram, or an incomplete one whose observed fragments match a recent completion, cannot be distinguished from a duplicate and may be suppressed. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. @@ -528,12 +555,12 @@ Additional notes: and IPv6 jumbograms are unsupported. Flow identity uses observed IP endpoints. - If capture parsing fails after processing begins, DPP flushes valid partial (not atomic) output from complete accepted batches and exits with an error; pending queries are not emitted as timeouts. -- **PCAPNG support level:** DPP supports PCAPNG on stream input and via `libpcap` on regular-file fallback paths, but the performance-critical pure-Rust fast path remains focused on classic PCAP. -- **Ethernet linktype only:** DPP rejects captures or packets declared with non-Ethernet linktypes instead of interpreting them as Ethernet. Ethernet frames containing VLAN, QinQ, MPLS, or similar outer encapsulation layers require preprocessing first. See [docs/encapsulation-playbook.md](docs/encapsulation-playbook.md). +- **PCAPNG support level:** DPP supports PCAPNG on stream input and via `libpcap` on regular-file fallback paths, but the performance-critical pure-Rust fast path remains focused on classic PCAP. Stdin PCAPNG blocks larger than 16 MiB are rejected before their body is read. +- **Ethernet linktype only:** DPP rejects captures or packets declared with non-Ethernet linktypes instead of interpreting them as Ethernet. Ethernet frames may carry VLAN or QinQ tags (`0x8100`, `0x88a8`, `0x9100`) before IPv4 or IPv6. MPLS and other outer encapsulation layers still require preprocessing; see [docs/encapsulation-playbook.md](docs/encapsulation-playbook.md). - **Unsorted exported data:** CSV and Parquet outputs are not guaranteed to be timestamp-sorted. - **Variable RAM usage:** Memory usage depends on capture size, traffic shape, and output backpressure. Larger `--bonded` values increase peak memory usage under slow output sinks because the output channel rounds the requested record backlog up to batched messages of up to `1024` records each. - **Monotonic-capture mode is explicit:** Batched timeout eviction is available only with `--monotonic-capture` because it depends on globally monotonic packet timestamps. If the capture is not monotonic, DPP aborts and recommends `reordercap`. -- **Scaling ceilings on skewed workloads:** DPP auto-sizes from all available CPUs, but flow-affinity ceilings can still limit scaling before linear speedup. +- **Scaling ceilings on skewed workloads:** DPP auto-sizes from available CPUs or the configured CPU cap, but flow-affinity ceilings can still limit scaling before linear speedup. - **Duplicate query handling:** Duplicate in-flight DNS queries and responses are preserved and resolved through deterministic matcher tie-breakers derived from capture order. Duplicate-heavy workloads can still increase matcher memory usage, and Parquet output may vary byte-for-byte because writers remain asynchronous. - **QNAME casing trade-off:** Matcher identity preserves observed QNAME casing instead of canonicalizing names to lowercase. RFC 4343 allows ASCII-label case-only differences between a query and a valid response, and name compression can contribute to that mismatch. Community Edition still matches on observed bytes because that better reflects the caching-resolver workloads it targets. As a result, names that differ only by case may not match even on protocol-compliant traffic. @@ -547,7 +574,7 @@ DPP Commercial Edition extends that foundation with broader capture support, ent |-------------------------------------------------------|-------------------------------------------------------------------------------------------------| | Offline PCAP processing | Native S3 integration for reading PCAP | | CSV and single-file Parquet export | Partitioned Parquet datasets, not just single-file export | -| Current encapsulation scope | Advanced encapsulation support: native support for VLAN, QinQ, MPLS, GRE, VXLAN, ERSPAN, Geneve | +| Native Ethernet, VLAN and QinQ decoding | Advanced encapsulation support: MPLS, GRE, VXLAN, ERSPAN, Geneve | | Local file outputs | Direct enterprise sinks: ClickHouse, Kafka, S3, PostgreSQL outputs | | Batch-oriented processing | Live/continuous ingestion | | Deterministic pseudonymization with file-based keying | Commercial anonymization/compliance features | diff --git a/docs/architecture.md b/docs/architecture.md index 116bc88..0564c1d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,8 +75,8 @@ In the current multi-threaded implementation, those stages are realized as a bou reader, a lightweight routing stage, shard-owned workers, a deterministic aggregator, and asynchronous writers. The routing stage performs only cheap L3/L4 extraction, computes a canonical client/resolver flow key, and hands owned packet batches to the correct worker before full DNS -decode begins. DPP derives its execution budget from all available CPUs; there is no supported -runtime thread-count override. On low-core hosts, DPP falls back to the simpler phase-parallel +decode begins. DPP derives its execution budget from available CPUs, capped by `--threads` or +`DPP_THREADS` when specified. On low-core budgets, DPP falls back to the simpler phase-parallel pipeline so it does not spend too much of the machine on staged-pipeline service roles. In staged mode, the runtime reserves two non-worker service threads for routing/aggregation and parser work, and uses the remaining CPU budget for shard workers. Routed DNS packets also carry compact UDP/DNS @@ -101,7 +101,8 @@ or second encoding of canonical addresses and ports. high/low timestamp words using the interface's full binary/decimal resolution and signed offset. Conversion scales the complete counter before rounding to microseconds and saturates only the final signed timestamp. It never invokes the dependency's stateful timestamp conversion. - Buffers grow with received block bytes rather than untrusted declared lengths. Stdin probing + Buffers grow with received block bytes rather than untrusted declared lengths, and stdin PCAPNG + blocks over 16 MiB are rejected before their body is read. Stdin probing needs no temp-file or second ingest owner. Unsupported stdin stream magic is rejected explicitly. Signal-driven shutdown can interrupt a blocked stdin read without waiting for the producer to close the stream; capture reading remains parser-owned. Packets already read into a nonempty @@ -188,6 +189,10 @@ or second encoding of canonical addresses and ports. duplicate and may be suppressed; the same holds for a fully identical new datagram. Reassembly state must remain bounded in memory, and IPv6 fragments remain unsupported. A fragmented UDP datagram must have an IP payload length equal to its UDP Length; mismatched fragment sets are rejected. + A first non-DNS UDP fragment removes any earlier tails for its datagram key. A separate bounded + history drops later non-DNS tails without evicting the completed-DNS history; a new DNS first + fragment with a reused IPv4 ID removes that non-DNS mark. Ethernet VLAN tags are part of the + fragment key so traffic from different tagged segments is not assembled together. The optional runtime flag `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains the semantic fallback for rare DNS messages that the fast path does not accept. Compression @@ -247,8 +252,8 @@ or second encoding of canonical addresses and ports. pipeline execution (0006), and packet-storage allocation experiments (0007). - `docs/encapsulation-playbook.md` - Operational and engineering guidance for captures that contain VLAN, QinQ, MPLS, or other outer - encapsulation layers before the IP header. + Operational and engineering guidance for captures with unsupported MPLS or other outer + encapsulation layers before the IP header. Ethernet VLAN and QinQ are decoded natively. - `benches/README.md` Documents the benchmark contract, safety expectations, and result layout for repeatable runs. @@ -394,8 +399,9 @@ The required boundary is: permutation with an independently derived AES key, so distinct IPv4 addresses cannot collide. IPv6 retains its original AES block mapping. The IPv4 mapping changed with this algorithm; exports from before and after the change cannot be joined by pseudonymized IPv4 address. -- The pseudonymization key derivation uses a fixed PBKDF2 salt by design so the same passphrase - yields stable output across runs and hosts. The operator-provided passphrase remains the secret - rotation boundary. +- Legacy text key files retain the fixed PBKDF2 salt so existing pseudonyms remain stable across + runs and hosts. The optional salted v2 key-file format supplies a 32-byte salt for both IPv4 and + IPv6 key derivation. Reusing the full file keeps its mapping stable; changing the salt or + passphrase rotates both mappings. Key loading and derivation are owned by `anonymizer.rs`. - `docs/rfc/` and `benches/` remain the canonical references for accepted architecture decisions and repeatable benchmark runs. diff --git a/docs/rfc/0006-adaptive-pipeline.md b/docs/rfc/0006-adaptive-pipeline.md index 887311b..9fcc250 100644 --- a/docs/rfc/0006-adaptive-pipeline.md +++ b/docs/rfc/0006-adaptive-pipeline.md @@ -52,7 +52,7 @@ threads would compete with the actual work. ### How the decision is made The threshold lives in `src/config.rs` as part of the `ExecutionBudget`. The -`uses_staged_pipeline()` method returns `true` when the available CPU count is high enough to +`uses_staged_pipeline()` method returns `true` when the effective CPU budget is high enough to reserve service threads (currently 2: one for parsing/routing, one implicitly for aggregation) and still leave meaningful worker capacity. The exact threshold is a tuned constant, not a CLI flag — it's an implementation detail, not a user-facing policy. @@ -70,12 +70,13 @@ CLI flag — it's an implementation detail, not a user-facing policy. and passes it alongside the packet to the matcher worker, so the worker doesn't repeat L3/L4 parsing for DNS question decode. -## Why not let the user choose? +## Why the execution model remains automatic Because the right choice depends on hardware, not on user preference. A user on a 2-core VM who forces staged mode gets worse performance, not better. The automatic selection removes a footgun without losing any capability — on high-core machines, you get the staged pipeline -automatically. +automatically. Operators can cap the CPU budget with `--threads` or `DPP_THREADS`; the pipeline +model is selected from that effective budget. If future profiling shows the threshold needs tuning, it's a one-line change in `config.rs`. diff --git a/src/app.rs b/src/app.rs index 81048f1..1f4de24 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ */ use crate::config::{AppConfig, InputSource, OutputFormat, ReportFormat}; -use crate::dns_processor::{DnsProcessor, ProcessingCounters}; +use crate::dns_processor::{DnsProcessor, PipelineExecutionConfig, ProcessingCounters}; use crate::error::{AppRunError, OutputError}; use crate::output::OutputMessage; use crate::packet_parser::{NonMonotonicTimestampSample, PacketParser}; @@ -341,16 +341,39 @@ fn average_matched_rtt_ms( } } +struct RunSummaryTimings { + processing_seconds: f64, + final_write_post_processing_seconds: f64, + total_runtime_seconds: f64, +} + +impl RunSummaryTimings { + fn new( + processing_seconds: f64, + final_write_post_processing_seconds: f64, + total_runtime_seconds: f64, + ) -> Self { + Self { + processing_seconds, + final_write_post_processing_seconds, + total_runtime_seconds, + } + } +} + fn build_run_summary( args: &AppConfig, execution_budget: crate::config::ExecutionBudget, counters: ProcessingCounters, warnings: RunWarningsSummary, max_memory_usage_kib: usize, - processing_seconds: f64, - final_write_post_processing_seconds: f64, - total_runtime_seconds: f64, + timings: RunSummaryTimings, ) -> RunSummary { + let RunSummaryTimings { + processing_seconds, + final_write_post_processing_seconds, + total_runtime_seconds, + } = timings; let processing_speed_pps = packets_per_second(counters.total_packets_processed, processing_seconds); @@ -569,13 +592,6 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { let packet_count = Arc::new(AtomicUsize::new(0)); - if let Some(requested_threads) = args.requested_threads { - tracing::warn!( - "Ignoring deprecated thread override {}. DPP now auto-sizes execution from all available CPUs.", - requested_threads - ); - } - runtime::log_build_messages()?; runtime::log_system_info(&args, affinity_plan.is_enabled())?; runtime::log_accessible_input_file(&args)?; @@ -631,9 +647,11 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { &mut packet_parser, &packet_count, &tx, - execution_budget, - affinity_plan, - true, + PipelineExecutionConfig { + execution_budget, + affinity_plan, + shard_parallelism_enabled: true, + }, Arc::clone(&shutdown_requested), Arc::clone(&output_closed), ) @@ -709,9 +727,11 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { counters, warnings, max_memory_kib, - processing_seconds, - final_write_post_processing_seconds, - total_runtime_seconds, + RunSummaryTimings::new( + processing_seconds, + final_write_post_processing_seconds, + total_runtime_seconds, + ), ); match args.report_format { @@ -793,9 +813,7 @@ mod tests { ProcessingCounters::default(), RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -836,9 +854,7 @@ mod tests { }, RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -998,9 +1014,7 @@ mod tests { }, RunWarningsSummary::default(), 123, - 2.0, - 0.75, - 2.75, + RunSummaryTimings::new(2.0, 0.75, 2.75), ); assert_eq!(summary.metrics.processing_speed_pps, 2_000); @@ -1018,9 +1032,7 @@ mod tests { ProcessingCounters::default(), RunWarningsSummary::default(), 123, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -1048,9 +1060,7 @@ mod tests { ..RunWarningsSummary::default() }, 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -1087,9 +1097,7 @@ mod tests { ..RunWarningsSummary::default() }, 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -1123,9 +1131,7 @@ mod tests { }, RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); assert_eq!(summary.metrics.timed_out_queries, 2); @@ -1145,9 +1151,7 @@ mod tests { }, RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); assert_eq!(summary.metrics.dns_messages_rejected_oversized_qname, 3); @@ -1204,9 +1208,7 @@ mod tests { ProcessingCounters::default(), RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let mut sink = BrokenPipeJsonSink { bytes_until_broken_pipe: 4, diff --git a/src/cli.rs b/src/cli.rs index 00a3a5b..4dd9827 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -33,9 +33,7 @@ pub(crate) fn parse_args() -> Result { let env_allow_fragments = parse_env_bool("DPP_ALLOW_FRAGMENTS"); let env_full_fragments = parse_env_bool("DPP_FULL_FRAGMENTS"); let env_monotonic_capture = parse_env_bool("DPP_MONOTONIC_CAPTURE"); - let env_threads = env::var("DPP_THREADS") - .ok() - .and_then(|value| value.parse::().ok()); + let env_threads = env::var_os("DPP_THREADS"); let env_bonded = env::var_os("DPP_BONDED"); let matches = build_cli(version).get_matches(); @@ -69,10 +67,7 @@ pub(crate) fn parse_args() -> Result { .map(|parallelism| parallelism.get()) .unwrap_or(1); - let requested_threads = matches - .get_one::("threads") - .and_then(|value| value.parse::().ok()) - .or(env_threads); + let requested_threads = resolve_thread_limit(&matches, env_threads.as_deref())?; let bonded = resolve_bonded(&matches, env_bonded.as_deref())?; @@ -85,6 +80,7 @@ pub(crate) fn parse_args() -> Result { let output_target = output_target_for_path(&output_filename); validate_output_path(&output_filename)?; validate_distinct_input_output(&input_source, &output_filename)?; + validate_distinct_key_output(anonymize.as_deref(), &output_filename)?; validate_output_mode(output_target, format, report_format)?; let zstd = matches.get_flag("zstd") || env_zstd; @@ -136,6 +132,7 @@ fn build_cli(version: &'static str) -> Command { DPP_FULL_FRAGMENTS Set to 'true' to reassemble IPv4 fragments and enable first-fragment response matching DPP_MONOTONIC_CAPTURE Set to 'true' to assume globally monotonic packet timestamps, enable batched timeout eviction, and abort on timestamp regressions + DPP_THREADS Maximum CPU execution budget (positive integer, capped to available CPUs; used if --threads is not specified) DPP_REPORT_FORMAT Final process report format: text or json (used if --report-format is not specified; json cannot be combined with stdout output) DPP_MATCH_TIMEOUT_MS DNS match timeout in milliseconds; allowed range is 1..=5000, default is 1200 DPP_BONDED=N Set IO channel capacity in records; internally rounded up to batched messages of up to 1024 records; 0 uses the safe default bounded capacity @@ -202,7 +199,7 @@ LICENSE INFORMATION: Arg::new("threads") .long("threads") .short('t') - .hide(true) + .help("Cap the CPU execution budget (positive integer, never exceeds available CPUs)") .value_name("N") .num_args(1), ) @@ -311,6 +308,23 @@ fn resolve_match_timeout_ms(matches: &ArgMatches, env_value: Option<&str>) -> Re } } +fn resolve_thread_limit(matches: &ArgMatches, env_value: Option<&OsStr>) -> Result> { + let value = if let Some(cli_value) = matches.get_one::("threads") { + cli_value.as_str() + } else if let Some(env_value) = env_value { + env_value + .to_str() + .ok_or_else(|| anyhow!("DPP_THREADS must be valid UTF-8"))? + } else { + return Ok(None); + }; + let limit = value + .parse::() + .with_context(|| format!("Failed to parse CPU thread limit from '{value}'"))?; + anyhow::ensure!(limit > 0, "CPU thread limit must be greater than 0."); + Ok(Some(limit)) +} + fn resolve_bonded(matches: &ArgMatches, env_value: Option<&OsStr>) -> Result { if let Some(value) = matches.get_one::("bonded") { return Ok(*value); @@ -408,6 +422,27 @@ fn validate_distinct_input_output(input_source: &InputSource, output_path: &Path Ok(()) } +fn validate_distinct_key_output(key_path: Option<&Path>, output_path: &Path) -> Result<()> { + let Some(key_path) = key_path else { + return Ok(()); + }; + if matches!(output_target_for_path(output_path), OutputTarget::Stdout) || !output_path.exists() + { + return Ok(()); + } + + if same_file::is_same_file(key_path, output_path) + .context("Failed to compare anonymization key and output file identities")? + { + bail!( + "Error: Anonymization key '{}' and output path '{}' refer to the same file; refusing to overwrite the key.", + key_path.display(), + output_path.display() + ); + } + Ok(()) +} + fn validate_output_mode( output_target: OutputTarget, format: OutputFormat, @@ -666,6 +701,57 @@ mod tests { assert_eq!(resolved, 500); } + #[test] + fn thread_limit_uses_env_and_cli_takes_precedence() { + let env_only = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_thread_limit(&env_only, Some(OsStr::new("4"))).unwrap(), + Some(4) + ); + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--threads", "2", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_thread_limit(&cli, Some(OsStr::new("4"))).unwrap(), + Some(2) + ); + } + + #[test] + fn invalid_thread_limits_are_rejected() { + let env_only = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + for invalid in ["0", "bad", "184467440737095516160"] { + assert!(resolve_thread_limit(&env_only, Some(OsStr::new(invalid))).is_err()); + } + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--threads", "0", "input.pcap"]) + .expect("cli parses"); + assert!(resolve_thread_limit(&cli, None).is_err()); + } + + #[cfg(unix)] + #[test] + fn non_utf8_thread_env_is_rejected_unless_cli_overrides_it() { + use std::os::unix::ffi::OsStrExt; + + let invalid = OsStr::from_bytes(&[0xff]); + let env_only = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert!(resolve_thread_limit(&env_only, Some(invalid)).is_err()); + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--threads", "2", "input.pcap"]) + .expect("cli parses"); + assert_eq!(resolve_thread_limit(&cli, Some(invalid)).unwrap(), Some(2)); + } + #[test] fn cli_monotonic_capture_flag_is_recognized() { let matches = build_cli("test") @@ -751,6 +837,24 @@ mod tests { fs::remove_file(path).expect("removes temp anonymization key"); } + #[test] + fn anonymization_key_and_hard_link_cannot_be_output() { + let key_path = unique_temp_path("anonymize.key"); + let alias_path = unique_temp_path("anonymize-output.csv"); + fs::write(&key_path, b"secret").expect("writes temp anonymization key"); + fs::hard_link(&key_path, &alias_path).expect("creates hard link to key"); + + for output_path in [&key_path, &alias_path] { + let error = validate_distinct_key_output(Some(&key_path), output_path) + .expect_err("key alias must not be used as output"); + assert!(error.to_string().contains("refusing to overwrite the key")); + } + assert_eq!(fs::read(&key_path).expect("reads preserved key"), b"secret"); + + fs::remove_file(alias_path).expect("removes hard link"); + fs::remove_file(key_path).expect("removes key file"); + } + #[test] fn stdout_output_sentinel_is_accepted() { validate_output_path(Path::new("-")).expect("stdout sentinel is accepted"); diff --git a/src/config.rs b/src/config.rs index 1374d80..7c69bbc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -260,7 +260,10 @@ impl AppConfig { } pub(crate) fn execution_budget(&self) -> ExecutionBudget { - ExecutionBudget::from_available_cpus(self.num_cpus) + let budget_cpus = self + .requested_threads + .map_or(self.num_cpus, |limit| self.num_cpus.min(limit)); + ExecutionBudget::from_available_cpus(budget_cpus) } } @@ -356,4 +359,22 @@ mod tests { assert_eq!(budget.staged_worker_budget, 14); assert!(budget.uses_staged_pipeline()); } + + #[test] + fn thread_limit_caps_budget_without_exceeding_available_cpus() { + let mut config = test_config(); + config.num_cpus = 16; + config.requested_threads = Some(3); + + let capped = config.execution_budget(); + assert_eq!(capped.available_cpus, 3); + assert_eq!(capped.model, ExecutionModel::PhaseParallel); + assert_eq!(capped.rayon_threads, Some(3)); + + config.requested_threads = Some(32); + let uncapped = config.execution_budget(); + assert_eq!(uncapped.available_cpus, 16); + assert_eq!(uncapped.model, ExecutionModel::Staged); + assert_eq!(uncapped.staged_worker_budget, 14); + } } diff --git a/src/dns_processor.rs b/src/dns_processor.rs index 10df8ff..1ef905d 100644 --- a/src/dns_processor.rs +++ b/src/dns_processor.rs @@ -16,7 +16,7 @@ mod types; #[cfg(test)] use crate::config::DEFAULT_MATCH_TIMEOUT_MS; -pub(crate) use pipeline::ProcessingCounters; +pub(crate) use pipeline::{PipelineExecutionConfig, ProcessingCounters}; use std::io; use std::net::IpAddr; use std::path::Path; diff --git a/src/dns_processor/anonymizer.rs b/src/dns_processor/anonymizer.rs index 47d4019..6bef1f3 100644 --- a/src/dns_processor/anonymizer.rs +++ b/src/dns_processor/anonymizer.rs @@ -10,17 +10,16 @@ use aes::cipher::{Block, BlockCipherEncrypt, KeyInit}; use pbkdf2::pbkdf2_hmac; use sha2::Sha256; use std::fs; -use std::io::{self, Read}; +use std::io; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::path::Path; -// The community edition uses a fixed PBKDF2 salt on purpose so that the same passphrase yields the -// same deterministic pseudonymization output across runs and hosts. The passphrase remains the -// operator-controlled secret; rotating it rotates all derived pseudonyms. +// Legacy text key files retain this salt so existing pseudonyms remain stable across runs and hosts. static SALT: [u8; 32] = [ 199, 76, 160, 70, 220, 85, 167, 75, 67, 93, 117, 51, 223, 17, 109, 52, 125, 192, 43, 44, 172, 36, 193, 95, 137, 81, 216, 92, 201, 141, 252, 241, ]; +const SALTED_KEY_MAGIC: &[u8] = b"\x89DPP-ANON-KEY-v2\n"; const IPV4_FEISTEL_ROUNDS: u8 = 8; const IPV4_KEY_LABEL: &[u8] = b"dpp-ipv4-feistel-v1"; @@ -37,8 +36,8 @@ impl Anonymizer { pub(super) fn new(anonymize_key_path: Option<&Path>) -> io::Result { let ciphers = match anonymize_key_path { Some(path) => { - let passphrase = Self::read_key_from_file(path)?; - Some(Self::derive_ciphers(&passphrase)?) + let (passphrase, salt) = Self::read_key_from_file(path)?; + Some(Self::derive_ciphers(&passphrase, &salt)?) } None => None, }; @@ -57,10 +56,47 @@ impl Anonymizer { } } - fn read_key_from_file(path: &Path) -> Result, io::Error> { - let mut file = fs::File::open(path)?; - let mut passphrase = String::new(); - file.read_to_string(&mut passphrase)?; + fn read_key_from_file(path: &Path) -> io::Result<(Box, [u8; 32])> { + let contents = fs::read(path)?; + let (passphrase, salt) = if let Some(body) = contents.strip_prefix(SALTED_KEY_MAGIC) { + let salt_line_end = body.iter().position(|byte| *byte == b'\n').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "salted key file has no salt line", + ) + })?; + let salt_hex = &body[..salt_line_end]; + if salt_hex.len() != 64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "salted key file must contain exactly 64 hexadecimal salt digits", + )); + } + if !salt_hex.iter().all(u8::is_ascii_hexdigit) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid hexadecimal salt", + )); + } + let mut salt = [0u8; 32]; + for (output, pair) in salt.iter_mut().zip(salt_hex.chunks_exact(2)) { + let digits = std::str::from_utf8(pair).expect("ASCII hex digits checked"); + *output = u8::from_str_radix(digits, 16).expect("ASCII hex digits checked"); + } + ( + std::str::from_utf8(&body[salt_line_end + 1..]).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "passphrase must be valid UTF-8") + })?, + salt, + ) + } else { + ( + std::str::from_utf8(&contents).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "passphrase must be valid UTF-8") + })?, + SALT, + ) + }; let passphrase = passphrase.trim(); if passphrase.is_empty() { return Err(io::Error::new( @@ -69,13 +105,16 @@ impl Anonymizer { )); } - Ok(passphrase.into()) + Ok((passphrase.into(), salt)) } - fn derive_ciphers(passphrase: &str) -> Result { + fn derive_ciphers( + passphrase: &str, + salt: &[u8; 32], + ) -> Result { // Keep the original key for IPv6 so existing IPv6 pseudonyms remain stable. - let ipv6_key = Self::derive_key_from_passphrase(passphrase, &SALT)?; - let mut ipv4_salt = SALT.to_vec(); + let ipv6_key = Self::derive_key_from_passphrase(passphrase, salt)?; + let mut ipv4_salt = salt.to_vec(); ipv4_salt.extend_from_slice(IPV4_KEY_LABEL); let ipv4_key = Self::derive_key_from_passphrase(passphrase, &ipv4_salt)?; @@ -131,7 +170,7 @@ impl Anonymizer { #[cfg(test)] mod tests { - use super::Anonymizer; + use super::{Anonymizer, SALT, SALTED_KEY_MAGIC}; use std::collections::HashSet; use std::fs; use std::io::ErrorKind; @@ -139,7 +178,7 @@ mod tests { use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - fn temp_key_file(contents: &str) -> PathBuf { + fn temp_key_file(contents: impl AsRef<[u8]>) -> PathBuf { let mut path = std::env::temp_dir(); let unique = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -186,9 +225,84 @@ mod tests { fs::remove_file(key_path).expect("removes temp key file"); } + #[test] + fn salted_key_changes_both_families_and_is_stable_across_reloads() { + let legacy_path = temp_key_file("secret-passphrase\n"); + let mut salted_contents = SALTED_KEY_MAGIC.to_vec(); + salted_contents.extend_from_slice( + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\nsecret-passphrase\n", + ); + let salted_path = temp_key_file(salted_contents); + + let legacy = Anonymizer::new(Some(&legacy_path)).expect("legacy key loads"); + let salted = Anonymizer::new(Some(&salted_path)).expect("salted key loads"); + let reloaded = Anonymizer::new(Some(&salted_path)).expect("salted key reloads"); + for ip in [ + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ] { + assert_ne!(salted.anonymize_ip(&ip), legacy.anonymize_ip(&ip)); + assert_eq!(salted.anonymize_ip(&ip), reloaded.anonymize_ip(&ip)); + } + + fs::remove_file(legacy_path).expect("removes legacy key file"); + fs::remove_file(salted_path).expect("removes salted key file"); + } + + #[test] + fn malformed_salted_keys_are_rejected_without_legacy_fallback() { + for body in [ + b"short\nsecret".as_slice(), + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeefg\nsecret", + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\n ", + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\n\xff", + ] { + let mut contents = SALTED_KEY_MAGIC.to_vec(); + contents.extend_from_slice(body); + let key_path = temp_key_file(contents); + let error = Anonymizer::new(Some(&key_path)) + .err() + .expect("malformed salted key must fail"); + assert!( + matches!( + error.kind(), + ErrorKind::InvalidData | ErrorKind::InvalidInput + ), + "unexpected error: {error}" + ); + fs::remove_file(key_path).expect("removes malformed key file"); + } + + let mut contents = SALTED_KEY_MAGIC.to_vec(); + let mut body = + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\nsecret".to_vec(); + body[0] = b'+'; + contents.extend_from_slice(&body); + let key_path = temp_key_file(contents); + assert_eq!( + Anonymizer::new(Some(&key_path)) + .err() + .expect("plus sign is not a hex digit") + .kind(), + ErrorKind::InvalidData + ); + fs::remove_file(key_path).expect("removes malformed key file"); + } + + #[test] + fn text_that_looks_like_a_key_header_remains_a_legacy_passphrase() { + let passphrase = "DPP-ANON-KEY-v2\n00112233445566778899aabbccddeeff"; + let key_path = temp_key_file(passphrase); + let (loaded, salt) = Anonymizer::read_key_from_file(&key_path).expect("legacy key loads"); + assert_eq!(&*loaded, passphrase); + assert_eq!(salt, SALT); + fs::remove_file(key_path).expect("removes legacy key file"); + } + #[test] fn ipv4_pseudonyms_are_unique_for_distinct_addresses() { - let ciphers = Anonymizer::derive_ciphers("secret").expect("derives ciphers"); + let ciphers = Anonymizer::derive_ciphers("secret", &SALT).expect("derives ciphers"); let mut seen = HashSet::with_capacity(100_000); for address in 0..100_000u32 { diff --git a/src/dns_processor/pipeline.rs b/src/dns_processor/pipeline.rs index 52fde54..bc02333 100644 --- a/src/dns_processor/pipeline.rs +++ b/src/dns_processor/pipeline.rs @@ -32,6 +32,25 @@ const MATCHER_WORKER_QUEUE_DEPTH: usize = 2; const AGGREGATOR_REORDER_BUFFER_CAPACITY: usize = BATCH_PREFETCH_DEPTH + MATCHER_WORKER_QUEUE_DEPTH + 2; +struct WorkerShutdownSignals { + shutdown_requested: Arc, + intake_failed: Arc, + output_closed: Arc, +} + +struct FragmentProcessingConfig { + allow_fragments: bool, + full_fragments: bool, + match_timeout_micros: i64, + monotonic_capture: bool, +} + +pub(crate) struct PipelineExecutionConfig { + pub(crate) execution_budget: ExecutionBudget, + pub(crate) affinity_plan: AffinityPlan, + pub(crate) shard_parallelism_enabled: bool, +} + fn staged_matcher_affinity_slot(worker_idx: usize) -> usize { worker_idx } @@ -620,10 +639,13 @@ fn run_phase_processing_worker( tx: Sender, shard_count: usize, shard_parallelism_enabled: bool, - shutdown_requested: Arc, - intake_failed: Arc, - output_closed: Arc, + signals: WorkerShutdownSignals, ) -> anyhow::Result { + let WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + } = signals; let mut shard_states: Vec = if shard_parallelism_enabled { (0..shard_count) .map(|_| MatcherShardState::default()) @@ -731,11 +753,14 @@ fn run_parser_stage( worker_txs: Vec>, routing_plan: ShardRoutingPlan, output_closed: Arc, - allow_fragments: bool, - full_fragments: bool, - match_timeout_micros: i64, - monotonic_capture: bool, + fragment_config: FragmentProcessingConfig, ) -> anyhow::Result<()> { + let FragmentProcessingConfig { + allow_fragments, + full_fragments, + match_timeout_micros, + monotonic_capture, + } = fragment_config; let mut batch_seq = 0_u64; let mut reassembler = full_fragments .then(|| Ipv4FragmentReassembler::new(match_timeout_micros, monotonic_capture)); @@ -810,10 +835,13 @@ fn run_matcher_worker( shard_range: Range, batch_rx: Receiver, result_tx: Sender, - shutdown_requested: Arc, - intake_failed: Arc, - output_closed: Arc, + signals: WorkerShutdownSignals, ) -> anyhow::Result<()> { + let WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + } = signals; let logical_shard_count = shard_range.end.saturating_sub(shard_range.start); let mut shard_states: Vec = (0..logical_shard_count) .map(|_| MatcherShardState::default()) @@ -958,10 +986,13 @@ fn run_staged_processing_pipeline( shard_count: usize, worker_count: usize, affinity_plan: AffinityPlan, - shutdown_requested: Arc, - intake_failed: Arc, - output_closed: Arc, + signals: WorkerShutdownSignals, ) -> anyhow::Result { + let WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + } = signals; let routing_plan = ShardRoutingPlan::new(shard_count, worker_count); let (result_tx, result_rx) = crossbeam::channel::bounded(MATCHER_WORKER_QUEUE_DEPTH * worker_count.max(1)); @@ -994,9 +1025,11 @@ fn run_staged_processing_pipeline( shard_range, worker_rx, result_tx, - shutdown_requested, - intake_failed, - output_closed, + WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + }, ) } })?, @@ -1023,10 +1056,12 @@ fn run_staged_processing_pipeline( worker_txs, routing_plan, output_closed, - allow_fragments, - full_fragments, - match_timeout_micros, - monotonic_capture, + FragmentProcessingConfig { + allow_fragments, + full_fragments, + match_timeout_micros, + monotonic_capture, + }, ) } })?; @@ -1057,12 +1092,15 @@ impl DnsProcessor { packet_parser: &mut PacketParser, packet_count: &Arc, tx: &Sender, - execution_budget: ExecutionBudget, - affinity_plan: AffinityPlan, - shard_parallelism_enabled: bool, + config: PipelineExecutionConfig, shutdown_requested: Arc, output_closed: Arc, ) -> anyhow::Result { + let PipelineExecutionConfig { + execution_budget, + affinity_plan, + shard_parallelism_enabled, + } = config; let shard_count = logical_shard_count(execution_budget.available_cpus, shard_parallelism_enabled); let worker_count = @@ -1072,14 +1110,14 @@ impl DnsProcessor { if execution_budget.uses_staged_pipeline() { tracing::info!( - "Execution budget: auto using {} CPUs, staged worker budget: {} shard workers, {} reserved service threads", + "Execution budget: {} CPUs, staged worker budget: {} shard workers, {} reserved service threads", execution_budget.available_cpus, worker_count, execution_budget.staged_reserved_service_threads ); } else { tracing::info!( - "Execution budget: auto using {} CPUs, phase-parallel pipeline selected for low-core host, Rayon worker budget: {}", + "Execution budget: {} CPUs, phase-parallel pipeline selected for low-core budget, Rayon worker budget: {}", execution_budget.available_cpus, execution_budget .rayon_threads @@ -1104,9 +1142,11 @@ impl DnsProcessor { shard_count, worker_count, affinity_plan, - shutdown_requested, - intake_failed, - output_closed, + WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + }, ) } })? @@ -1126,9 +1166,11 @@ impl DnsProcessor { output_tx, shard_count, shard_parallelism_enabled, - shutdown_requested, - intake_failed, - output_closed, + WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + }, ) } })? @@ -1213,6 +1255,14 @@ mod tests { use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicUsize}; + fn test_worker_signals(shutdown_requested: bool) -> WorkerShutdownSignals { + WorkerShutdownSignals { + shutdown_requested: Arc::new(AtomicBool::new(shutdown_requested)), + intake_failed: Arc::new(AtomicBool::new(false)), + output_closed: Arc::new(AtomicBool::new(false)), + } + } + fn shard_result(token: usize) -> ShardProcessingResult { ShardProcessingResult { dns_query_count: token, @@ -1385,9 +1435,11 @@ mod tests { &mut parser, &packet_count, &output_tx, - ExecutionBudget::from_available_cpus(5), - affinity_plan, - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(5), + affinity_plan, + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ) @@ -1527,9 +1579,11 @@ mod tests { &mut parser, &packet_count, &output_tx, - ExecutionBudget::from_available_cpus(available_cpus), - AffinityPlan::disabled(), - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ); @@ -1573,9 +1627,11 @@ mod tests { &mut parser, &packet_count, &output_tx, - ExecutionBudget::from_available_cpus(available_cpus), - AffinityPlan::disabled(), - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ) @@ -1683,9 +1739,11 @@ mod tests { &mut parser, &Arc::new(AtomicUsize::new(0)), &output_tx, - ExecutionBudget::from_available_cpus(available_cpus), - AffinityPlan::disabled(), - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ) @@ -1797,9 +1855,11 @@ mod tests { &mut parser, &packet_count, &output_tx, - ExecutionBudget::from_available_cpus(available_cpus), - AffinityPlan::disabled(), - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ) @@ -1916,9 +1976,7 @@ mod tests { output_tx, 1, false, - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(false), ) .expect("phase worker completes"); @@ -1954,9 +2012,7 @@ mod tests { output_tx, 1, false, - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(false), ) .expect("phase worker completes"); @@ -1983,9 +2039,7 @@ mod tests { output_tx, 1, false, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(true), ) .expect("phase worker completes"); @@ -2025,9 +2079,7 @@ mod tests { worker_range, batch_rx, result_tx, - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(false), ) .expect("matcher worker completes"); @@ -2075,9 +2127,7 @@ mod tests { worker_range, batch_rx, result_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(true), ) .expect("matcher worker completes"); diff --git a/src/packet_parser_wire_tests.rs b/src/packet_parser_wire_tests.rs index 96dfaf6..28d21db 100644 --- a/src/packet_parser_wire_tests.rs +++ b/src/packet_parser_wire_tests.rs @@ -331,23 +331,28 @@ fn preserves_block_and_packet_length_validation() { } #[test] -fn rejects_huge_truncated_blocks_without_reserving_the_declared_length() { - let mut capture = WireCapture::new(Order::Little); - capture.bytes.extend(6_u32.to_le_bytes()); - capture.bytes.extend(0xffff_fffc_u32.to_le_bytes()); - capture.bytes.extend([0; 4]); - let mut parser = parser(capture.bytes); - assert!( - parser - .next_batch(8) - .unwrap_err() - .to_string() - .contains("Truncated pcapng block") - ); - let PacketBackend::PcapNg(reader) = &parser.backend else { - panic!("pcapng backend") - }; - assert!(reader.block_bytes.capacity() < 1024 * 1024); +fn rejects_oversized_or_truncated_blocks_without_reserving_the_declared_length() { + for (declared_len, expected_error) in [ + (0xffff_fffc_u32, "exceeds maximum"), + (1024_u32, "Truncated pcapng block"), + ] { + let mut capture = WireCapture::new(Order::Little); + capture.bytes.extend(6_u32.to_le_bytes()); + capture.bytes.extend(declared_len.to_le_bytes()); + capture.bytes.extend([0; 4]); + let mut parser = parser(capture.bytes); + assert!( + parser + .next_batch(8) + .unwrap_err() + .to_string() + .contains(expected_error) + ); + let PacketBackend::PcapNg(reader) = &parser.backend else { + panic!("pcapng backend") + }; + assert!(reader.block_bytes.capacity() < 1024 * 1024); + } } #[test] diff --git a/src/runtime.rs b/src/runtime.rs index dc64014..72d877a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -226,9 +226,15 @@ pub(crate) fn log_system_info( let formatted_os = format_os_name(std::env::consts::OS); info!("OS: {}, ARCH: {}", formatted_os, std::env::consts::ARCH); + let budget = args.execution_budget(); + let budget_source = if args.requested_threads.is_some() { + "operator cap" + } else { + "auto" + }; info!( - "Available parallelism: {}, execution budget: auto (all available CPUs), affinity requested: {}, effective: {}", - args.num_cpus, args.affinity, affinity_effective, + "Available parallelism: {}, execution budget: {} CPUs ({}), affinity requested: {}, effective: {}", + args.num_cpus, budget.available_cpus, budget_source, args.affinity, affinity_effective, ); info!( "Available memory (system reported): {} MB", diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index df6a7cd..7a844bd 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -622,6 +622,78 @@ fn maximum_wire_qname_round_trips_with_full_escaped_presentation() { fs::remove_file(&output_path).expect("remove output csv"); } +#[test] +fn anonymization_key_is_not_overwritten_by_output_alias() { + let input_path = temp_test_path("key-output-alias", "pcap"); + let key_path = temp_test_path("key-output-alias", "key"); + let alias_path = temp_test_path("key-output-alias", "csv"); + let distinct_output = temp_test_path("key-output-distinct", "csv"); + fs::write(&input_path, classic_pcap_bytes(&[])).expect("empty pcap written"); + fs::write(&key_path, b"secret-passphrase\n").expect("key written"); + fs::hard_link(&key_path, &alias_path).expect("hard link to key created"); + + for output_path in [&key_path, &alias_path] { + let output = Command::new(dpp_binary()) + .arg("--anonymize") + .arg(&key_path) + .arg(&input_path) + .arg(output_path) + .output() + .expect("dpp executed"); + assert!(!output.status.success(), "key alias must be rejected"); + assert!(String::from_utf8_lossy(&output.stderr).contains("refusing to overwrite the key")); + assert_eq!( + fs::read(&key_path).expect("key remains readable"), + b"secret-passphrase\n" + ); + } + + let output = Command::new(dpp_binary()) + .arg("--anonymize") + .arg(&key_path) + .arg(&input_path) + .arg(&distinct_output) + .output() + .expect("dpp executed with distinct output"); + assert!( + output.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(alias_path).expect("remove key alias"); + fs::remove_file(key_path).expect("remove key"); + fs::remove_file(distinct_output).expect("remove distinct output"); +} + +#[test] +fn thread_cap_changes_reported_execution_budget() { + let input_path = temp_test_path("thread-cap", "pcap"); + let output_path = temp_test_path("thread-cap", "csv"); + fs::write(&input_path, classic_pcap_bytes(&[])).expect("empty pcap written"); + + let output = Command::new(dpp_binary()) + .args(["--threads", "1", "--report-format", "json"]) + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed"); + assert!( + output.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("JSON report parses"); + assert_eq!(report["execution"]["available_cpus"], 1); + assert_eq!(report["execution"]["model"], "phase_parallel"); + assert_eq!(report["execution"]["rayon_threads"], 1); + + fs::remove_file(&input_path).expect("remove input pcap"); + fs::remove_file(&output_path).expect("remove output csv"); +} + #[test] fn oversized_wire_qname_is_rejected_and_reported() { let input_path = temp_test_path("oversized-wire-qname", "pcap"); From fc8bf88f6e97efaae39e95fc9b8379dfe5ac1881 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:39:14 +0300 Subject: [PATCH 13/28] Reduce inline DNS name size in matcher keys --- docs/rfc/0008-matcher-state-expiry.md | 8 ++++++++ src/custom_types.rs | 16 +++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/rfc/0008-matcher-state-expiry.md b/docs/rfc/0008-matcher-state-expiry.md index 4a927b8..0f2c56c 100644 --- a/docs/rfc/0008-matcher-state-expiry.md +++ b/docs/rfc/0008-matcher-state-expiry.md @@ -44,6 +44,14 @@ identity is approximately 312 bytes on the measured target. A safe eagerly cance index must either share that identity through another allocation or introduce stable handles and an arena/reverse index. +On 2026-09-26, the inline `DnsNameBuf` capacity was separately reduced from 255 to 64 bytes; +the size above describes the original experiment. In five alternating paired runs on one macOS +ARM64 host (`perf` profile, 16-CPU budget), the smaller layout improved processing speed by a +paired geometric mean of `1.095x` on an 18.0 M packet synthetic capture and `1.316x` on a 500,612 +query-only subset. Median peak child RSS fell from 307 to 187 MiB and from 407 to 234 MiB, +respectively. CSV output for the query-only subset was byte-identical. These measurements are +specific to the tested host and captures; they do not establish a gain for every QNAME distribution. + ## Decision Keep the current matcher state and eviction behavior. diff --git a/src/custom_types.rs b/src/custom_types.rs index 4cdb4e5..485c2fc 100644 --- a/src/custom_types.rs +++ b/src/custom_types.rs @@ -321,7 +321,7 @@ fn parse_response_code_text(value: &str) -> Option { } } -const DNS_NAME_INLINE_CAPACITY: usize = 255; +const DNS_NAME_INLINE_CAPACITY: usize = 64; /// Maximum escaped presentation length for a 255-octet wire name: 250 label bytes, each expanded /// to a four-byte octal escape, plus three label separators. pub const DNS_NAME_PRESENTATION_MAX_LENGTH: usize = 1003; @@ -333,8 +333,8 @@ struct InlineDnsName { } #[derive(Clone)] -// Boxing the inline variant would allocate for every common QNAME. Keep the large no-allocation -// hot path and spill only the rare presentation that exceeds 255 bytes. +// Keep common QNAMEs inline while limiting the size of matcher identity keys. Longer +// presentations spill without changing the 1003-byte validation limit. #[allow(clippy::large_enum_variant)] enum DnsNameStorage { Inline(InlineDnsName), @@ -695,7 +695,7 @@ mod tests { #[test] fn dns_name_buf_spills_presentation_larger_than_inline_capacity() { - let spilled = "a".repeat(256); + let spilled = "a".repeat(super::DNS_NAME_INLINE_CAPACITY + 1); let name = DnsNameBuf::new(&spilled).expect("spill presentation fits"); assert_eq!(name.as_str(), spilled); @@ -729,7 +729,13 @@ mod tests { #[test] fn dns_name_buf_keeps_common_names_inline_without_kilobyte_layout() { - assert!(size_of::() <= 272); + assert!(size_of::() <= 80); + let at_boundary = DnsNameBuf::new(&"a".repeat(super::DNS_NAME_INLINE_CAPACITY)) + .expect("inline presentation fits"); + assert!(matches!( + at_boundary.storage, + super::DnsNameStorage::Inline(_) + )); } #[test] From acfd18edec61500baf8acb032673c450e8bd4bca Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:49:11 +0300 Subject: [PATCH 14/28] Align example log with execution budget output --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b2e089b..dbc943b 100644 --- a/README.md +++ b/README.md @@ -428,7 +428,7 @@ $ DPP_FILENAME=server1_jul_2024.pcap DPP_FORMAT=csv target/release/dpp --dns-wir 04:15:01.076 INFO Monotonic capture mode: enabled (batched timeout eviction active; timestamp regressions abort the run) 04:15:01.076 INFO PID: 501755 04:15:01.147 INFO Results will be written to: /mnt/mirror/src/dpp/dns_output.csv -04:15:01.150 INFO Execution budget: auto using 4 CPUs, phase-parallel pipeline selected for low-core host, Rayon worker budget: 4 +04:15:01.150 INFO Execution budget: 4 CPUs, phase-parallel pipeline selected for low-core budget, Rayon worker budget: 4 04:15:16.718 INFO Total packets processed: 40,000,000 04:15:16.718 INFO Total DNS queries processed: 19,670,037 04:15:16.718 INFO Deduplicated duplicate queries: 23,947 From bc512eef64248f8cae21736b2b1b8fd938916b94 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sat, 26 Sep 2026 23:50:23 +0300 Subject: [PATCH 15/28] Document IPv4 ID reuse ambiguity for non-DNS fragments --- README.md | 2 +- docs/architecture.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dbc943b..03b4f76 100644 --- a/README.md +++ b/README.md @@ -538,7 +538,7 @@ Additional notes: sets a lower ceiling. The pipeline model is still selected automatically from that budget. - The parser fast path is opt-in through `--dns-wire-fast-path` or `DPP_DNS_WIRE_FAST_PATH=1`; without that flag DPP uses the legacy `hickory` question decoder. - `--allow-fragments` or `DPP_ALLOW_FRAGMENTS=1` optionally pairs a query with the observable first IPv4 fragment of a response when its DNS header and complete question are present. `response_code` is populated when there are no additional records or every declared DNS record fits in the first fragment; otherwise it is empty. The JSON metric `fragmented_response_prefix_count` (text: `Accepted first IPv4 response fragments`) counts accepted prefixes, including ones without a matching query. -- `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. A bounded history of completed datagrams compares the fragment key and UDP payload bytes to suppress repeated complete datagrams and prefix fallback when all observed fragments match a recent completion. A different datagram can still use the same IPv4 ID, including one with an identical first fragment but different later bytes. A fully identical new datagram, or an incomplete one whose observed fragments match a recent completion, cannot be distinguished from a duplicate and may be suppressed. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. +- `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. A bounded history of completed datagrams compares the fragment key and UDP payload bytes to suppress repeated complete datagrams and prefix fallback when all observed fragments match a recent completion. A different datagram can still use the same IPv4 ID, including one with an identical first fragment but different later bytes. A fully identical new datagram, or an incomplete one whose observed fragments match a recent completion, cannot be distinguished from a duplicate and may be suppressed. A bounded non-DNS history drops later tails after a non-DNS first fragment; if a new DNS datagram reuses the same IPv4 ID and its tail arrives before its first fragment while that history is active, the tail is indistinguishable and may be dropped. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. - For captures normalized with `reordercap`, `--monotonic-capture` can reduce in-flight matcher state by enabling batched timeout eviction. If a timestamp regression is detected, DPP fails the run instead of silently weakening matching semantics. - Global allocator choice is a build-time concern. See [docs/allocator-guide.md](docs/allocator-guide.md) for the supported allocator matrix and [benches/allocator-benchmarking.md](benches/allocator-benchmarking.md) for the comparison protocol. diff --git a/docs/architecture.md b/docs/architecture.md index 0564c1d..4bfddac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -192,7 +192,9 @@ or second encoding of canonical addresses and ports. A first non-DNS UDP fragment removes any earlier tails for its datagram key. A separate bounded history drops later non-DNS tails without evicting the completed-DNS history; a new DNS first fragment with a reused IPv4 ID removes that non-DNS mark. Ethernet VLAN tags are part of the - fragment key so traffic from different tagged segments is not assembled together. + fragment key so traffic from different tagged segments is not assembled together. A new DNS tail + that arrives before its first fragment while the old non-DNS mark is active is indistinguishable + from an old non-DNS tail and may be dropped. The optional runtime flag `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains the semantic fallback for rare DNS messages that the fast path does not accept. Compression From 5afce0baa8ae5cf92df4d758444fbe528a15f501 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 06:54:04 +0300 Subject: [PATCH 16/28] Reject retained fragment tails outside the UDP boundary --- docs/architecture.md | 2 ++ src/dns_processor/reassembly.rs | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 4bfddac..4f24d4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,6 +189,8 @@ or second encoding of canonical addresses and ports. duplicate and may be suppressed; the same holds for a fully identical new datagram. Reassembly state must remain bounded in memory, and IPv6 fragments remain unsupported. A fragmented UDP datagram must have an IP payload length equal to its UDP Length; mismatched fragment sets are rejected. + When the first fragment arrives after tails, its UDP Length also bounds every retained tail; + a nonfinal fragment cannot reach or exceed that boundary, regardless of arrival order. A first non-DNS UDP fragment removes any earlier tails for its datagram key. A separate bounded history drops later non-DNS tails without evicting the completed-DNS history; a new DNS first fragment with a reused IPv4 ID removes that non-DNS mark. Ethernet VLAN tags are part of the diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index 83e952d..7d17e23 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -208,6 +208,13 @@ impl PendingDatagram { || self .final_payload_length .is_some_and(|last| last != udp_length) + || self.segments.last_key_value().is_some_and(|(offset, segment)| { + // Disjoint intervals have increasing ends, so the last retained + // tail is enough to validate the newly known UDP boundary. + let segment_end = offset + segment.payload.len(); + segment_end > udp_length + || (segment.more_fragments && segment_end == udp_length) + }) { return Err(()); } @@ -1268,6 +1275,37 @@ mod tests { assert!(reassembler.finish().is_empty()); } + #[test] + fn nonfinal_payload_at_or_beyond_udp_length_is_rejected_in_both_orders() { + // The fixture declares UDP Length=40; these eight-byte tails end at 40 and 48. + for offset in [32_u16, 40] { + for tail_first in [false, true] { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let (first_ordinal, tail_ordinal) = if tail_first { (2, 1) } else { (1, 2) }; + let mut tail = packet(24, false, tail_ordinal, 99 + tail_ordinal as i64); + let mut bytes = tail.data.as_slice().to_vec(); + bytes.truncate(ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + 8); + bytes[16..18].copy_from_slice(&(IPV4_MIN_HEADER_LEN as u16 + 8).to_be_bytes()); + bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | offset / 8).to_be_bytes()); + tail.data = PacketPayload::owned(bytes.into_boxed_slice()); + let first = packet(0, true, first_ordinal, 99 + first_ordinal as i64); + let input = if tail_first { + vec![tail, first] + } else { + vec![first, tail] + }; + + let (output, pending) = reassembler.process_batch(input); + assert!(output.is_empty(), "offset={offset}, tail_first={tail_first}"); + assert_eq!(pending, None, "offset={offset}, tail_first={tail_first}"); + assert!( + reassembler.finish().is_empty(), + "invalid tails must not produce prefix fallback: offset={offset}, tail_first={tail_first}" + ); + } + } + } + #[test] fn payload_beyond_final_fragment_is_rejected() { let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); From a843e31e20488e91b1dcfa2a96307abf408d5a00 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 06:54:23 +0300 Subject: [PATCH 17/28] Accept valid long backward DNS compression chains --- docs/architecture.md | 4 ++- src/dns_processor/parser.rs | 68 ++++++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4f24d4a..167ca2c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -201,7 +201,9 @@ or second encoding of canonical addresses and ports. `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains the semantic fallback for rare DNS messages that the fast path does not accept. Compression pointers must target prior, nonoverlapping names; enabling the fast path must not weaken that - validation. For ordinary QUERY messages (OPCODE 0), more than one question is rejected under + validation. Both wire-name walkers bound traversal by strictly decreasing pointer targets, + allowing valid chains longer than 32 pointers while rejecting cycles and overlaps. + For ordinary QUERY messages (OPCODE 0), more than one question is rejected under RFC 9619. A complete response cannot claim answer or authority records that are absent from its DNS payload, whether or not it has additional records. TSIG status extraction consumes and bounds-checks the declared Other Data, including the six-byte server time in BADTIME responses. diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index b0dab4e..9898af6 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -45,7 +45,6 @@ const DNS_PORT: u16 = 53; const DNS_POINTER_MASK: u8 = 0b1100_0000; const DNS_POINTER_TAG: u8 = 0b1100_0000; const DNS_LABEL_LEN_MASK: u8 = 0b0011_1111; -const DNS_COMPRESSION_JUMP_LIMIT: usize = 32; const DNS_RESOURCE_RECORD_FIXED_LEN: usize = 10; const DNS_OPT_RECORD_TYPE: u16 = 41; const DNS_TSIG_RECORD_TYPE: u16 = 250; @@ -895,7 +894,6 @@ impl DnsProcessor { let start = *cursor; let mut position = *cursor; let mut resume_position = None; - let mut jump_count = 0; let mut segment_start = position; let mut segment_end = dns_data.len(); @@ -927,11 +925,8 @@ impl DnsProcessor { resume_position = Some(position + 2); } - jump_count += 1; - if jump_count > DNS_COMPRESSION_JUMP_LIMIT { - return Err("DNS compression pointer loop"); - } - + // Every pointer strictly decreases this boundary, so traversal is + // finite without rejecting valid chains after an arbitrary hop count. segment_end = segment_start; segment_start = offset; position = offset; @@ -959,7 +954,6 @@ impl DnsProcessor { let mut position = *cursor; let mut resume_position = None; let mut wrote_label = false; - let mut jump_count = 0; let mut expanded_wire_len = 1_usize; let mut oversized = false; let mut segment_start = position; @@ -1004,11 +998,7 @@ impl DnsProcessor { resume_position = Some(position + 2); } - jump_count += 1; - if jump_count > DNS_COMPRESSION_JUMP_LIMIT { - return Err(DnsQuestionDecodeError::Invalid); - } - + // Strictly decreasing segment starts also bound pointer-only chains. segment_end = segment_start; segment_start = offset; position = offset; @@ -1732,6 +1722,7 @@ mod protocol_regression_tests { fn compression_rejects_overlapping_names_but_accepts_prior_questions() { let overlap = [4, b'a', b'b', 0xc0, 0, 0]; assert!(DnsProcessor::read_wire_domain_name(&overlap, &mut 3).is_err()); + assert!(DnsProcessor::skip_wire_domain_name(&overlap, &mut 3).is_err()); assert!(Name::read(&mut BinDecoder::new(&overlap).clone(3)).is_err()); let mut dns = question(0x0900, 1); @@ -1746,6 +1737,57 @@ mod protocol_regression_tests { } } + #[test] + fn response_accepts_long_backward_compression_chains() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&33_u16.to_be_bytes()); + let mut previous_owner = DNS_HEADER_LEN; + let mut last_owner = 0; + for index in 0..33_u8 { + last_owner = dns.len(); + dns.extend_from_slice(&(0xc000 | previous_owner as u16).to_be_bytes()); + // Distinct TXT records keep the complete response below 512 bytes. + dns.extend_from_slice(&[0, 16, 0, 1, 0, 0, 0, 60, 0, 2, 1, b'a' + index]); + previous_owner = last_owner; + } + assert_eq!(Message::from_vec(&dns).unwrap().answers.len(), 33); + + for processor in processors() { + let records = processor + .process_packet_batch(&ipv4(&dns, true, 53000), 100) + .expect("valid response with 33 backward pointers"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].response_code.as_u16(), 0); + } + assert_eq!( + DnsProcessor::read_wire_domain_name(&dns, &mut last_owner) + .unwrap() + .as_str(), + "example.com" + ); + } + + #[test] + fn compression_walkers_reject_cycles_and_truncation() { + for (case, bytes, offset) in [ + ("self pointer", &[0xc0, 0][..], 0), + ("pointer cycle", &[0xc0, 2, 0xc0, 0][..], 2), + ("truncated pointer", &[0xc0][..], 0), + ("truncated label", &[2, b'a'][..], 0), + ] { + let mut read_cursor = offset; + assert!( + DnsProcessor::read_wire_domain_name(bytes, &mut read_cursor).is_err(), + "{case}" + ); + let mut skip_cursor = offset; + assert!( + DnsProcessor::skip_wire_domain_name(bytes, &mut skip_cursor).is_err(), + "{case}" + ); + } + } + #[test] fn ipv6_walks_extension_chains_and_atomic_fragments() { let hbh = (IPV6_HOP_BY_HOP, vec![0; 8]); From 761b4b1493c97aa572f946cb8d60687bfd22acf7 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 06:55:09 +0300 Subject: [PATCH 18/28] Expire IPv4 fragment state only for monotonic captures --- docs/architecture.md | 4 ++- src/dns_processor/reassembly.rs | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 167ca2c..b29a926 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,7 +179,9 @@ or second encoding of canonical addresses and ports. existing UDP/DNS validation path only after the first and last fragments establish its bounds and every byte is present. Incomplete responses may use the first-fragment inference on capacity eviction or end of input; with monotonic capture, entries can also expire after the match - timeout. Fragmented queries require a complete datagram. A complete datagram + timeout. Without monotonic capture, timestamp gaps between fragments of the same datagram + do not expire it; capacity limits and end of input still bound its lifetime. + Fragmented queries require a complete datagram. A complete datagram uses the final fragment's (`MF=0`) capture timestamp, while inferred responses use the first fragment's timestamp. The reassembler retains a bounded history of completed datagrams and compares the fragment key and UDP payload bytes before suppressing repeated complete datagrams diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index 7d17e23..887842e 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -709,6 +709,9 @@ impl Ipv4FragmentReassembler { } fn expire_key_at(&mut self, key: &FragmentKey, timestamp: i64, output: &mut PacketBatch) { + if !self.monotonic_capture { + return; + } let expired = self.pending.get(key).is_some_and(|entry| { timestamp.saturating_sub(entry.first_seen_timestamp) > self.timeout_micros }); @@ -1223,6 +1226,47 @@ mod tests { assert!(reassembler.finish().is_empty()); } + #[test] + fn fragment_expiration_requires_monotonic_capture() { + for monotonic_capture in [false, true] { + for final_first in [false, true] { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, monotonic_capture); + let (first_ordinal, final_ordinal) = if final_first { (2, 1) } else { (1, 2) }; + let first_timestamp = 1_100_000 + (first_ordinal as i64 - 1) * 1_300_000; + let final_timestamp = 1_100_000 + (final_ordinal as i64 - 1) * 1_300_000; + let first = packet(0, true, first_ordinal, first_timestamp); + let final_fragment = packet(24, false, final_ordinal, final_timestamp); + let input = if final_first { + vec![final_fragment, first] + } else { + vec![first, final_fragment] + }; + + let (mut output, pending) = reassembler.process_batch(input); + assert_eq!( + pending, + monotonic_capture.then_some(2_400_000), + "monotonic_capture={monotonic_capture}, final_first={final_first}" + ); + output.extend(reassembler.finish()); + assert_eq!(output.len(), 1); + if monotonic_capture { + // Expiration retains only the first-fragment fallback in either order. + assert_eq!(output[0].timestamp_micros, first_timestamp); + assert_eq!(&output[0].data[20..22], &MORE_FRAGMENTS.to_be_bytes()); + } else { + assert_eq!(output[0].timestamp_micros, final_timestamp); + assert_eq!(output[0].packet_ordinal, final_ordinal); + assert_eq!(&output[0].data[20..22], &[0, 0]); + assert_eq!( + output[0].data.len(), + ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + 40 + ); + } + } + } + } + #[test] fn timestamp_regression_does_not_expire_adjacent_fragments() { let mut reassembler = Ipv4FragmentReassembler::new(100, false); From cf9d53c7f45549c2276a795e428035e382341400 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 07:02:40 +0300 Subject: [PATCH 19/28] Preserve DNS event order across IPv4 reassembly batches --- README.md | 4 + docs/architecture.md | 11 +- docs/rfc/0004-forward-only-matcher.md | 14 ++ src/dns_processor/pipeline.rs | 347 +++++++++++++++++++++++--- 4 files changed, 338 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 03b4f76..cb2a6d0 100644 --- a/README.md +++ b/README.md @@ -553,6 +553,10 @@ Additional notes: heuristic for incomplete sets. Neither mode reassembles IPv6 fragments. IPv6 Hop-by-Hop, Routing, Destination Options, Authentication and atomic Fragment headers are traversed before UDP; ESP and IPv6 jumbograms are unsupported. Flow identity uses observed IP endpoints. +- Full IPv4 reassembly holds ready packet batches until unresolved fragments are completed or + released, preserving retry and response ordering across batches. This adds a retained backlog + of up to 65,536 packets or 64 MiB of packet payloads; exceeding either bound triggers the + incomplete-fragment capacity fallback. The current input batch adds transient memory on top. - If capture parsing fails after processing begins, DPP flushes valid partial (not atomic) output from complete accepted batches and exits with an error; pending queries are not emitted as timeouts. - **PCAPNG support level:** DPP supports PCAPNG on stream input and via `libpcap` on regular-file fallback paths, but the performance-critical pure-Rust fast path remains focused on classic PCAP. Stdin PCAPNG blocks larger than 16 MiB are rejected before their body is read. diff --git a/docs/architecture.md b/docs/architecture.md index b29a926..e29a06a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,16 @@ or second encoding of canonical addresses and ports. do not expire it; capacity limits and end of input still bound its lifetime. Fragmented queries require a complete datagram. A complete datagram uses the final fragment's (`MF=0`) capture timestamp, while inferred responses use the first - fragment's timestamp. The reassembler retains a bounded history of completed datagrams and + fragment's timestamp. While any datagram is unresolved, routing retains its ready packet + batches so a delayed reconstruction cannot arrive after a retry or response that it should + precede. Released packets are sorted together by timestamp and capture ordinal before matching. + The eviction watermark cannot advance beyond either unresolved fragments or retained ready + packets. The additional ready backlog is bounded between batches to 65,536 packets or 64 MiB + of packet payloads; exceeding either bound resolves pending datagrams through the existing + capacity fallback before releasing the backlog. The current input batch and fallback packets + can temporarily exceed that retained-state bound. This buffering is owned by the routing stage + and contains no query/response pairing state. + The reassembler retains a bounded history of completed datagrams and compares the fragment key and UDP payload bytes before suppressing repeated complete datagrams or prefix fallback from fragments matching a recent completion. A reused IPv4 ID with different content can still produce a new datagram, even when its first fragment is identical. An incomplete diff --git a/docs/rfc/0004-forward-only-matcher.md b/docs/rfc/0004-forward-only-matcher.md index 7d73d4a..7ce7251 100644 --- a/docs/rfc/0004-forward-only-matcher.md +++ b/docs/rfc/0004-forward-only-matcher.md @@ -30,6 +30,20 @@ order within each shard and never backtracks. The key design choices: `(timestamp, packet_ordinal, record_ordinal)` order. Tie-breaks are explicit — scheduler interleaving and container iteration order are not valid tie-breaks. + With full IPv4 reassembly, routing retains ready packet batches while fragment sets remain + unresolved. A reconstructed datagram can carry an earlier final-fragment timestamp, so merely + limiting timeout eviction would still let a retry or response finalize before that datagram. + Routing releases the retained packets and reconstructed datagrams together for the existing + timestamp/ordinal sort. This also preserves ordering within a batch whose timestamps regress + in default mode; it does not impose global monotonicity on that mode. + + This packet backlog is separate from matcher state and bounded between batches to one packet + batch (65,536 packets) or 64 MiB of payloads. On overflow, pending fragment sets use the existing + capacity fallback before routing releases the backlog. Processing the current input batch and + fallback packets can temporarily exceed those retained-state limits. EOF and interrupted + intake drain accepted ready packets through the existing shutdown policy. The matcher eviction + watermark is capped by both the oldest unresolved fragment and the oldest retained ready packet. + 4. **Retry deduplication.** If a query with the same identity arrives while an earlier one is still pending inside the match-timeout window (1200 ms by default), the duplicate is counted but doesn't create a second canonical query. One canonical query → one terminal outcome diff --git a/src/dns_processor/pipeline.rs b/src/dns_processor/pipeline.rs index bc02333..2883b56 100644 --- a/src/dns_processor/pipeline.rs +++ b/src/dns_processor/pipeline.rs @@ -31,6 +31,10 @@ const BATCH_PREFETCH_DEPTH: usize = 2; const MATCHER_WORKER_QUEUE_DEPTH: usize = 2; const AGGREGATOR_REORDER_BUFFER_CAPACITY: usize = BATCH_PREFETCH_DEPTH + MATCHER_WORKER_QUEUE_DEPTH + 2; +/// Ready packets may wait behind an unresolved fragment for at most this backlog. +/// Reaching either bound releases incomplete datagrams using the capacity fallback. +const FRAGMENT_READY_PACKET_LIMIT: usize = PACKET_BATCH_SIZE; +const FRAGMENT_READY_BYTE_LIMIT: usize = 64 * 1024 * 1024; struct WorkerShutdownSignals { shutdown_requested: Arc, @@ -587,21 +591,67 @@ fn route_batch_to_worker_batches( } } -fn route_batch_with_reassembly( - packet_batch: PacketBatch, - routing_plan: &ShardRoutingPlan, - reassembler: &mut Ipv4FragmentReassembler, -) -> RoutedWorkerBatches { - let input_max_timestamp_micros = packet_batch - .iter() - .map(|packet| packet.timestamp_micros) - .max(); - let (packets, oldest_pending_timestamp_micros) = reassembler.process_batch(packet_batch); - let mut routed = route_batch_to_worker_batches(packets, routing_plan, true); - routed.batch_max_timestamp_micros = input_max_timestamp_micros.map(|timestamp| { - oldest_pending_timestamp_micros.map_or(timestamp, |pending| timestamp.min(pending)) - }); - routed +/// Owns only packet-stage backlog; fragment and matcher state keep their existing owners. +struct FragmentRoutingState { + reassembler: Ipv4FragmentReassembler, + ready: PacketBatch, +} + +impl FragmentRoutingState { + fn new(match_timeout_micros: i64, monotonic_capture: bool) -> Self { + Self { + reassembler: Ipv4FragmentReassembler::new(match_timeout_micros, monotonic_capture), + ready: Vec::new(), + } + } + + fn route_batch( + &mut self, + packet_batch: PacketBatch, + routing_plan: &ShardRoutingPlan, + ) -> RoutedWorkerBatches { + let input_max_timestamp_micros = packet_batch + .iter() + .map(|packet| packet.timestamp_micros) + .max(); + let (packets, mut oldest_pending_timestamp_micros) = + self.reassembler.process_batch(packet_batch); + self.ready.extend(packets); + + // A final fragment may precede other parts, so its eventual datagram can + // have an earlier event than a retry already present in `ready`. Hold the + // entire batch until the unresolved fragments have been decided, then + // apply the normal timestamp/ordinal sort to all ready packets together. + // An ordinal-only prefix is unsafe when timestamps regress within a batch. + let ready_bytes: usize = self.ready.iter().map(|packet| packet.data.len()).sum(); + if self.ready.len() > FRAGMENT_READY_PACKET_LIMIT || ready_bytes > FRAGMENT_READY_BYTE_LIMIT + { + self.ready.extend(self.reassembler.finish()); + oldest_pending_timestamp_micros = None; + } + let packets = if oldest_pending_timestamp_micros.is_some() { + Vec::new() + } else { + std::mem::take(&mut self.ready) + }; + let mut routed = route_batch_to_worker_batches(packets, routing_plan, true); + routed.batch_max_timestamp_micros = input_max_timestamp_micros.map(|timestamp| { + let pending_frontier = + oldest_pending_timestamp_micros.map_or(timestamp, |pending| timestamp.min(pending)); + self.ready + .iter() + .map(|packet| packet.timestamp_micros) + .min() + .map_or(pending_frontier, |ready| pending_frontier.min(ready)) + }); + routed + } + + fn finish(&mut self) -> PacketBatch { + self.ready.extend(self.reassembler.finish()); + // Accepted packets must drain even on interrupted or failed intake. + std::mem::take(&mut self.ready) + } } fn parse_shard_packets( @@ -654,8 +704,8 @@ fn run_phase_processing_worker( vec![MatcherShardState::default()] }; let routing_plan = ShardRoutingPlan::new(shard_count, shard_count); - let mut reassembler = dns_processor.full_fragments.then(|| { - Ipv4FragmentReassembler::new( + let mut fragment_routing = dns_processor.full_fragments.then(|| { + FragmentRoutingState::new( dns_processor.match_timeout_micros, dns_processor.monotonic_capture, ) @@ -671,8 +721,8 @@ fn run_phase_processing_worker( let routed = match received { Ok(packet_batch) => { - if let Some(reassembler) = reassembler.as_mut() { - route_batch_with_reassembly(packet_batch, &routing_plan, reassembler) + if let Some(fragment_routing) = fragment_routing.as_mut() { + fragment_routing.route_batch(packet_batch, &routing_plan) } else { route_batch_to_worker_batches( packet_batch, @@ -682,10 +732,10 @@ fn run_phase_processing_worker( } } Err(_) => { - let Some(reassembler) = reassembler.as_mut() else { + let Some(fragment_routing) = fragment_routing.as_mut() else { break; }; - let remaining = reassembler.finish(); + let remaining = fragment_routing.finish(); if remaining.is_empty() { break; } @@ -762,8 +812,8 @@ fn run_parser_stage( monotonic_capture, } = fragment_config; let mut batch_seq = 0_u64; - let mut reassembler = full_fragments - .then(|| Ipv4FragmentReassembler::new(match_timeout_micros, monotonic_capture)); + let mut fragment_routing = + full_fragments.then(|| FragmentRoutingState::new(match_timeout_micros, monotonic_capture)); loop { let received = batch_rx.recv(); @@ -774,17 +824,17 @@ fn run_parser_stage( let routed = match received { Ok(packet_batch) => { - if let Some(reassembler) = reassembler.as_mut() { - route_batch_with_reassembly(packet_batch, &routing_plan, reassembler) + if let Some(fragment_routing) = fragment_routing.as_mut() { + fragment_routing.route_batch(packet_batch, &routing_plan) } else { route_batch_to_worker_batches(packet_batch, &routing_plan, allow_fragments) } } Err(_) => { - let Some(reassembler) = reassembler.as_mut() else { + let Some(fragment_routing) = fragment_routing.as_mut() else { break; }; - let remaining = reassembler.finish(); + let remaining = fragment_routing.finish(); if remaining.is_empty() { break; } @@ -1246,7 +1296,7 @@ impl DnsProcessor { mod tests { use super::*; use crate::config::{InputSource, OUTPUT_RECORD_BATCH_SIZE}; - use crate::packet_parser::PacketParser; + use crate::packet_parser::{PacketParser, PacketPayload}; use crate::test_support::{ classic_pcap_bytes, encode_dns_header, make_udp_dns_packet, make_udp_dns_packet_with_payload, temp_test_path, test_dns_record, @@ -1263,6 +1313,233 @@ mod tests { } } + fn fragment_ordering_query_response() -> (Vec, Vec) { + let mut payload = encode_dns_header(0x3456, 0x0100, 1); + payload.extend_from_slice(b"\x07example\x03com\0\0\x01\0\x01"); + let query = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &payload); + payload[2..4].copy_from_slice(&0x8180_u16.to_be_bytes()); + let response = + make_udp_dns_packet_with_payload([8, 8, 8, 8], [10, 0, 0, 1], 53, 53_000, &payload); + (query, response) + } + + fn split_ipv4_test_packet(packet: &[u8]) -> (Vec, Vec) { + let first_end = 14 + 20 + 32; + let mut first = packet[..first_end].to_vec(); + first[16..18].copy_from_slice(&52_u16.to_be_bytes()); + first[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + first[20..22].copy_from_slice(&0x2000_u16.to_be_bytes()); + let mut tail = packet[..34].to_vec(); + tail[16..18].copy_from_slice(&(20_u16 + (packet.len() - first_end) as u16).to_be_bytes()); + tail[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + tail[20..22].copy_from_slice(&4_u16.to_be_bytes()); + tail.extend_from_slice(&packet[first_end..]); + (first, tail) + } + + fn test_packet(bytes: &[u8], timestamp_micros: i64, packet_ordinal: u64) -> PacketData { + PacketData { + data: PacketPayload::owned(bytes.into()), + timestamp_micros, + packet_ordinal, + } + } + + fn run_fragment_test_batches( + batches: Vec, + staged: bool, + monotonic: bool, + signals: WorkerShutdownSignals, + ) -> (PipelineCounters, Vec) { + let processor = Arc::new( + DnsProcessor::new_with_runtime_options(None, false, 1_200_000, monotonic) + .expect("processor initializes") + .with_full_fragments(true), + ); + let (batch_tx, batch_rx) = crossbeam::channel::unbounded(); + for batch in batches { + batch_tx.send(batch).expect("test batch sends"); + } + drop(batch_tx); + let (output_tx, output_rx) = crossbeam::channel::unbounded(); + let counters = if staged { + run_staged_processing_pipeline( + processor, + batch_rx, + output_tx, + 4, + 2, + AffinityPlan::disabled(), + signals, + ) + } else { + run_phase_processing_worker(processor, batch_rx, output_tx, 1, false, signals) + } + .expect("fragment pipeline completes"); + let records = output_rx + .into_iter() + .flat_map(|message| match message { + OutputMessage::Records(records) => records, + other => panic!("unexpected output message: {other:?}"), + }) + .collect(); + (counters, records) + } + + #[test] + fn full_fragments_preserve_delayed_query_order_in_both_pipeline_models() { + let (query, response) = fragment_ordering_query_response(); + let (first, tail) = split_ipv4_test_packet(&query); + for staged in [false, true] { + for monotonic in [false, true] { + for with_response in [false, true] { + let mut initial = vec![ + test_packet(&tail, 1_000_000, 0), + test_packet(&query, 1_100_000, 1), + ]; + if with_response { + initial.push(test_packet(&response, 1_120_000, 2)); + } + let (counters, records) = run_fragment_test_batches( + vec![initial, vec![test_packet(&first, 1_200_000, 3)]], + staged, + monotonic, + test_worker_signals(false), + ); + assert_eq!(counters.dns_query_count, 2); + assert_eq!(counters.duplicated_query_count, 1); + assert_eq!( + counters.matched_query_response_count, + usize::from(with_response) + ); + assert_eq!(counters.timeout_query_count, usize::from(!with_response)); + assert_eq!(records.len(), 1); + assert_eq!(records[0].request_timestamp, 1_000_000); + assert_eq!( + records[0].response_timestamp, + with_response.then_some(1_120_000) + ); + assert_eq!( + counters.matched_rtt_sum_micros, + if with_response { 120_000 } else { 0 } + ); + } + } + } + } + + #[test] + fn full_fragments_preserve_default_intra_batch_timestamp_order() { + let (query, response) = fragment_ordering_query_response(); + let (first, tail) = split_ipv4_test_packet(&response); + for staged in [false, true] { + let (counters, records) = run_fragment_test_batches( + vec![ + vec![ + test_packet(&query, 1_000_000, 0), + test_packet(&query, 1_300_000, 1), + test_packet(&tail, 1_200_000, 2), + ], + vec![test_packet(&first, 1_400_000, 3)], + ], + staged, + false, + test_worker_signals(false), + ); + assert_eq!(counters.duplicated_query_count, 0); + assert_eq!(counters.matched_query_response_count, 1); + assert_eq!(counters.timeout_query_count, 1); + assert_eq!(records.len(), 2); + assert_eq!(records[0].request_timestamp, 1_000_000); + assert_eq!(records[0].response_timestamp, Some(1_200_000)); + assert_eq!(records[1].request_timestamp, 1_300_000); + assert_eq!(records[1].response_timestamp, None); + } + } + + #[test] + fn full_fragments_hold_watermark_for_buffered_response() { + let (query, response) = fragment_ordering_query_response(); + let (_, unrelated_tail) = split_ipv4_test_packet(&query); + for staged in [false, true] { + let (counters, records) = run_fragment_test_batches( + vec![ + vec![test_packet(&query, 0, 0)], + vec![ + test_packet(&response, 1_100_000, 1), + test_packet(&unrelated_tail, 1_500_000, 2), + ], + ], + staged, + true, + test_worker_signals(false), + ); + assert_eq!(counters.matched_query_response_count, 1); + assert_eq!(counters.timeout_query_count, 0); + assert_eq!(records.len(), 1); + assert_eq!(records[0].response_timestamp, Some(1_100_000)); + } + } + + #[test] + fn full_fragments_release_backlog_at_both_capacity_bounds() { + let (query, _) = fragment_ordering_query_response(); + let (_, tail) = split_ipv4_test_packet(&query); + for byte_bound in [false, true] { + let mut routing = FragmentRoutingState::new(1_200_000, false); + let plan = ShardRoutingPlan::new(1, 1); + let blocked = routing.route_batch( + vec![test_packet(&tail, 0, 0), test_packet(&query, 1, 1)], + &plan, + ); + assert!(blocked.worker_batches[0][0].packets.is_empty()); + let ordinary = if byte_bound { + vec![test_packet(&vec![0; FRAGMENT_READY_BYTE_LIMIT + 1], 2, 2)] + } else { + (0..=FRAGMENT_READY_PACKET_LIMIT) + .map(|ordinal| test_packet(&[], 2, ordinal as u64 + 2)) + .collect() + }; + let routed = routing.route_batch(ordinary, &plan); + assert!(routing.ready.is_empty()); + assert!(routing.reassembler.finish().is_empty()); + assert_eq!(routed.worker_batches[0][0].packets.len(), 1); + assert_eq!(routed.worker_batches[0][0].packets[0].packet_ordinal, 1); + } + } + + #[test] + fn full_fragments_drain_accepted_backlog_after_signal_or_intake_error() { + let (query, response) = fragment_ordering_query_response(); + let (_, tail) = split_ipv4_test_packet(&query); + for staged in [false, true] { + for signal in [false, true] { + let signals = test_worker_signals(signal); + signals.intake_failed.store(!signal, AtomicOrdering::SeqCst); + let mut unmatched = query.clone(); + unmatched[42..44].copy_from_slice(&0x9876_u16.to_be_bytes()); + let (counters, records) = run_fragment_test_batches( + vec![vec![ + test_packet(&tail, 1_000_000, 0), + test_packet(&query, 1_100_000, 1), + test_packet(&response, 1_200_000, 2), + test_packet(&unmatched, 1_300_000, 3), + ]], + staged, + true, + signals, + ); + assert_eq!(counters.dns_query_count, 2); + assert_eq!(counters.matched_query_response_count, 1); + assert_eq!(counters.timeout_query_count, 0); + assert_eq!(records.len(), 1); + assert_eq!(records[0].request_timestamp, 1_100_000); + assert_eq!(records[0].response_timestamp, Some(1_200_000)); + } + } + } + fn shard_result(token: usize) -> ShardProcessingResult { ShardProcessingResult { dns_query_count: token, @@ -1825,21 +2102,17 @@ mod tests { packets.push((1, 200_000, final_fragment.as_slice())); fs::write(&path, classic_pcap_bytes(&packets)).expect("test pcap written"); - // A pending first fragment holds the monotonic matcher watermark at - // its timestamp even when later unrelated packets fill the batch. + // The unresolved response holds this entire batch. Its earlier buffered + // query must also cap the matcher watermark until both are released. let mut cap_parser = PacketParser::new(&InputSource::File(path.clone()), false).expect("parser opens"); let first_batch = cap_parser .next_batch(PACKET_BATCH_SIZE) .expect("batch reads") .expect("first batch exists"); - let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); - let routed = route_batch_with_reassembly( - first_batch, - &ShardRoutingPlan::new(1, 1), - &mut reassembler, - ); - assert_eq!(routed.batch_max_timestamp_micros, Some(1_100_000)); + let mut fragment_routing = FragmentRoutingState::new(1_200_000, true); + let routed = fragment_routing.route_batch(first_batch, &ShardRoutingPlan::new(1, 1)); + assert_eq!(routed.batch_max_timestamp_micros, Some(1_000_000)); for available_cpus in [1, 5] { let mut parser = From 4a71cea57a60273492ba88a9e6fffac5f93d7cb2 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 07:03:33 +0300 Subject: [PATCH 20/28] Keep DNS transactions isolated by canonical VLAN context --- README.md | 4 +- docs/architecture.md | 17 ++- docs/encapsulation-playbook.md | 3 + docs/rfc/0004-forward-only-matcher.md | 11 +- src/dns_processor/matcher.rs | 3 + src/dns_processor/parser.rs | 56 +++++--- src/dns_processor/reassembly.rs | 72 +++++++---- src/dns_processor/tests.rs | 18 ++- src/dns_processor/types.rs | 23 +++- tests/vlan_isolation.rs | 180 ++++++++++++++++++++++++++ 10 files changed, 325 insertions(+), 62 deletions(-) create mode 100644 tests/vlan_isolation.rs diff --git a/README.md b/README.md index cb2a6d0..4dd87bf 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,9 @@ match window. Finalized transactions are never reopened. With `--monotonic-captu history is needed or allocated. DNS QR determines query/response direction, including valid exchanges with UDP port 53 on both -endpoints. QCLASS and OPCODE distinguish transactions internally and do not add output columns. +endpoints. QCLASS, OPCODE and the full VLAN/QinQ tag stack distinguish transactions internally +and do not add output columns. VLAN identity includes each tag's TPID and VLAN ID; changes to +priority (PCP) or drop eligibility (DEI) do not prevent matching within the same tagged segment. Ordinary DNS QUERY messages with more than one question are rejected under RFC 9619. Responses whose declared answer or authority records are truncated are also rejected before matching. diff --git a/docs/architecture.md b/docs/architecture.md index e29a06a..6077544 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,6 +83,13 @@ and uses the remaining CPU budget for shard workers. Routed DNS packets also car metadata into shard workers so the worker path can reuse the first L3/L4 parse instead of repeating it for full DNS question decoding. +Ethernet decoding also produces one canonical VLAN context: the ordered stack of tag TPIDs and +12-bit VLAN IDs. PCP and DEI are QoS metadata and do not distinguish transactions. This context is +shared by fragment keys and matcher identities, keeping overlapping IP endpoints in different +VLANs separate. Routing still uses the coarser client/resolver endpoint tuple, so different VLANs +with the same endpoints can share a worker without sharing matcher state. Untagged packets allocate +no tag storage; tagged stacks use shared immutable storage and remain internal to processing. + Flow routing buffers the existing tuple's `Hash` writes on the stack and runs SeaHash once over the resulting bytes. The integer encoding and digest remain identical to the streaming hasher, so shard assignment and output order are preserved. If a future tuple encoding exceeds the @@ -204,8 +211,9 @@ or second encoding of canonical addresses and ports. a nonfinal fragment cannot reach or exceed that boundary, regardless of arrival order. A first non-DNS UDP fragment removes any earlier tails for its datagram key. A separate bounded history drops later non-DNS tails without evicting the completed-DNS history; a new DNS first - fragment with a reused IPv4 ID removes that non-DNS mark. Ethernet VLAN tags are part of the - fragment key so traffic from different tagged segments is not assembled together. A new DNS tail + fragment with a reused IPv4 ID removes that non-DNS mark. The canonical VLAN context is part of + the fragment key so traffic from different tagged segments is not assembled together, while + priority or drop-eligibility changes do not split a datagram. A new DNS tail that arrives before its first fragment while the old non-DNS mark is active is indistinguishable from an old non-DNS tail and may be dropped. The optional runtime flag @@ -355,8 +363,9 @@ The DNS matcher must preserve these invariants: - Routing and in-flight matching use the original observed client IP, client port, and resolver IP. The resolver remains internal; deterministic client-IP pseudonymization is applied exactly once when the matcher constructs a finalized `DnsRecord`. -- DNS ID, observed QNAME, QTYPE, QCLASS and OPCODE also distinguish in-flight identities. QCLASS, - OPCODE and resolver identity remain internal and do not change the exported record schema. +- DNS ID, observed QNAME, QTYPE, QCLASS, OPCODE and the full canonical VLAN tag stack also distinguish + in-flight identities. QCLASS, OPCODE, VLAN context and resolver identity remain internal and do + not change the exported record schema. - Repeated pending queries with the same match identity inside the configured timeout window (`1200ms` by default) are deduplicated to the earliest canonical query. Later duplicates are counted separately and must not create extra matched or timeout records. diff --git a/docs/encapsulation-playbook.md b/docs/encapsulation-playbook.md index 01fbf8d..9e06bfb 100644 --- a/docs/encapsulation-playbook.md +++ b/docs/encapsulation-playbook.md @@ -5,6 +5,9 @@ This document explains how to convert a capture that contains MPLS or another unsupported outer encapsulation layer into a flat Ethernet plus IPv4 or IPv6 capture that DPP can read directly. Ethernet VLAN and QinQ stacks with TPIDs `0x8100`, `0x88a8`, or `0x9100` are decoded natively. +Matching and IPv4 reassembly preserve the complete ordered TPID/VLAN-ID stack, so overlapping +IP endpoints on different tagged segments remain separate. PCP and DEI changes are ignored for +this identity; no VLAN column is added to exported records. Target output shape: diff --git a/docs/rfc/0004-forward-only-matcher.md b/docs/rfc/0004-forward-only-matcher.md index 7ce7251..d9ea62f 100644 --- a/docs/rfc/0004-forward-only-matcher.md +++ b/docs/rfc/0004-forward-only-matcher.md @@ -24,7 +24,9 @@ order within each shard and never backtracks. The key design choices: `CanonicalFlowKey` (observed client IP, client port and resolver IP, oriented by DNS QR) and hashes it to a shard index. This includes the valid case where both ports are 53 and guarantees that a query and its - matching response always land in the same shard. + matching response always land in the same shard. This is a coarser partition than matcher + identity: VLANs with overlapping endpoints may share a worker, but their transaction state + remains distinct. 3. **Deterministic ordering.** Within a shard, packets are processed in strict `(timestamp, packet_ordinal, record_ordinal)` order. Tie-breaks are explicit — scheduler @@ -59,8 +61,11 @@ order within each shard and never backtracks. The key design choices: of retry history, with no secondary matcher map. Match identity includes the DNS ID, observed name, client IP and port, resolver IP, query - type, query class and opcode. Resolver identity, query class and opcode remain internal and are - not added to the exported `DnsRecord` schema. + type, query class, opcode and canonical VLAN context. Ethernet decoding owns the ordered + TPID/12-bit-VID stack used by both matcher and fragment keys; PCP and DEI bits are excluded. + Untagged traffic needs no tag allocation, while tagged metadata and keys share immutable tag + storage. Resolver identity, query class, opcode and VLAN context remain internal and are not + added to the exported `DnsRecord` schema. The current Community Edition identity key preserves the observed presentation-form QNAME bytes and does not lowercase them before matching. This is a deliberate Community Edition trade-off, diff --git a/src/dns_processor/matcher.rs b/src/dns_processor/matcher.rs index 00f5a82..f982be2 100644 --- a/src/dns_processor/matcher.rs +++ b/src/dns_processor/matcher.rs @@ -494,6 +494,7 @@ impl DnsProcessor { resolver_ip, record.query_class, record.opcode, + record.vlan_context.clone(), ) } @@ -513,6 +514,7 @@ impl DnsProcessor { resolver_ip, record.query_class, record.opcode, + record.vlan_context.clone(), ) } @@ -527,6 +529,7 @@ impl DnsProcessor { query.resolver_ip, query.query_class, query.opcode, + super::types::VlanContext::default(), ) } diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index 9898af6..306ed7e 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -16,7 +16,7 @@ use hickory_proto::serialize::binary::{BinDecodable, BinDecoder, DecodeError}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use super::DnsProcessor; -use super::types::ProcessedDnsRecord; +use super::types::{ProcessedDnsRecord, VlanContext}; use crate::custom_types::{DnsNameBuf, DnsNameTooLong, ProtoResponseCode}; const ETHERNET_HEADER_LEN: usize = 14; @@ -49,16 +49,17 @@ const DNS_RESOURCE_RECORD_FIXED_LEN: usize = 10; const DNS_OPT_RECORD_TYPE: u16 = 41; const DNS_TSIG_RECORD_TYPE: u16 = 250; -/// Return the encapsulated EtherType and the start of its payload. Reassembly uses the -/// same VLAN traversal so fragment classification and DNS routing agree on the L3 offset. +/// Decode the encapsulated EtherType, payload start and canonical VLAN context once. +/// Reassembly and DNS matching share this traversal and its VLAN identity semantics. pub(super) fn ethernet_ethertype_and_payload_offset( data: &[u8], -) -> Result<(u16, usize), &'static str> { +) -> Result<(u16, usize, VlanContext), &'static str> { let ethernet = data .get(..ETHERNET_HEADER_LEN) .ok_or("Failed to parse Ethernet packet")?; let mut ethertype = u16::from_be_bytes([ethernet[12], ethernet[13]]); let mut payload_offset = ETHERNET_HEADER_LEN; + let mut tags = Vec::new(); while matches!( ethertype, @@ -67,11 +68,13 @@ pub(super) fn ethernet_ethertype_and_payload_offset( let tag = data .get(payload_offset..payload_offset + 4) .ok_or("Failed to parse Ethernet VLAN tag")?; + let vlan_id = u16::from_be_bytes([tag[0], tag[1]]) & 0x0fff; + tags.push((u32::from(ethertype) << 16) | u32::from(vlan_id)); ethertype = u16::from_be_bytes([tag[2], tag[3]]); payload_offset += 4; } - Ok((ethertype, payload_offset)) + Ok((ethertype, payload_offset, VlanContext::from_tags(tags))) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -81,9 +84,10 @@ pub(super) struct CanonicalFlowKey { pub(super) resolver_ip: IpAddr, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct ParsedUdpDnsMeta { pub(super) flow_key: CanonicalFlowKey, + vlan_context: VlanContext, // DNS starts at least at byte 42. A maximum IPv6 frame ends at byte 65589; // reserving the validated 12-byte DNS header bounds this delta to 65535. dns_offset_delta: u16, @@ -94,11 +98,11 @@ pub(super) struct ParsedUdpDnsMeta { } impl ParsedUdpDnsMeta { - fn dns_offset(self) -> usize { + fn dns_offset(&self) -> usize { MIN_DNS_OFFSET + usize::from(self.dns_offset_delta) } - fn src_ip(self) -> IpAddr { + fn src_ip(&self) -> IpAddr { if self.is_response { self.flow_key.resolver_ip } else { @@ -106,7 +110,7 @@ impl ParsedUdpDnsMeta { } } - fn dst_ip(self) -> IpAddr { + fn dst_ip(&self) -> IpAddr { if self.is_response { self.flow_key.client_ip } else { @@ -114,7 +118,7 @@ impl ParsedUdpDnsMeta { } } - fn src_port(self) -> u16 { + fn src_port(&self) -> u16 { if self.is_response { DNS_PORT } else { @@ -122,7 +126,7 @@ impl ParsedUdpDnsMeta { } } - fn dst_port(self) -> u16 { + fn dst_port(&self) -> u16 { if self.is_response { self.flow_key.client_port } else { @@ -130,7 +134,7 @@ impl ParsedUdpDnsMeta { } } - fn dns_data(self, data: &[u8]) -> Result<&[u8], &'static str> { + fn dns_data<'a>(&self, data: &'a [u8]) -> Result<&'a [u8], &'static str> { let start = self.dns_offset(); let end = start .checked_add(usize::from(self.dns_len)) @@ -435,6 +439,7 @@ impl DnsProcessor { query_class: query.query_class, opcode: header.opcode, response_code, + vlan_context: meta.vlan_context.clone(), partial_first_ipv4_fragment: meta.partial_first_ipv4_fragment, partial_response_code: header.partial_response_code, }); @@ -1054,10 +1059,10 @@ impl DnsProcessor { data: &[u8], allow_first_ipv4_response_fragment: bool, ) -> Result, &'static str> { - let (ethertype, l3_offset) = ethernet_ethertype_and_payload_offset(data)?; + let (ethertype, l3_offset, vlan_context) = ethernet_ethertype_and_payload_offset(data)?; let payload = &data[l3_offset..]; - match ethertype { + let mut meta = match ethertype { ETHER_TYPE_IPV4 => Self::extract_udp_dns_from_ipv4( payload, l3_offset, @@ -1065,7 +1070,11 @@ impl DnsProcessor { ), ETHER_TYPE_IPV6 => Self::extract_udp_dns_from_ipv6(payload, l3_offset), _ => Ok(None), + }?; + if let Some(meta) = meta.as_mut() { + meta.vlan_context = vlan_context; } + Ok(meta) } fn extract_udp_dns_from_ipv4( @@ -1280,6 +1289,7 @@ impl DnsProcessor { Ok(Some(ParsedUdpDnsMeta { flow_key: Self::canonical_flow_key(src_ip, dst_ip, src_port, dst_port, is_response), + vlan_context: VlanContext::default(), dns_offset_delta: u16::try_from( dns_offset .checked_sub(MIN_DNS_OFFSET) @@ -1504,7 +1514,8 @@ mod protocol_regression_tests { assert_eq!(meta.dns_data(&first).unwrap().len(), 32); for processor in processors() { - let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { PacketProcessingOutcome::Records(records) => records, other => panic!("partial response rejected: {other:?}"), }; @@ -1521,7 +1532,7 @@ mod protocol_regression_tests { .expect("header-only first fragment routes for decode validation"); for processor in processors() { assert!(matches!( - processor.process_packet_batch_with_meta(&incomplete_question, 100, meta), + processor.process_packet_batch_with_meta(&incomplete_question, 100, meta.clone()), PacketProcessingOutcome::Invalid )); } @@ -1569,7 +1580,7 @@ mod protocol_regression_tests { assert!(!meta.partial_first_ipv4_fragment); for processor in processors() { assert!(matches!( - processor.process_packet_batch_with_meta(&full_packet, 100, meta), + processor.process_packet_batch_with_meta(&full_packet, 100, meta.clone()), PacketProcessingOutcome::Invalid )); } @@ -1584,7 +1595,8 @@ mod protocol_regression_tests { let first = first_ipv4_fragment(ipv4(&no_additionals, true, 53000), 40); let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); for processor in processors() { - let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { PacketProcessingOutcome::Records(records) => records, other => panic!("partial response rejected: {other:?}"), }; @@ -1598,7 +1610,8 @@ mod protocol_regression_tests { let first = first_ipv4_fragment(ipv4(&with_opt, true, 53000), 48); let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); for processor in processors() { - let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { PacketProcessingOutcome::Records(records) => records, other => panic!("partial response rejected: {other:?}"), }; @@ -1611,7 +1624,8 @@ mod protocol_regression_tests { let first = first_ipv4_fragment(ipv4(&with_tsig, true, 53000), fragment_payload_len); let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); for processor in processors() { - let records = match processor.process_packet_batch_with_meta(&first, 100, meta) { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { PacketProcessingOutcome::Records(records) => records, other => panic!("partial response rejected: {other:?}"), }; @@ -1636,7 +1650,7 @@ mod protocol_regression_tests { let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); for processor in processors() { assert!(matches!( - processor.process_packet_batch_with_meta(&first, 100, meta), + processor.process_packet_batch_with_meta(&first, 100, meta.clone()), PacketProcessingOutcome::Invalid )); } diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index 887842e..c3d01ce 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -15,8 +15,10 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; +use super::types::VlanContext; use crate::packet_parser::{PacketBatch, PacketData, PacketPayload}; +#[cfg(test)] const ETHERNET_HEADER_LEN: usize = 14; const IPV4_MIN_HEADER_LEN: usize = 20; const UDP_HEADER_LEN: usize = 8; @@ -45,7 +47,7 @@ struct FragmentKey { source: [u8; 4], destination: [u8; 4], identification: u16, - l2_tags: Option>, + vlan_context: VlanContext, } struct FragmentPart { @@ -208,13 +210,16 @@ impl PendingDatagram { || self .final_payload_length .is_some_and(|last| last != udp_length) - || self.segments.last_key_value().is_some_and(|(offset, segment)| { - // Disjoint intervals have increasing ends, so the last retained - // tail is enough to validate the newly known UDP boundary. - let segment_end = offset + segment.payload.len(); - segment_end > udp_length - || (segment.more_fragments && segment_end == udp_length) - }) + || self + .segments + .last_key_value() + .is_some_and(|(offset, segment)| { + // Disjoint intervals have increasing ends, so the last retained + // tail is enough to validate the newly known UDP boundary. + let segment_end = offset + segment.payload.len(); + segment_end > udp_length + || (segment.more_fragments && segment_end == udp_length) + }) { return Err(()); } @@ -525,16 +530,14 @@ impl Ipv4FragmentReassembler { } fn insert_entry(&mut self, key: FragmentKey, entry: PendingDatagram) { - self.pending_bytes += - entry.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.pending_bytes += entry.memory_bytes() + key.vlan_context.memory_bytes(); self.arrival_order.insert(entry.arrival_order, key.clone()); self.pending.insert(key, entry); } fn take_entry(&mut self, key: &FragmentKey) -> Option { let entry = self.pending.remove(key)?; - self.pending_bytes -= - entry.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.pending_bytes -= entry.memory_bytes() + key.vlan_context.memory_bytes(); self.arrival_order.remove(&entry.arrival_order); Some(entry) } @@ -553,8 +556,7 @@ impl Ipv4FragmentReassembler { observed_timestamp, arrival_order, }; - self.completed_bytes += - completed.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.completed_bytes += completed.memory_bytes() + key.vlan_context.memory_bytes(); self.completed_order.insert(arrival_order, key.clone()); self.recently_completed.insert(key, completed); while self.recently_completed.len() > MAX_RECENT_COMPLETIONS @@ -570,8 +572,7 @@ impl Ipv4FragmentReassembler { fn take_completion(&mut self, key: &FragmentKey) -> Option { let completed = self.recently_completed.remove(key)?; - self.completed_bytes -= - completed.memory_bytes() + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.completed_bytes -= completed.memory_bytes() + key.vlan_context.memory_bytes(); self.completed_order.remove(&completed.arrival_order); Some(completed) } @@ -580,8 +581,7 @@ impl Ipv4FragmentReassembler { self.take_non_dns(&key); let arrival_order = self.next_non_dns_order; self.next_non_dns_order = self.next_non_dns_order.wrapping_add(1); - self.non_dns_bytes += - RECENT_ENTRY_OVERHEAD_ESTIMATE + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.non_dns_bytes += RECENT_ENTRY_OVERHEAD_ESTIMATE + key.vlan_context.memory_bytes(); self.non_dns_order.insert(arrival_order, key.clone()); self.known_non_dns.insert( key, @@ -602,8 +602,7 @@ impl Ipv4FragmentReassembler { fn take_non_dns(&mut self, key: &FragmentKey) -> Option { let known = self.known_non_dns.remove(key)?; - self.non_dns_bytes -= - RECENT_ENTRY_OVERHEAD_ESTIMATE + key.l2_tags.as_ref().map_or(0, |tags| tags.len()); + self.non_dns_bytes -= RECENT_ENTRY_OVERHEAD_ESTIMATE + key.vlan_context.memory_bytes(); self.non_dns_order.remove(&known.arrival_order); Some(known) } @@ -740,7 +739,8 @@ impl Ipv4FragmentReassembler { fn classify_fragment(packet: &PacketData) -> FragmentInput { let bytes = packet.data.as_slice(); - let Ok((ethertype, l3_offset)) = super::parser::ethernet_ethertype_and_payload_offset(bytes) + let Ok((ethertype, l3_offset, vlan_context)) = + super::parser::ethernet_ethertype_and_payload_offset(bytes) else { return FragmentInput::Ordinary; }; @@ -785,8 +785,7 @@ fn classify_fragment(packet: &PacketData) -> FragmentInput { source: ip[12..16].try_into().expect("IPv4 minimum header checked"), destination: ip[16..20].try_into().expect("IPv4 minimum header checked"), identification: u16::from_be_bytes([ip[4], ip[5]]), - l2_tags: (l3_offset > ETHERNET_HEADER_LEN) - .then(|| Arc::<[u8]>::from(&bytes[12..l3_offset])), + vlan_context, }; let first_udp_length = if offset == 0 { if payload.len() < UDP_HEADER_LEN { @@ -971,6 +970,26 @@ mod tests { } } + #[test] + fn tagged_fragments_ignore_priority_and_drop_eligibility_bits() { + let mut assembler = Ipv4FragmentReassembler::new(1_200_000, false); + let first = tagged_packet(packet(0, true, 1, 100), &[(0x88a8, 10), (0x8100, 100)]); + let last = tagged_packet( + packet(24, false, 2, 101), + &[(0x88a8, 0xb00a), (0x8100, 0xf064)], + ); + let (output, pending) = assembler.process_batch(vec![first, last]); + assert_eq!(pending, None); + assert_eq!( + output.len(), + 1, + "QoS changes do not change the fragment key" + ); + let meta = super::super::DnsProcessor::packet_routing_meta(output[0].data.as_slice()) + .expect("reassembled tagged datagram routes"); + assert!(!meta.partial_first_ipv4_fragment); + } + #[test] fn assembles_across_batches_once_at_final_fragment_time() { let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); @@ -1117,7 +1136,7 @@ mod tests { source: [192, 0, 2, 1], destination: [192, 0, 2, 53], identification: id as u16, - l2_tags: None, + vlan_context: VlanContext::default(), }, Arc::<[u8]>::from(&b"payload"[..]), 5_000_103, @@ -1340,7 +1359,10 @@ mod tests { }; let (output, pending) = reassembler.process_batch(input); - assert!(output.is_empty(), "offset={offset}, tail_first={tail_first}"); + assert!( + output.is_empty(), + "offset={offset}, tail_first={tail_first}" + ); assert_eq!(pending, None, "offset={offset}, tail_first={tail_first}"); assert!( reassembler.finish().is_empty(), diff --git a/src/dns_processor/tests.rs b/src/dns_processor/tests.rs index d3b9097..b78fdd9 100644 --- a/src/dns_processor/tests.rs +++ b/src/dns_processor/tests.rs @@ -13,7 +13,7 @@ use std::net::{IpAddr, Ipv4Addr}; use super::DnsProcessor; use super::parser::PacketProcessingOutcome; -use super::types::{MatcherShardState, ProcessedDnsRecord}; +use super::types::{MatcherShardState, ProcessedDnsRecord, VlanContext}; use crate::custom_types::DnsNameBuf; use crate::test_support::{ encode_dns_header, make_udp_dns_packet, make_udp_dns_packet_with_payload, temp_test_path, @@ -436,6 +436,7 @@ fn insert_query( query.resolver_ip, query.query_class, query.opcode, + VlanContext::default(), ); let key = super::types::TimelineKey::new( query.timestamp_micros, @@ -459,6 +460,7 @@ fn insert_response( response.resolver_ip, response.query_class, response.opcode, + VlanContext::default(), ); let key = super::types::TimelineKey::new( response.timestamp_micros, @@ -492,6 +494,7 @@ fn make_query_record_with_timestamp( query_class: 1, opcode: 0, response_code: HickoryResponseCode::ServFail.into(), + vlan_context: VlanContext::default(), partial_first_ipv4_fragment: false, partial_response_code: None, } @@ -517,6 +520,7 @@ fn make_response_record_with_timestamp( query_class: 1, opcode: 0, response_code: HickoryResponseCode::NoError.into(), + vlan_context: VlanContext::default(), partial_first_ipv4_fragment: false, partial_response_code: None, } @@ -560,7 +564,7 @@ fn packet_routing_meta_preserves_standard_packet_processing_output() { .expect("standard parser succeeds"); let routing_meta = DnsProcessor::packet_routing_meta(&packet).expect("routing metadata exists"); let meta_records = - match processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta) { + match processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta.clone()) { PacketProcessingOutcome::Records(records) => records, outcome => panic!("metadata-assisted parser failed: {outcome:?}"), }; @@ -610,7 +614,7 @@ fn packet_processing_appends_records_with_final_ordinals() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -665,7 +669,7 @@ fn packet_processing_failure_does_not_append_partial_records() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -700,7 +704,7 @@ fn packet_processing_zero_questions_leaves_destination_unchanged() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -734,7 +738,7 @@ fn packet_processing_response_appends_only_first_question() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -775,7 +779,7 @@ fn parser_bounds_dns_decode_to_udp_length() { ] { assert!( matches!( - processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta,), + processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta.clone(),), PacketProcessingOutcome::Invalid ), "fast_path={fast_path}" diff --git a/src/dns_processor/types.rs b/src/dns_processor/types.rs index b62d0d1..848015d 100644 --- a/src/dns_processor/types.rs +++ b/src/dns_processor/types.rs @@ -10,6 +10,7 @@ use hickory_proto::rr::RecordType as HickoryRecordType; use std::collections::BTreeMap; use std::mem; use std::net::IpAddr; +use std::sync::Arc; #[cfg(test)] use std::mem::MaybeUninit; @@ -19,6 +20,23 @@ use crate::output::OutputRecordBatches; const INLINE_TIMELINE_CAPACITY: usize = 1; +/// Ordered Ethernet tag stack, with each tag encoded as TPID and its 12-bit VLAN ID. +/// Untagged traffic needs no allocation; packet metadata and matcher keys share tagged stacks. +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(super) struct VlanContext(Option>); + +impl VlanContext { + pub(super) fn from_tags(tags: Vec) -> Self { + Self((!tags.is_empty()).then(|| Arc::from(tags))) + } + + pub(super) fn memory_bytes(&self) -> usize { + self.0 + .as_ref() + .map_or(0, |tags| std::mem::size_of_val(tags.as_ref())) + } +} + // Matcher identity preserves the observed presentation-form QNAME bytes and does not lowercase // them before building in-flight keys. RFC 4343 defines ASCII label comparison as // case-insensitive, and a protocol-compliant response is allowed to differ from the query's 0x20 @@ -26,7 +44,8 @@ const INLINE_TIMELINE_CAPACITY: usize = 1; // Edition intentionally does not canonicalize names here: byte-preserving matching aligns better // with the real behavior we target on offline caching-resolver workloads. As a result, a // query/response pair that differs only by case may fail to match even on otherwise valid DNS -// traffic. Tuple order is id, name, client IP, client port, query type, resolver IP, query class, opcode. +// traffic. Tuple order is id, name, client IP, client port, query type, resolver IP, query class, +// opcode, VLAN context. QoS bits (PCP and DEI) do not distinguish network segments. pub(super) type MatcherIdentityKey = ( u16, DnsNameBuf, @@ -36,6 +55,7 @@ pub(super) type MatcherIdentityKey = ( IpAddr, u16, u8, + VlanContext, ); pub(super) type QueryIdentityKey = MatcherIdentityKey; pub(super) type ResponseIdentityKey = MatcherIdentityKey; @@ -425,6 +445,7 @@ pub(super) struct ProcessedDnsRecord { pub(super) query_class: u16, pub(super) opcode: u8, pub(super) response_code: ProtoResponseCode, + pub(super) vlan_context: VlanContext, pub(super) partial_first_ipv4_fragment: bool, /// Known only when all records needed to determine the response code are in the prefix. pub(super) partial_response_code: Option, diff --git a/tests/vlan_isolation.rs b/tests/vlan_isolation.rs new file mode 100644 index 0000000..6b605f3 --- /dev/null +++ b/tests/vlan_isolation.rs @@ -0,0 +1,180 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +#[allow(dead_code)] +mod support; + +use std::fs; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use support::{ + classic_pcap_bytes, encode_dns_header, make_udp_dns_packet_with_payload, temp_test_path, +}; + +fn dns_packet(response: bool, ipv6: bool, tags: &[(u16, u16)]) -> Vec { + let mut dns = encode_dns_header(123, if response { 0x8180 } else { 0x0100 }, 1); + dns.extend_from_slice(b"\x01a\0\0\x01\0\x01"); + let (source, destination, source_port, destination_port) = if response { + ([8, 8, 8, 8], [10, 0, 0, 1], 53, 53000) + } else { + ([10, 0, 0, 1], [8, 8, 8, 8], 53000, 53) + }; + let mut packet = + make_udp_dns_packet_with_payload(source, destination, source_port, destination_port, &dns); + if ipv6 { + let udp = packet[34..].to_vec(); + packet.truncate(14); + packet[12..14].copy_from_slice(&0x86dd_u16.to_be_bytes()); + packet.extend_from_slice(&[0x60, 0, 0, 0]); + packet.extend_from_slice(&(udp.len() as u16).to_be_bytes()); + packet.extend_from_slice(&[17, 64]); + for address in [source, destination] { + packet.extend_from_slice(&[0x20, 1, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0]); + packet.extend_from_slice(&address); + } + packet.extend_from_slice(&udp); + } + let mut tagged = packet[..12].to_vec(); + for &(tpid, tci) in tags { + tagged.extend_from_slice(&tpid.to_be_bytes()); + tagged.extend_from_slice(&tci.to_be_bytes()); + } + tagged.extend_from_slice(&packet[12..]); + tagged +} + +fn output_timestamps( + packets: &[Vec], + threads: &str, + fast_path: bool, + full_fragments: bool, +) -> Vec<(i64, Option)> { + static NEXT_CAPTURE: AtomicU64 = AtomicU64::new(0); + let prefix = format!( + "vlan-isolation-{}", + NEXT_CAPTURE.fetch_add(1, Ordering::Relaxed) + ); + let input = temp_test_path(&prefix, "pcap"); + let output = temp_test_path(&prefix, "csv"); + let capture_packets: Vec<_> = packets + .iter() + .enumerate() + .map(|(index, packet)| (1, index as u32 * 100, packet.as_slice())) + .collect(); + fs::write(&input, classic_pcap_bytes(&capture_packets)).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_dpp")); + command.args(["--silent", "--threads", threads]); + if fast_path { + command.arg("--dns-wire-fast-path"); + } + if full_fragments { + command.arg("--full-fragments"); + } + let result = command.arg(&input).arg(&output).output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let mut rows: Vec<_> = csv::Reader::from_path(&output) + .unwrap() + .records() + .map(|record| { + let record = record.unwrap(); + (record[0].parse().unwrap(), record[1].parse().ok()) + }) + .collect(); + rows.sort_unstable(); + fs::remove_file(input).unwrap(); + fs::remove_file(output).unwrap(); + rows +} + +#[test] +fn overlapping_dns_tuples_remain_isolated_by_full_vlan_stack() { + for (left, right) in [ + (vec![(0x8100, 100)], vec![(0x8100, 200)]), + (vec![], vec![(0x8100, 100)]), + (vec![(0x8100, 100)], vec![(0x9100, 100)]), + ( + vec![(0x88a8, 10), (0x8100, 100)], + vec![(0x88a8, 20), (0x8100, 100)], + ), + ( + vec![(0x88a8, 10), (0x8100, 100)], + vec![(0x88a8, 10), (0x8100, 200)], + ), + ] { + for ipv6 in [false, true] { + let packets = [ + dns_packet(false, ipv6, &left), + dns_packet(false, ipv6, &right), + dns_packet(true, ipv6, &right), + ]; + for threads in ["1", "6"] { + for fast_path in [false, true] { + assert_eq!( + output_timestamps(&packets, threads, fast_path, false), + [(1_000_000, None), (1_000_100, Some(1_000_200))], + "left={left:?}, right={right:?}, ipv6={ipv6}, threads={threads}, fast_path={fast_path}" + ); + } + } + } + } +} + +#[test] +fn vlan_priority_and_drop_eligibility_changes_preserve_matching() { + for ipv6 in [false, true] { + let packets = [ + dns_packet(false, ipv6, &[(0x88a8, 10), (0x8100, 100)]), + dns_packet(true, ipv6, &[(0x88a8, 0xb00a), (0x8100, 0xf064)]), + ]; + for threads in ["1", "6"] { + for fast_path in [false, true] { + assert_eq!( + output_timestamps(&packets, threads, fast_path, false), + [(1_000_000, Some(1_000_100))] + ); + } + } + } +} + +#[test] +fn reassembled_responses_keep_vlan_identity_despite_fragment_qos_changes() { + const IP_OFFSET: usize = 14 + 8; + const IP_HEADER_LEN: usize = 20; + const FIRST_PAYLOAD_LEN: usize = 24; + let query_one = dns_packet(false, false, &[(0x88a8, 10), (0x8100, 100)]); + let query_two = dns_packet(false, false, &[(0x88a8, 20), (0x8100, 100)]); + let response = dns_packet(true, false, &[(0x88a8, 20), (0x8100, 100)]); + let split = IP_OFFSET + IP_HEADER_LEN + FIRST_PAYLOAD_LEN; + let mut first = response[..split].to_vec(); + first[IP_OFFSET + 2..IP_OFFSET + 4] + .copy_from_slice(&((IP_HEADER_LEN + FIRST_PAYLOAD_LEN) as u16).to_be_bytes()); + first[IP_OFFSET + 6..IP_OFFSET + 8].copy_from_slice(&0x2000_u16.to_be_bytes()); + let mut last = response[..IP_OFFSET + IP_HEADER_LEN].to_vec(); + last.extend_from_slice(&response[split..]); + let last_ip_length = (last.len() - IP_OFFSET) as u16; + last[IP_OFFSET + 2..IP_OFFSET + 4].copy_from_slice(&last_ip_length.to_be_bytes()); + last[IP_OFFSET + 6..IP_OFFSET + 8] + .copy_from_slice(&((FIRST_PAYLOAD_LEN / 8) as u16).to_be_bytes()); + last[14..16].copy_from_slice(&0xb014_u16.to_be_bytes()); + last[18..20].copy_from_slice(&0xf064_u16.to_be_bytes()); + let packets = [query_one, query_two, first, last]; + for threads in ["1", "6"] { + for fast_path in [false, true] { + assert_eq!( + output_timestamps(&packets, threads, fast_path, true), + [(1_000_000, None), (1_000_100, Some(1_000_300))] + ); + } + } +} From 7807751cde94070e3903984f39d16b928c0fec76 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 07:05:20 +0300 Subject: [PATCH 21/28] Clarify fragment flag precedence in regression test --- src/dns_processor/reassembly.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index c3d01ce..f988710 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -1349,7 +1349,7 @@ mod tests { let mut bytes = tail.data.as_slice().to_vec(); bytes.truncate(ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + 8); bytes[16..18].copy_from_slice(&(IPV4_MIN_HEADER_LEN as u16 + 8).to_be_bytes()); - bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | offset / 8).to_be_bytes()); + bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | (offset / 8)).to_be_bytes()); tail.data = PacketPayload::owned(bytes.into_boxed_slice()); let first = packet(0, true, first_ordinal, 99 + first_ordinal as i64); let input = if tail_first { From 311bd75617856e6c7f8f8ccd744192762b896293 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 07:38:08 +0300 Subject: [PATCH 22/28] Bound DNS compression depth and cache validated message suffixes --- README.md | 3 + docs/architecture.md | 21 +- src/app.rs | 13 + src/cli.rs | 106 +++++- src/config.rs | 5 + src/dns_processor.rs | 9 + src/dns_processor/name_decoder.rs | 378 +++++++++++++++++++ src/dns_processor/name_decoder_tests.rs | 328 +++++++++++++++++ src/dns_processor/parser.rs | 463 ++++++++++++++---------- src/parquet_writer.rs | 6 + 10 files changed, 1140 insertions(+), 192 deletions(-) create mode 100644 src/dns_processor/name_decoder.rs create mode 100644 src/dns_processor/name_decoder_tests.rs diff --git a/README.md b/README.md index 4dd87bf..8692fb0 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ Notes: | `--v2` | Use Parquet Version 2 | | `-a, --affinity` | Apply CPU affinity to processing threads | | `--dns-wire-fast-path` | Enable the optional question-only DNS wire fast path with `hickory` fallback | +| `--max-dns-compression-jumps ` | Maximum compression-pointer jumps per DNS name; defaults to `32`; `0` disables the jump limit | | `--allow-fragments` | Opt in to matching complete queries with first IPv4 response fragments when the DNS header and question are present; no reassembly | | `--full-fragments` | Reassemble IPv4 fragments into complete UDP datagrams; also enables `--allow-fragments` for incomplete responses | | `--anonymize ` | Path to the pseudonymization key file | @@ -259,6 +260,7 @@ provide supported per-core thread pinning through the affinity backend. | `DPP_V2` | Enable Parquet Version 2 | | `DPP_AFFINITY` | Apply CPU affinity to processing threads | | `DPP_DNS_WIRE_FAST_PATH` | Enable the optional DNS wire fast path | +| `DPP_MAX_DNS_COMPRESSION_JUMPS` | Maximum compression-pointer jumps per DNS name when `--max-dns-compression-jumps` is not set; defaults to `32`; `0` disables the jump limit | | `DPP_ANONYMIZE` | Path to the key file used for pseudonymization | | `DPP_SILENT` | Suppress info-level log output | @@ -539,6 +541,7 @@ Additional notes: - DPP auto-sizes its execution budget from available CPUs unless `--threads` or `DPP_THREADS` sets a lower ceiling. The pipeline model is still selected automatically from that budget. - The parser fast path is opt-in through `--dns-wire-fast-path` or `DPP_DNS_WIRE_FAST_PATH=1`; without that flag DPP uses the legacy `hickory` question decoder. +- Both parser modes limit DNS compression chains to `32` pointer jumps per name by default. Use `--max-dns-compression-jumps N` or `DPP_MAX_DNS_COMPRESSION_JUMPS=N` to change the limit; `0` disables this resource limit. Cached suffixes still count toward the full chain depth. Bounds, backward-pointer, overlap, and existing name-length validation remain active. Checked suffixes are cached within each message to avoid repeatedly walking shared chains. - `--allow-fragments` or `DPP_ALLOW_FRAGMENTS=1` optionally pairs a query with the observable first IPv4 fragment of a response when its DNS header and complete question are present. `response_code` is populated when there are no additional records or every declared DNS record fits in the first fragment; otherwise it is empty. The JSON metric `fragmented_response_prefix_count` (text: `Accepted first IPv4 response fragments`) counts accepted prefixes, including ones without a matching query. - `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. A bounded history of completed datagrams compares the fragment key and UDP payload bytes to suppress repeated complete datagrams and prefix fallback when all observed fragments match a recent completion. A different datagram can still use the same IPv4 ID, including one with an identical first fragment but different later bytes. A fully identical new datagram, or an incomplete one whose observed fragments match a recent completion, cannot be distinguished from a duplicate and may be suppressed. A bounded non-DNS history drops later tails after a non-DNS first fragment; if a new DNS datagram reuses the same IPv4 ID and its tail arrives before its first fragment while that history is active, the tail is indistinguishable and may be dropped. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. - For captures normalized with `reordercap`, `--monotonic-capture` can reduce in-flight matcher state by enabling batched timeout eviction. If a timestamp regression is detected, DPP fails the run instead of silently weakening matching semantics. diff --git a/docs/architecture.md b/docs/architecture.md index 6077544..7b25d85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -220,8 +220,25 @@ or second encoding of canonical addresses and ports. `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains the semantic fallback for rare DNS messages that the fast path does not accept. Compression pointers must target prior, nonoverlapping names; enabling the fast path must not weaken that - validation. Both wire-name walkers bound traversal by strictly decreasing pointer targets, - allowing valid chains longer than 32 pointers while rejecting cycles and overlaps. + validation. `--max-dns-compression-jumps` (environment: `DPP_MAX_DNS_COMPRESSION_JUMPS`) + limits each decoded name to 32 pointer transitions by default; `0` disables this resource + limit. The limit applies to questions, scanned RR owners and TSIG algorithm names in both + decoder modes, including partial responses and fast-path fallback. A cached suffix contributes + its full pointer depth to this limit. Even when unlimited, strictly backward, nonoverlapping + segments prevent cycles. This is a resource policy, not a claim that longer chains violate DNS. + One immutable-message name decoder caches fully validated pointer targets and their encoded + segment end, expanded wire length, pointer depth and first label. Every cache hit rechecks the + segment end against the current name's start so a previously valid target cannot authorize an + overlap. Only referenced suffixes are retained; ordinary flat record lists do not grow the cache + per record. RR/TSIG name skipping validates short walks directly (up to eight jumps and 64 + literal bytes per referenced segment), switching to the shared cached walker when either + threshold or the configured jump limit would be exceeded. These thresholds choose a cheaper + validation strategy; they never relax the limit or accept an otherwise invalid name. + Small caches and traversal stacks use inline storage; larger ones allocate within + the message and are bounded by the 14-bit pointer address space. Label materialization skips + pointer-only segments. Compressed questions are passed to Hickory already expanded, avoiding + a second recursive traversal without replacing Hickory's question semantics. Literal-root + validation for OPT remains distinct from a compressed name that expands to root. For ordinary QUERY messages (OPCODE 0), more than one question is rejected under RFC 9619. A complete response cannot claim answer or authority records that are absent from its DNS payload, whether or not it has additional records. TSIG status extraction consumes and diff --git a/src/app.rs b/src/app.rs index 1f4de24..0f559d7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -149,6 +149,14 @@ fn display_parser_mode(args: &AppConfig) { } else { info!("DNS wire fast path: disabled"); } + if args.max_dns_compression_jumps == 0 { + info!("DNS compression-pointer jump limit: disabled"); + } else { + info!( + "DNS compression-pointer jump limit: {} per name", + args.max_dns_compression_jumps + ); + } } fn display_fragment_mode(args: &AppConfig) { @@ -197,6 +205,7 @@ struct RunConfigSummary { match_timeout_ms: u64, monotonic_capture: bool, dns_wire_fast_path: bool, + max_dns_compression_jumps: usize, allow_fragments: bool, full_fragments: bool, anonymization_enabled: bool, @@ -397,6 +406,7 @@ fn build_run_summary( match_timeout_ms: args.match_timeout_ms, monotonic_capture: args.monotonic_capture, dns_wire_fast_path: args.dns_wire_fast_path, + max_dns_compression_jumps: args.max_dns_compression_jumps, allow_fragments: args.allow_fragments, full_fragments: args.full_fragments, anonymization_enabled: args.anonymize_key_path().is_some(), @@ -614,6 +624,7 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { args.monotonic_capture, ) .map_err(|source| AppRunError::DnsProcessorInit { source })? + .with_max_dns_compression_jumps(args.max_dns_compression_jumps) .with_allow_fragments(args.allow_fragments) .with_full_fragments(args.full_fragments), ); @@ -778,6 +789,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, } @@ -997,6 +1009,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, } diff --git a/src/cli.rs b/src/cli.rs index 4dd9827..7e527f3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,8 +6,8 @@ */ use crate::config::{ - AppConfig, DEFAULT_MATCH_TIMEOUT_MS, InputSource, MAX_MATCH_TIMEOUT_MS, OutputFormat, - OutputTarget, ReportFormat, output_target_for_path, + AppConfig, DEFAULT_MATCH_TIMEOUT_MS, DEFAULT_MAX_DNS_COMPRESSION_JUMPS, InputSource, + MAX_MATCH_TIMEOUT_MS, OutputFormat, OutputTarget, ReportFormat, output_target_for_path, }; use anyhow::{Context, Result, anyhow, bail}; use clap::{Arg, ArgAction, ArgMatches, Command}; @@ -30,6 +30,7 @@ pub(crate) fn parse_args() -> Result { let env_v2 = parse_env_bool("DPP_V2"); let env_affinity = parse_env_bool("DPP_AFFINITY"); let env_dns_wire_fast_path = parse_env_bool("DPP_DNS_WIRE_FAST_PATH"); + let env_max_dns_compression_jumps = env::var_os("DPP_MAX_DNS_COMPRESSION_JUMPS"); let env_allow_fragments = parse_env_bool("DPP_ALLOW_FRAGMENTS"); let env_full_fragments = parse_env_bool("DPP_FULL_FRAGMENTS"); let env_monotonic_capture = parse_env_bool("DPP_MONOTONIC_CAPTURE"); @@ -70,6 +71,8 @@ pub(crate) fn parse_args() -> Result { let requested_threads = resolve_thread_limit(&matches, env_threads.as_deref())?; let bonded = resolve_bonded(&matches, env_bonded.as_deref())?; + let max_dns_compression_jumps = + resolve_max_dns_compression_jumps(&matches, env_max_dns_compression_jumps.as_deref())?; let output_filename = matches .get_one::("output_filename") @@ -110,6 +113,7 @@ pub(crate) fn parse_args() -> Result { bonded, anonymize, dns_wire_fast_path, + max_dns_compression_jumps, allow_fragments, full_fragments, }) @@ -128,6 +132,8 @@ fn build_cli(version: &'static str) -> Command { DPP_AFFINITY Set to 'true' to apply CPU affinity to processing threads DPP_DNS_WIRE_FAST_PATH Set to 'true' to enable the optional question-only DNS wire fast path with hickory fallback + DPP_MAX_DNS_COMPRESSION_JUMPS + Maximum compression-pointer jumps per DNS name; default is 32, 0 disables the limit (used if --max-dns-compression-jumps is not specified) DPP_ALLOW_FRAGMENTS Set to 'true' to match queries with observable first IPv4 response fragments without reassembly DPP_FULL_FRAGMENTS Set to 'true' to reassemble IPv4 fragments and enable first-fragment response matching DPP_MONOTONIC_CAPTURE @@ -238,6 +244,14 @@ LICENSE INFORMATION: .help("Enable the optional question-only DNS wire fast path with hickory fallback") .action(ArgAction::SetTrue), ) + .arg( + Arg::new("max_dns_compression_jumps") + .long("max-dns-compression-jumps") + .help("Maximum compression-pointer jumps per DNS name (default: 32; 0 disables the limit)") + .value_name("N") + .num_args(1) + .value_parser(clap::value_parser!(usize)), + ) .arg( Arg::new("allow_fragments") .long("allow-fragments") @@ -340,6 +354,29 @@ fn resolve_bonded(matches: &ArgMatches, env_value: Option<&OsStr>) -> Result, +) -> Result { + if let Some(value) = matches.get_one::("max_dns_compression_jumps") { + return Ok(*value); + } + + match env_value { + Some(value) => value + .to_str() + .ok_or_else(|| anyhow!("DPP_MAX_DNS_COMPRESSION_JUMPS must contain valid UTF-8"))? + .parse::() + .with_context(|| { + format!( + "Failed to parse DPP_MAX_DNS_COMPRESSION_JUMPS from '{}'", + value.display() + ) + }), + None => Ok(DEFAULT_MAX_DNS_COMPRESSION_JUMPS), + } +} + fn resolve_silent_mode( matches: &ArgMatches, env_silent: bool, @@ -679,6 +716,71 @@ mod tests { assert_eq!(resolved, DEFAULT_MATCH_TIMEOUT_MS); } + #[test] + fn compression_jump_limit_defaults_to_32_and_accepts_zero() { + let defaults = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_max_dns_compression_jumps(&defaults, None).unwrap(), + DEFAULT_MAX_DNS_COMPRESSION_JUMPS + ); + assert_eq!(DEFAULT_MAX_DNS_COMPRESSION_JUMPS, 32); + for value in ["0", "64"] { + let expected = value.parse::().unwrap(); + assert_eq!( + resolve_max_dns_compression_jumps(&defaults, Some(OsStr::new(value))).unwrap(), + expected + ); + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--max-dns-compression-jumps", value, "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_max_dns_compression_jumps(&cli, Some(OsStr::new("invalid"))).unwrap(), + expected + ); + } + } + + #[test] + fn invalid_compression_jump_limits_are_rejected() { + let defaults = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + for value in ["-1", "invalid", "184467440737095516160"] { + assert!( + build_cli("test") + .try_get_matches_from([ + "dpp", + &format!("--max-dns-compression-jumps={value}"), + "input.pcap", + ]) + .is_err() + ); + assert!(resolve_max_dns_compression_jumps(&defaults, Some(OsStr::new(value))).is_err()); + } + } + + #[cfg(unix)] + #[test] + fn non_utf8_compression_jump_env_is_rejected_unless_cli_overrides_it() { + use std::os::unix::ffi::OsStrExt; + + let invalid = OsStr::from_bytes(&[0xff]); + let defaults = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert!(resolve_max_dns_compression_jumps(&defaults, Some(invalid)).is_err()); + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--max-dns-compression-jumps", "0", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_max_dns_compression_jumps(&cli, Some(invalid)).unwrap(), + 0 + ); + } + #[test] fn env_match_timeout_wins_when_cli_is_absent() { let matches = build_cli("test") diff --git a/src/config.rs b/src/config.rs index 7c69bbc..78676b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,9 @@ pub(crate) const PACKET_BATCH_SIZE: usize = 65_536; /// Default DNS match timeout, expressed in milliseconds. pub(crate) const DEFAULT_MATCH_TIMEOUT_MS: u64 = 1_200; +/// Default maximum number of compression-pointer jumps in one DNS name. +pub(crate) const DEFAULT_MAX_DNS_COMPRESSION_JUMPS: usize = 32; + /// Maximum supported DNS match timeout, expressed in milliseconds. pub(crate) const MAX_MATCH_TIMEOUT_MS: u64 = 5_000; @@ -166,6 +169,7 @@ pub(crate) struct AppConfig { pub(crate) bonded: usize, pub(crate) anonymize: Option, pub(crate) dns_wire_fast_path: bool, + pub(crate) max_dns_compression_jumps: usize, pub(crate) allow_fragments: bool, pub(crate) full_fragments: bool, } @@ -288,6 +292,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, } diff --git a/src/dns_processor.rs b/src/dns_processor.rs index 1ef905d..ba14592 100644 --- a/src/dns_processor.rs +++ b/src/dns_processor.rs @@ -7,6 +7,7 @@ mod anonymizer; mod matcher; +mod name_decoder; mod parser; mod pipeline; mod reassembly; @@ -16,6 +17,7 @@ mod types; #[cfg(test)] use crate::config::DEFAULT_MATCH_TIMEOUT_MS; +use crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS; pub(crate) use pipeline::{PipelineExecutionConfig, ProcessingCounters}; use std::io; use std::net::IpAddr; @@ -24,6 +26,7 @@ use std::path::Path; pub struct DnsProcessor { anonymizer: anonymizer::Anonymizer, dns_wire_fast_path: bool, + max_dns_compression_jumps: usize, match_timeout_micros: i64, monotonic_capture: bool, allow_fragments: bool, @@ -66,6 +69,7 @@ impl DnsProcessor { Ok(DnsProcessor { anonymizer: anonymizer::Anonymizer::new(anonymize_key_path)?, dns_wire_fast_path, + max_dns_compression_jumps: DEFAULT_MAX_DNS_COMPRESSION_JUMPS, match_timeout_micros, monotonic_capture, allow_fragments: false, @@ -78,6 +82,11 @@ impl DnsProcessor { self } + pub fn with_max_dns_compression_jumps(mut self, limit: usize) -> Self { + self.max_dns_compression_jumps = limit; + self + } + pub fn with_full_fragments(mut self, full_fragments: bool) -> Self { self.full_fragments = full_fragments; self.allow_fragments |= full_fragments; diff --git a/src/dns_processor/name_decoder.rs b/src/dns_processor/name_decoder.rs new file mode 100644 index 0000000..d7171cd --- /dev/null +++ b/src/dns_processor/name_decoder.rs @@ -0,0 +1,378 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +use arrayvec::ArrayVec; +use std::collections::HashMap; + +const POINTER_TAG: u8 = 0xc0; +const MAX_POINTER_OFFSET: usize = 0x3fff; +const DIRECT_SUFFIX_BYTES: usize = 64; +const DIRECT_POINTER_JUMPS: usize = 8; + +#[derive(Clone, Copy, Debug)] +pub(super) struct ValidatedName { + /// End of this segment's encoded labels and terminating pointer or root. + wire_end: usize, + pub(super) jumps: usize, + pub(super) expanded_wire_len: usize, + /// First label (or root), bypassing any leading pointer-only segments. + first_label: usize, +} + +struct PendingSegment { + start: usize, + wire_end: usize, + label_bytes: usize, +} + +impl PendingSegment { + fn prepend(self, suffix: ValidatedName) -> ValidatedName { + ValidatedName { + wire_end: self.wire_end, + jumps: suffix.jumps + 1, + expanded_wire_len: self.label_bytes + suffix.expanded_wire_len, + first_label: if self.label_bytes == 0 { + suffix.first_label + } else { + self.start + }, + } + } +} + +/// One immutable DNS message owns all cached validation results. Only referenced +/// suffixes are cached: ordinary flat RR lists do not grow the cache per record. +/// Small messages need no heap allocation; larger caches cover at most the 14-bit +/// pointer address space. No recursion is used, even when the jump limit is zero. +pub(super) struct DnsNameDecoder<'a> { + data: &'a [u8], + max_jumps: usize, + cache: Option, + #[cfg(test)] + visited: usize, +} + +#[derive(Default)] +struct DnsNameCache { + inline: ArrayVec<(usize, ValidatedName), 8>, + overflow: Option>, + pending: ArrayVec, + pending_overflow: Vec, +} + +impl<'a> DnsNameDecoder<'a> { + pub(super) fn new(data: &'a [u8], max_jumps: usize) -> Self { + Self { + data, + max_jumps, + cache: None, + #[cfg(test)] + visited: 0, + } + } + + fn cached(&self, start: usize) -> Option { + let state = self.cache.as_ref()?; + if let Some(cache) = &state.overflow { + cache.get(&start).copied() + } else { + state + .inline + .iter() + .find_map(|&(offset, name)| (offset == start).then_some(name)) + } + } + + fn remember(&mut self, start: usize, name: ValidatedName) { + debug_assert!(start <= MAX_POINTER_OFFSET); + let state = self + .cache + .as_mut() + .expect("an uncached pointer initialized the traversal state"); + if let Some(cache) = &mut state.overflow { + cache.insert(start, name); + } else if !state.inline.is_full() { + state.inline.push((start, name)); + } else { + let mut cache = HashMap::with_capacity(state.inline.len() * 2); + cache.extend(state.inline.drain(..)); + cache.insert(start, name); + state.overflow = Some(cache); + } + } + + fn check_jumps(&self, jumps: usize) -> Result<(), &'static str> { + if self.max_jumps != 0 && jumps > self.max_jumps { + Err("DNS compression jump limit exceeded") + } else { + Ok(()) + } + } + + fn boundary_error(&self, end: usize, truncated: &'static str) -> &'static str { + if end < self.data.len() { + "DNS compression name overlaps its target" + } else { + truncated + } + } + + #[inline] + pub(super) fn read(&mut self, cursor: &mut usize) -> Result { + self.read_inner::(cursor, |_| {}) + } + + #[inline] + pub(super) fn read_with_labels( + &mut self, + cursor: &mut usize, + visit: impl FnMut(&[u8]), + ) -> Result { + self.read_inner::(cursor, visit) + } + + #[inline(always)] + fn read_inner( + &mut self, + cursor: &mut usize, + mut visit: impl FnMut(&[u8]), + ) -> Result { + let start = *cursor; + let mut segment_start = start; + let mut segment_end = self.data.len(); + let mut jumps = 0; + + let mut suffix = 'segments: loop { + let mut position = segment_start; + loop { + #[cfg(test)] + { + self.visited += 1; + } + let length = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS name truncated"))?; + match length { + 0 => { + break 'segments ValidatedName { + wire_end: position + 1, + jumps: 0, + expanded_wire_len: position - segment_start + 1, + first_label: segment_start, + }; + } + _ if length & POINTER_TAG == POINTER_TAG => { + let next = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1)) + .ok_or_else(|| { + self.boundary_error( + segment_end, + "DNS compression pointer truncated", + ) + })?; + let target = (usize::from(length & 0x3f) << 8) | usize::from(next); + if target >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + jumps += 1; + self.check_jumps(jumps)?; + let segment = PendingSegment { + start: segment_start, + wire_end: position + 2, + label_bytes: position - segment_start, + }; + if let Some(cached) = self.cached(target) { + // The cached suffix was validated in a possibly wider range. + // Its first segment must still end BEFORE this name begins. + if cached.wire_end > segment_start { + return Err("DNS compression name overlaps its target"); + } + self.check_jumps(jumps + cached.jumps)?; + if VISIT { + for label in self.labels(cached) { + visit(label); + } + } + break 'segments segment.prepend(cached); + } + let state = self.cache.get_or_insert_with(DnsNameCache::default); + if jumps == 1 { + state.pending.clear(); + state.pending_overflow.clear(); + } + if state.pending.is_full() { + state.pending_overflow.push(segment); + } else { + state.pending.push(segment); + } + segment_end = segment_start; + segment_start = target; + continue 'segments; + } + _ if length & POINTER_TAG != 0 => return Err("Unsupported DNS label encoding"), + _ => { + let end = position + 1 + usize::from(length); + let label = self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1..end)) + .ok_or_else(|| { + self.boundary_error(segment_end, "DNS label truncated") + })?; + if VISIT { + visit(label); + } + position = end; + } + } + } + }; + + // Publish only fully validated suffixes. Failed or partial names cannot + // poison later reads, including the semantic fallback in the same message. + if segment_start == start { + *cursor = suffix.wire_end; + return Ok(suffix); + } + self.remember(segment_start, suffix); + while let Some(segment) = { + let state = self + .cache + .as_mut() + .expect("a traversed suffix has pending segments"); + state.pending_overflow.pop().or_else(|| state.pending.pop()) + } { + let offset = segment.start; + suffix = segment.prepend(suffix); + if offset != start { + self.remember(offset, suffix); + } + } + *cursor = suffix.wire_end; + Ok(suffix) + } + + #[inline] + pub(super) fn skip(&mut self, cursor: &mut usize) -> Result { + let start = *cursor; + let mut position = start; + let mut resume = None; + let mut segment_start = start; + let mut segment_end = self.data.len(); + let direct_limit = if self.max_jumps == 0 { + DIRECT_POINTER_JUMPS + } else { + self.max_jumps.min(DIRECT_POINTER_JUMPS) + }; + let mut jumps = 0; + loop { + let length = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS name truncated"))?; + match length { + 0 => { + *cursor = resume.unwrap_or(position + 1); + // OPT requires a literal root, never an expanded root. + return Ok(resume.is_none() && position == start); + } + _ if length & POINTER_TAG == POINTER_TAG => { + if jumps == direct_limit { + // Cheap short walks avoid cache bookkeeping. The full + // walker enforces smaller configured limits and caches + // longer chains, counting every cached transition. + self.read(cursor)?; + return Ok(false); + } + let next = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1)) + .ok_or_else(|| { + self.boundary_error(segment_end, "DNS compression pointer truncated") + })?; + let target = (usize::from(length & 0x3f) << 8) | usize::from(next); + if target >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + jumps += 1; + if resume.is_none() { + resume = Some(position + 2); + } + segment_end = segment_start; + segment_start = target; + position = target; + } + _ if length & POINTER_TAG != 0 => return Err("Unsupported DNS label encoding"), + _ => { + let end = position + 1 + usize::from(length); + if resume.is_some() && end - segment_start > DIRECT_SUFFIX_BYTES { + // Bound repeated literal-label work too, including RR + // owner names whose expanded size is not materialized. + self.read(cursor)?; + return Ok(false); + } + self.data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1..end)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS label truncated"))?; + position = end; + } + } + } + } + + pub(super) fn labels(&self, name: ValidatedName) -> NameLabels<'_, 'a> { + NameLabels { + decoder: self, + position: name.first_label, + } + } +} + +#[cfg(test)] +#[path = "name_decoder_tests.rs"] +mod tests; + +/// Materialization visits only label-bearing segments. Pointer-only chains have +/// already been validated and reduced to their first label by `read`. +pub(super) struct NameLabels<'decoder, 'data> { + decoder: &'decoder DnsNameDecoder<'data>, + position: usize, +} + +impl<'data> Iterator for NameLabels<'_, 'data> { + type Item = &'data [u8]; + + fn next(&mut self) -> Option { + loop { + let data = self.decoder.data; + let length = data[self.position]; + if length == 0 { + return None; + } + if length & POINTER_TAG == POINTER_TAG { + let target = + (usize::from(length & 0x3f) << 8) | usize::from(data[self.position + 1]); + self.position = self + .decoder + .cached(target) + .expect("every followed pointer target has been validated") + .first_label; + } else { + let start = self.position + 1; + self.position = start + usize::from(length); + return Some(&data[start..self.position]); + } + } + } +} diff --git a/src/dns_processor/name_decoder_tests.rs b/src/dns_processor/name_decoder_tests.rs new file mode 100644 index 0000000..83f0f47 --- /dev/null +++ b/src/dns_processor/name_decoder_tests.rs @@ -0,0 +1,328 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +use super::*; + +fn append_pointer(data: &mut Vec, target: usize) -> usize { + assert!(target <= MAX_POINTER_OFFSET); + let start = data.len(); + data.extend_from_slice(&(0xc000 | target as u16).to_be_bytes()); + start +} + +fn pointer_chain(depth: usize) -> (Vec, Vec) { + let mut data = vec![0]; + let mut offsets = vec![0]; + for _ in 0..depth { + let next = append_pointer(&mut data, *offsets.last().unwrap()); + offsets.push(next); + } + (data, offsets) +} + +#[test] +fn cache_hit_still_rejects_a_suffix_overlapping_its_pointer() { + let mut data = vec![4, b'a', b'b', 0xc0, 0, 0]; + let warm = append_pointer(&mut data, 0); + let mut decoder = DnsNameDecoder::new(&data, 0); + let mut cursor = warm; + decoder.read(&mut cursor).unwrap(); + assert_eq!(decoder.cached(0).unwrap().wire_end, 6); + + let mut overlapping = 3; + assert!(decoder.read(&mut overlapping).is_err()); + assert_eq!(overlapping, 3); +} + +#[test] +fn cached_suffix_depth_counts_toward_the_limit() { + let (data, offsets) = pointer_chain(33); + let mut decoder = DnsNameDecoder::new(&data, 32); + for (depth, &offset) in offsets.iter().take(33).enumerate() { + let mut cursor = offset; + let name = decoder.read(&mut cursor).unwrap(); + assert_eq!(name.jumps, depth); + assert_eq!(cursor, offset + if depth == 0 { 1 } else { 2 }); + } + assert_eq!(decoder.cached(offsets[31]).unwrap().jumps, 31); + assert!(decoder.cached(offsets[32]).is_none()); + + for _ in 0..2 { + let mut cursor = offsets[33]; + assert_eq!( + decoder.read(&mut cursor).unwrap_err(), + "DNS compression jump limit exceeded" + ); + assert_eq!(cursor, offsets[33]); + assert!(decoder.cached(offsets[32]).is_none()); + } + // A failed traversal must not leave pending segments attached to the next name. + assert_eq!(decoder.read(&mut 0).unwrap().jumps, 0); +} + +#[test] +fn unlimited_mode_accepts_the_maximum_backward_pointer_chain() { + let (data, offsets) = pointer_chain(8193); + assert_eq!(offsets[8192], MAX_POINTER_OFFSET); + let mut decoder = DnsNameDecoder::new(&data, 0); + let mut cursor = offsets[8193]; + let name = decoder.read(&mut cursor).unwrap(); + assert_eq!(name.jumps, 8193); + assert_eq!(name.expanded_wire_len, 1); + assert_eq!(cursor, data.len()); + assert_eq!(decoder.labels(name).count(), 0); +} + +#[test] +fn materialization_preserves_labels_across_cached_pointer_only_segments() { + let mut data = b"\x03CoM\0".to_vec(); + let mut target = 0; + for _ in 0..9 { + target = append_pointer(&mut data, target); + } + let example = data.len(); + data.extend_from_slice(b"\x07ExAmPlE"); + append_pointer(&mut data, target); + target = example; + for _ in 0..9 { + target = append_pointer(&mut data, target); + } + let www = data.len(); + data.extend_from_slice(b"\x03WwW"); + append_pointer(&mut data, target); + + let mut decoder = DnsNameDecoder::new(&data, 32); + for start in [target, www, example, www] { + let mut cursor = start; + let name = decoder.read(&mut cursor).unwrap(); + let labels: Vec<_> = decoder.labels(name).collect(); + if start == www { + assert_eq!(labels, vec![&b"WwW"[..], &b"ExAmPlE"[..], &b"CoM"[..]]); + assert_eq!(name.expanded_wire_len, 17); + assert_eq!(name.jumps, 20); + } else { + assert_eq!(labels, vec![&b"ExAmPlE"[..], &b"CoM"[..]]); + } + } +} + +#[test] +fn short_chains_and_repeated_flat_names_use_only_inline_storage() { + let (data, offsets) = pointer_chain(8); + let mut decoder = DnsNameDecoder::new(&data, 32); + let mut cursor = offsets[8]; + decoder.read(&mut cursor).unwrap(); + assert!(decoder.cache.as_ref().unwrap().overflow.is_none()); + assert_eq!( + decoder.cache.as_ref().unwrap().pending_overflow.capacity(), + 0 + ); + assert!(decoder.cache.as_ref().unwrap().pending.is_empty()); + + let mut flat = b"\x07example\x03com\0".to_vec(); + let mut owners = Vec::new(); + for _ in 0..2000 { + owners.push(append_pointer(&mut flat, 0)); + } + let mut decoder = DnsNameDecoder::new(&flat, 32); + for mut owner in owners { + decoder.skip(&mut owner).unwrap(); + } + assert!(decoder.cache.is_none()); +} + +#[test] +fn cached_suffixes_are_local_to_their_message() { + let valid = [1, b'a', 0, 0xc0, 0]; + let invalid = [3, b'a', b'b', 0xc0, 0]; + let mut first = DnsNameDecoder::new(&valid, 0); + let name = first.read(&mut 3).unwrap(); + assert_eq!(first.labels(name).collect::>(), vec![&b"a"[..]]); + assert!(first.cached(0).is_some()); + + let mut second = DnsNameDecoder::new(&invalid, 0); + assert!(second.cached(0).is_none()); + assert!(second.read(&mut 3).is_err()); + assert!(second.cached(0).is_none()); +} + +#[test] +fn repeated_deep_names_require_linear_validation_work() { + let (mut data, offsets) = pointer_chain(1000); + let mut repeated = Vec::new(); + for _ in 0..4000 { + repeated.push(append_pointer(&mut data, offsets[1000])); + } + let mut decoder = DnsNameDecoder::new(&data, 0); + for (index, &offset) in repeated.iter().enumerate() { + let before = decoder.visited; + let mut cursor = offset; + let name = decoder.read(&mut cursor).unwrap(); + assert_eq!(name.jumps, 1001); + assert_eq!(name.expanded_wire_len, 1); + if index > 0 { + assert_eq!(decoder.visited - before, 1); + } + } + assert_eq!(decoder.visited, 1001 + repeated.len()); +} + +#[test] +fn compressed_root_does_not_become_a_literal_root_on_cache_hit() { + let data = [0, 0xc0, 0, 0xc0, 0]; + let mut decoder = DnsNameDecoder::new(&data, 32); + assert!(decoder.skip(&mut 0).unwrap()); + assert!(!decoder.skip(&mut 1).unwrap()); + decoder.read(&mut 1).unwrap(); + assert!(decoder.cached(0).is_some()); + assert!(!decoder.skip(&mut 3).unwrap()); +} + +#[test] +fn shallow_skip_fallback_enforces_the_second_jump_limit() { + let (data, offsets) = pointer_chain(2); + for warm_cache in [false, true] { + let mut decoder = DnsNameDecoder::new(&data, 1); + if warm_cache { + let mut cursor = offsets[1]; + decoder.read(&mut cursor).unwrap(); + } + let mut one_jump = offsets[1]; + assert!(!decoder.skip(&mut one_jump).unwrap()); + assert_eq!(one_jump, offsets[1] + 2); + + let mut two_jumps = offsets[2]; + assert_eq!( + decoder.skip(&mut two_jumps).unwrap_err(), + "DNS compression jump limit exceeded" + ); + assert_eq!(two_jumps, offsets[2]); + } +} + +#[test] +fn shallow_skip_reports_overlap_consistently_with_cold_and_warm_cache() { + let mut data = vec![4, b'a', b'b', 0xc0, 0, 0]; + let warm = append_pointer(&mut data, 0); + for warm_cache in [false, true] { + let mut decoder = DnsNameDecoder::new(&data, 0); + if warm_cache { + let mut cursor = warm; + decoder.read(&mut cursor).unwrap(); + } + let mut overlap = 3; + assert_eq!( + decoder.skip(&mut overlap).unwrap_err(), + "DNS compression name overlaps its target" + ); + assert_eq!(overlap, 3); + } +} + +#[test] +fn shallow_skip_memoizes_long_literal_suffixes() { + let mut data = Vec::new(); + for _ in 0..128 { + data.extend_from_slice(b"\x01x"); + } + data.push(0); + let mut owners = Vec::new(); + for _ in 0..2000 { + owners.push(append_pointer(&mut data, 0)); + } + + let mut decoder = DnsNameDecoder::new(&data, 1); + for (index, &owner) in owners.iter().enumerate() { + let before = decoder.visited; + let mut cursor = owner; + assert!(!decoder.skip(&mut cursor).unwrap()); + assert_eq!(cursor, owner + 2); + assert_eq!(decoder.cached(0).unwrap().expanded_wire_len, 257); + if index > 0 { + assert_eq!(decoder.visited - before, 1); + } + } + assert_eq!(decoder.cache.as_ref().unwrap().inline.len(), 1); + assert!(decoder.cache.as_ref().unwrap().overflow.is_none()); +} + +#[test] +fn streamed_labels_match_materialization_with_cold_and_warm_suffixes() { + let mut data = b"\x03CoM\0".to_vec(); + let mut target = 0; + for _ in 0..10 { + target = append_pointer(&mut data, target); + } + let example = data.len(); + data.extend_from_slice(b"\x07ExAmPlE"); + append_pointer(&mut data, target); + let www = data.len(); + data.extend_from_slice(b"\x03WwW"); + append_pointer(&mut data, example); + + let mut decoder = DnsNameDecoder::new(&data, 32); + for _ in 0..2 { + let mut cursor = www; + let mut visited_labels = Vec::new(); + let name = decoder + .read_with_labels(&mut cursor, |label| visited_labels.push(label.to_vec())) + .unwrap(); + assert_eq!(cursor, data.len()); + assert_eq!(name.jumps, 12); + assert_eq!(name.expanded_wire_len, 17); + assert_eq!( + visited_labels, + vec![b"WwW".to_vec(), b"ExAmPlE".to_vec(), b"CoM".to_vec()] + ); + assert_eq!( + decoder.labels(name).map(<[u8]>::to_vec).collect::>(), + visited_labels + ); + } +} + +#[test] +fn shallow_skip_switches_to_cached_validation_after_eight_jumps() { + let (data, offsets) = pointer_chain(9); + for (limit, accepts_ninth) in [(8, false), (9, true), (32, true), (0, true)] { + let mut decoder = DnsNameDecoder::new(&data, limit); + let mut eight_jumps = offsets[8]; + assert!(!decoder.skip(&mut eight_jumps).unwrap()); + assert_eq!(eight_jumps, offsets[8] + 2); + assert!(decoder.cache.is_none()); + assert_eq!(decoder.visited, 0); + + for _ in 0..2 { + let mut nine_jumps = offsets[9]; + assert_eq!(decoder.skip(&mut nine_jumps).is_ok(), accepts_ninth); + assert_eq!(nine_jumps, offsets[9] + if accepts_ninth { 2 } else { 0 }); + } + if accepts_ninth { + assert_eq!(decoder.cached(offsets[8]).unwrap().jumps, 8); + } + } +} + +#[test] +fn shallow_literal_threshold_keeps_short_suffixes_direct_and_caches_longer_ones() { + for extra_label in [false, true] { + let mut data = vec![63]; + data.extend_from_slice(&[b'x'; 63]); + if extra_label { + data.extend_from_slice(b"\x01y"); + } + data.push(0); + let owner = append_pointer(&mut data, 0); + let mut decoder = DnsNameDecoder::new(&data, 1); + for _ in 0..2 { + let mut cursor = owner; + assert!(!decoder.skip(&mut cursor).unwrap()); + assert_eq!(cursor, owner + 2); + assert_eq!(decoder.cached(0).is_some(), extra_label); + } + } +} diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index 306ed7e..01be8ac 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -7,6 +7,7 @@ use hickory_proto::ProtoError; use hickory_proto::op::Header; +#[cfg(test)] use hickory_proto::op::Message; use hickory_proto::op::Query; use hickory_proto::op::ResponseCode as HickoryResponseCode; @@ -16,6 +17,7 @@ use hickory_proto::serialize::binary::{BinDecodable, BinDecoder, DecodeError}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use super::DnsProcessor; +use super::name_decoder::DnsNameDecoder; use super::types::{ProcessedDnsRecord, VlanContext}; use crate::custom_types::{DnsNameBuf, DnsNameTooLong, ProtoResponseCode}; @@ -42,9 +44,6 @@ const IPV4_DONT_FRAGMENT: u16 = 0x4000; const IPV4_RESERVED_FLAG: u16 = 0x8000; const IPV4_FRAGMENT_OFFSET_MASK: u16 = 0x1fff; const DNS_PORT: u16 = 53; -const DNS_POINTER_MASK: u8 = 0b1100_0000; -const DNS_POINTER_TAG: u8 = 0b1100_0000; -const DNS_LABEL_LEN_MASK: u8 = 0b0011_1111; const DNS_RESOURCE_RECORD_FIXED_LEN: usize = 10; const DNS_OPT_RECORD_TYPE: u16 = 41; const DNS_TSIG_RECORD_TYPE: u16 = 250; @@ -452,15 +451,18 @@ impl DnsProcessor { decode_extended_rcode: bool, partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { + let mut names = DnsNameDecoder::new(dns_data, self.max_dns_compression_jumps); if self.dns_wire_fast_path { Self::decode_dns_questions_fast( dns_data, + &mut names, decode_extended_rcode, partial_first_ipv4_fragment, ) .or_else(|_| { Self::decode_dns_questions_hickory( dns_data, + &mut names, decode_extended_rcode, partial_first_ipv4_fragment, ) @@ -468,6 +470,7 @@ impl DnsProcessor { } else { Self::decode_dns_questions_hickory( dns_data, + &mut names, decode_extended_rcode, partial_first_ipv4_fragment, ) @@ -489,8 +492,15 @@ impl DnsProcessor { opcode == 0 && query_count > 1 } + fn question_capacity(dns_len: usize, count: usize) -> usize { + // Even a root question occupies five bytes. An untrusted count alone + // must not reserve tens of MiB for presentation-form names. + count.min(dns_len.saturating_sub(DNS_HEADER_LEN) / 5) + } + fn decode_dns_questions_fast( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, decode_extended_rcode: bool, partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { @@ -512,9 +522,9 @@ impl DnsProcessor { } let mut cursor = DNS_HEADER_LEN; - let mut queries = Vec::with_capacity(query_count); + let mut queries = Vec::with_capacity(Self::question_capacity(dns_data.len(), query_count)); for _ in 0..query_count { - let name = Self::read_wire_domain_name(dns_data, &mut cursor)?; + let name = Self::read_wire_domain_name(names, &mut cursor)?; let query_type = HickoryRecordType::from(Self::parse_u16_at( dns_data, cursor, @@ -554,6 +564,7 @@ impl DnsProcessor { if partial_first_ipv4_fragment { partial_response_code = Self::decode_partial_response_code_with_sections( dns_data, + names, cursor, section_counts, low_response_code, @@ -564,6 +575,7 @@ impl DnsProcessor { { response_code = Self::decode_response_code_with_sections( dns_data, + names, cursor, section_counts, low_response_code, @@ -582,6 +594,7 @@ impl DnsProcessor { fn decode_dns_questions_hickory( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, decode_extended_rcode: bool, partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { @@ -591,8 +604,20 @@ impl DnsProcessor { if Self::invalid_question_count(opcode, header.counts.queries as usize) { return Err(DnsQuestionDecodeError::Invalid); } - let queries = Message::read_queries(&mut decoder, header.counts.queries as usize) - .map_err(Self::classify_hickory_question_error)?; + let mut cursor = decoder.index(); + let mut queries = Vec::with_capacity(Self::question_capacity( + dns_data.len(), + header.counts.queries as usize, + )); + for _ in 0..header.counts.queries { + let query = Self::read_hickory_question(dns_data, names, &mut cursor)?; + queries.push(DecodedDnsQuestion { + name: Self::format_domain_name(query.name()) + .map_err(|_| DnsQuestionDecodeError::Invalid)?, + query_type: query.query_type(), + query_class: query.query_class().into(), + }); + } let mut response_code = ProtoResponseCode::from(header.response_code); let mut partial_response_code = None; if decode_extended_rcode { @@ -604,7 +629,8 @@ impl DnsProcessor { if partial_first_ipv4_fragment { partial_response_code = Self::decode_partial_response_code_with_sections( dns_data, - decoder.index(), + names, + cursor, section_counts, header.response_code.low(), )?; @@ -614,7 +640,8 @@ impl DnsProcessor { { response_code = Self::decode_response_code_with_sections( dns_data, - decoder.index(), + names, + cursor, section_counts, header.response_code.low(), )?; @@ -628,22 +655,13 @@ impl DnsProcessor { response_code, partial_response_code, }, - queries - .into_iter() - .map(|query: Query| { - Ok(DecodedDnsQuestion { - name: Self::format_domain_name(query.name()) - .map_err(|_| DnsQuestionDecodeError::Invalid)?, - query_type: query.query_type(), - query_class: query.query_class().into(), - }) - }) - .collect::, DnsQuestionDecodeError>>()?, + queries, )) } fn decode_response_code_with_sections( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, mut cursor: usize, section_counts: DnsSectionCounts, low_response_code: u8, @@ -651,10 +669,23 @@ impl DnsProcessor { let response_code = ProtoResponseCode::from(HickoryResponseCode::from(0, low_response_code)); - Self::skip_dns_resource_records(dns_data, &mut cursor, section_counts.answers, true)?; - Self::skip_dns_resource_records(dns_data, &mut cursor, section_counts.authorities, true)?; + Self::skip_dns_resource_records( + dns_data, + names, + &mut cursor, + section_counts.answers, + true, + )?; + Self::skip_dns_resource_records( + dns_data, + names, + &mut cursor, + section_counts.authorities, + true, + )?; let additional_fields = Self::read_additional_response_code_fields( dns_data, + names, &mut cursor, section_counts.additionals, )?; @@ -677,6 +708,7 @@ impl DnsProcessor { fn decode_partial_response_code_with_sections( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: usize, section_counts: DnsSectionCounts, low_response_code: u8, @@ -687,7 +719,7 @@ impl DnsProcessor { section_counts.answers + section_counts.authorities + section_counts.additionals; let mut additional_fields = AdditionalResponseCodeFields::default(); for index in 0..record_count { - match Self::read_dns_resource_record_meta(dns_data, &mut boundary_cursor) { + match Self::read_dns_resource_record_meta(dns_data, names, &mut boundary_cursor) { Ok(meta) => { if index < section_counts.answers + section_counts.authorities { if meta.record_type == DNS_OPT_RECORD_TYPE { @@ -698,6 +730,7 @@ impl DnsProcessor { index - section_counts.answers - section_counts.authorities; Self::include_additional_response_code_fields( dns_data, + names, &mut additional_fields, &meta, additional_index, @@ -719,6 +752,7 @@ impl DnsProcessor { // context, including OPT and TSIG, using the same validation as full datagrams. Self::decode_response_code_with_sections( dns_data, + names, cursor, section_counts, low_response_code, @@ -739,12 +773,13 @@ impl DnsProcessor { fn skip_dns_resource_records( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, count: usize, reject_opt: bool, ) -> Result<(), &'static str> { for _ in 0..count { - let meta = Self::read_dns_resource_record_meta(dns_data, cursor)?; + let meta = Self::read_dns_resource_record_meta(dns_data, names, cursor)?; if reject_opt && meta.record_type == DNS_OPT_RECORD_TYPE { return Err("OPT record outside additional section"); } @@ -755,14 +790,16 @@ impl DnsProcessor { fn read_additional_response_code_fields( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, additional_count: usize, ) -> Result { let mut fields = AdditionalResponseCodeFields::default(); for index in 0..additional_count { - let meta = Self::read_dns_resource_record_meta(dns_data, cursor)?; + let meta = Self::read_dns_resource_record_meta(dns_data, names, cursor)?; Self::include_additional_response_code_fields( dns_data, + names, &mut fields, &meta, index, @@ -775,6 +812,7 @@ impl DnsProcessor { fn include_additional_response_code_fields( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, fields: &mut AdditionalResponseCodeFields, meta: &DnsResourceRecordMeta, index: usize, @@ -797,18 +835,22 @@ impl DnsProcessor { if index + 1 != additional_count { return Err("TSIG record must be last additional"); } - fields.tsig_error = Some(Self::read_tsig_error(dns_data, meta)?); + fields.tsig_error = Some(Self::read_tsig_error(dns_data, names, meta)?); } _ => {} } Ok(()) } - fn read_tsig_error(dns_data: &[u8], meta: &DnsResourceRecordMeta) -> Result { + fn read_tsig_error( + dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, + meta: &DnsResourceRecordMeta, + ) -> Result { let mut cursor = meta.rdata_start; let rdata_end = meta.rdata_end; - Self::skip_wire_domain_name_in_range(dns_data, &mut cursor, rdata_end)?; + Self::skip_wire_domain_name_in_range(names, &mut cursor, rdata_end)?; Self::skip_bytes_in_range(&mut cursor, rdata_end, 6 + 2)?; let mac_size = usize::from(Self::parse_u16_at( dns_data, @@ -835,9 +877,10 @@ impl DnsProcessor { fn read_dns_resource_record_meta( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, ) -> Result { - let owner_is_root = Self::skip_wire_domain_name(dns_data, cursor)?; + let owner_is_root = names.skip(cursor)?; let fixed_start = *cursor; let fixed_end = fixed_start .checked_add(DNS_RESOURCE_RECORD_FIXED_LEN) @@ -866,7 +909,7 @@ impl DnsProcessor { } fn skip_wire_domain_name_in_range( - dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, limit: usize, ) -> Result<(), &'static str> { @@ -874,7 +917,7 @@ impl DnsProcessor { return Err("DNS name truncated"); } - Self::skip_wire_domain_name(dns_data, cursor)?; + names.skip(cursor)?; if *cursor > limit { return Err("DNS name truncated"); } @@ -895,148 +938,67 @@ impl DnsProcessor { Ok(()) } - fn skip_wire_domain_name(dns_data: &[u8], cursor: &mut usize) -> Result { - let start = *cursor; - let mut position = *cursor; - let mut resume_position = None; - let mut segment_start = position; - let mut segment_end = dns_data.len(); - - loop { - let length = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position)) - .ok_or("DNS name truncated")?; - - match length { - 0 => { - position += 1; - *cursor = resume_position.unwrap_or(position); - return Ok(resume_position.is_none() && position == start + 1); - } - _ if (length & DNS_POINTER_MASK) == DNS_POINTER_TAG => { - let next = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1)) - .ok_or("DNS compression pointer truncated")?; - let offset = - (((length & DNS_LABEL_LEN_MASK) as usize) << 8) | usize::from(next); - - if offset >= segment_start { - return Err("DNS compression pointer is not prior to name"); - } - - if resume_position.is_none() { - resume_position = Some(position + 2); - } - - // Every pointer strictly decreases this boundary, so traversal is - // finite without rejecting valid chains after an arbitrary hop count. - segment_end = segment_start; - segment_start = offset; - position = offset; - } - _ if (length & DNS_POINTER_MASK) != 0 => { - return Err("Unsupported DNS label encoding"); - } - _ => { - let label_len = usize::from(length); - dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1..position + 1 + label_len)) - .ok_or("DNS label truncated")?; - position += 1 + label_len; - } - } - } - } - fn read_wire_domain_name( - dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, ) -> Result { let mut output = DnsNameBuf::default(); - let mut position = *cursor; - let mut resume_position = None; - let mut wrote_label = false; - let mut expanded_wire_len = 1_usize; - let mut oversized = false; - let mut segment_start = position; - let mut segment_end = dns_data.len(); - - loop { - let length = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position)) - .ok_or("DNS name truncated")?; - - match length { - 0 => { - position += 1; - if oversized { - return Err(DnsQuestionDecodeError::OversizedQname); - } - if !wrote_label { - output - .try_push('.') - .map_err(|_| DnsQuestionDecodeError::Invalid)?; - } - - *cursor = resume_position.unwrap_or(position); - return Ok(output); - } - _ if (length & DNS_POINTER_MASK) == DNS_POINTER_TAG => { - let next = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1)) - .ok_or("DNS compression pointer truncated")?; - let offset = - (((length & DNS_LABEL_LEN_MASK) as usize) << 8) | usize::from(next); - - // Each pointer must refer to an earlier, nonoverlapping name, - // matching Hickory's question decoder (RFC 1035 section 4.1.4). - if offset >= segment_start { - return Err(DnsQuestionDecodeError::Invalid); - } - - if resume_position.is_none() { - resume_position = Some(position + 2); - } - - // Strictly decreasing segment starts also bound pointer-only chains. - segment_end = segment_start; - segment_start = offset; - position = offset; - } - _ if (length & DNS_POINTER_MASK) != 0 => { - return Err(DnsQuestionDecodeError::Invalid); - } - _ => { - let label_len = usize::from(length); - let label = dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1..position + 1 + label_len)) - .ok_or("DNS label truncated")?; - - expanded_wire_len = expanded_wire_len.saturating_add(1 + label_len); - oversized |= expanded_wire_len > Name::MAX_LENGTH; - - if !oversized { - if wrote_label { - output - .try_push('.') - .map_err(|_| DnsQuestionDecodeError::Invalid)?; - } - if !Self::write_label_ascii(label, &mut output) { - return Err(DnsQuestionDecodeError::Invalid); - } - } - - wrote_label = true; - position += 1 + label_len; + let mut presentation_valid = true; + let name = names.read_with_labels(cursor, |label| { + if presentation_valid { + if !output.as_str().is_empty() { + presentation_valid = output.try_push('.').is_ok(); } + presentation_valid &= Self::write_label_ascii(label, &mut output); } + })?; + if name.expanded_wire_len > Name::MAX_LENGTH { + return Err(DnsQuestionDecodeError::OversizedQname); + } + if !presentation_valid { + return Err(DnsQuestionDecodeError::Invalid); + } + if output.as_str().is_empty() { + output + .try_push('.') + .map_err(|_| DnsQuestionDecodeError::Invalid)?; } + Ok(output) + } + + fn read_hickory_question( + dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, + cursor: &mut usize, + ) -> Result { + let start = *cursor; + let name = names.read(cursor)?; + if name.expanded_wire_len > Name::MAX_LENGTH { + return Err(DnsQuestionDecodeError::OversizedQname); + } + let fields = dns_data + .get(*cursor..*cursor + 4) + .ok_or("DNS question truncated")?; + *cursor += 4; + if name.jumps == 0 { + return Query::read(&mut BinDecoder::new(&dns_data[start..*cursor])) + .map_err(|error| Self::classify_hickory_question_error(error.into())); + } + + // Hickory keeps semantic ownership of questions, but never recursively + // follows the original chain again. The validated name fits on the stack. + let mut expanded = [0_u8; Name::MAX_LENGTH + 4]; + let mut end = 0; + for label in names.labels(name) { + expanded[end] = label.len() as u8; + end += 1; + expanded[end..end + label.len()].copy_from_slice(label); + end += label.len(); + } + end += 1; // terminating root, already zeroed + expanded[end..end + 4].copy_from_slice(fields); + Query::read(&mut BinDecoder::new(&expanded[..end + 4])) + .map_err(|error| Self::classify_hickory_question_error(error.into())) } #[inline] @@ -1735,8 +1697,11 @@ mod protocol_regression_tests { #[test] fn compression_rejects_overlapping_names_but_accepts_prior_questions() { let overlap = [4, b'a', b'b', 0xc0, 0, 0]; - assert!(DnsProcessor::read_wire_domain_name(&overlap, &mut 3).is_err()); - assert!(DnsProcessor::skip_wire_domain_name(&overlap, &mut 3).is_err()); + assert!( + DnsProcessor::read_wire_domain_name(&mut DnsNameDecoder::new(&overlap, 0), &mut 3) + .is_err() + ); + assert!(DnsNameDecoder::new(&overlap, 0).skip(&mut 3).is_err()); assert!(Name::read(&mut BinDecoder::new(&overlap).clone(3)).is_err()); let mut dns = question(0x0900, 1); @@ -1751,34 +1716,150 @@ mod protocol_regression_tests { } } - #[test] - fn response_accepts_long_backward_compression_chains() { + fn response_with_compression_chain(count: u16) -> (Vec, usize) { let mut dns = question(0x8180, 1); - dns[6..8].copy_from_slice(&33_u16.to_be_bytes()); + dns[6..8].copy_from_slice(&count.to_be_bytes()); let mut previous_owner = DNS_HEADER_LEN; let mut last_owner = 0; - for index in 0..33_u8 { + for index in 0..count { last_owner = dns.len(); dns.extend_from_slice(&(0xc000 | previous_owner as u16).to_be_bytes()); - // Distinct TXT records keep the complete response below 512 bytes. - dns.extend_from_slice(&[0, 16, 0, 1, 0, 0, 0, 60, 0, 2, 1, b'a' + index]); + dns.extend_from_slice(&[0, 16, 0, 1, 0, 0, 0, 60, 0, 2, 1, index as u8]); previous_owner = last_owner; } - assert_eq!(Message::from_vec(&dns).unwrap().answers.len(), 33); + (dns, last_owner) + } + #[test] + fn compression_limit_counts_cached_suffixes_in_both_decoders() { + for count in [1, 2, 32, 33, 256] { + let (dns, _) = response_with_compression_chain(count); + if count == 33 { + assert_eq!(Message::from_vec(&dns).unwrap().answers.len(), 33); + } + for limit in [1, 32, 33, 0] { + for processor in processors() { + let fast = processor.dns_wire_fast_path; + let result = processor + .with_max_dns_compression_jumps(limit) + .process_packet_batch(&ipv4(&dns, true, 53000), 100); + assert_eq!( + result.is_some(), + limit == 0 || usize::from(count) <= limit, + "depth={count}, limit={limit}, fast={fast}" + ); + } + } + } + let (dns, _) = response_with_compression_chain(33); for processor in processors() { - let records = processor - .process_packet_batch(&ipv4(&dns, true, 53000), 100) - .expect("valid response with 33 backward pointers"); - assert_eq!(records.len(), 1); - assert_eq!(records[0].response_code.as_u16(), 0); + assert!( + processor + .process_packet_batch(&ipv4(&dns, true, 53000), 100) + .is_none(), + "default limit must be 32, including after fast-path fallback" + ); + } + } + + #[test] + fn compression_limit_applies_to_questions_and_unlimited_avoids_recursive_decoding() { + for count in [32, 33, 2700] { + let mut dns = question(0x0900, 1); // IQUERY permits multiple questions. + dns[4..6].copy_from_slice(&(count + 1_u16).to_be_bytes()); + let mut previous = DNS_HEADER_LEN; + for _ in 0..count { + let current = dns.len(); + dns.extend_from_slice(&(0xc000 | previous as u16).to_be_bytes()); + dns.extend_from_slice(&[0, 1, 0, 1]); + previous = current; + } + for limit in [32, 0] { + for processor in processors() { + let result = processor + .with_max_dns_compression_jumps(limit) + .process_packet_batch(&ipv4(&dns, false, 53000), 0); + assert_eq!(result.is_some(), limit == 0 || count <= 32); + if let Some(records) = result { + assert_eq!(records.len(), usize::from(count) + 1); + assert!( + records + .iter() + .all(|record| record.name.as_str() == "example.com") + ); + } + } + } + } + } + + #[test] + fn compression_limit_applies_to_tsig_algorithm_and_partial_responses() { + let (mut dns, last_owner) = response_with_compression_chain(32); + dns[10..12].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0, 0, 250, 0, 255, 0, 0, 0, 0, 0, 18]); + dns.extend_from_slice(&(0xc000 | last_owner as u16).to_be_bytes()); + dns.extend_from_slice(&[0; 12]); // time, fudge, MAC length and original ID + dns.extend_from_slice(&[0, 16, 0, 0]); // TSIG error and Other Data length + for limit in [32, 33, 0] { + for processor in processors() { + let result = processor + .with_max_dns_compression_jumps(limit) + .decode_dns_questions(&dns, true, false); + assert_eq!(result.is_ok(), limit != 32); + if let Ok((header, _)) = result { + assert_eq!(header.response_code.as_u16(), 16); + } + } + } + let (mut partial, last_owner) = response_with_compression_chain(33); + partial.truncate(last_owner + 2); // complete owner, absent RR fields/data + for processor in processors() { + assert!(matches!( + processor.decode_dns_questions(&partial, true, true), + Err(DnsQuestionDecodeError::Invalid) + )); + let (header, _) = processor + .with_max_dns_compression_jumps(0) + .decode_dns_questions(&partial, true, true) + .unwrap(); + assert_eq!(header.partial_response_code.unwrap().as_u16(), 0); + } + } + + #[test] + fn cached_expanded_root_does_not_allow_compressed_opt_owner() { + let mut dns = encode_dns_header(0xbeef, 0x8180, 1); + dns[6..8].copy_from_slice(&1_u16.to_be_bytes()); + dns[10..12].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0, 0, 1, 0, 1]); // root question + dns.extend_from_slice(&[0xc0, 12, 0, 16, 0, 1, 0, 0, 0, 0, 0, 1, 0]); + dns.extend_from_slice(&[0xc0, 12, 0, 41, 4, 208, 0, 0, 0, 0, 0, 0]); + for processor in processors() { + assert!( + processor + .process_packet_batch(&ipv4(&dns, true, 53000), 0) + .is_none() + ); + } + } + + #[test] + fn overlapping_compression_is_not_treated_as_an_incomplete_fragment() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&2_u16.to_be_bytes()); + dns.extend_from_slice(&[0xc0, 12, 0, 16, 0, 1, 0, 0, 0, 0, 0, 3]); + let target = dns.len(); + dns.extend_from_slice(&[4, b'a', b'b']); + // The preceding bytes pretend to be a label crossing this owner's + // pointer. Those bytes are present; this is overlap, not missing data. + dns.extend_from_slice(&(0xc000 | target as u16).to_be_bytes()); + for processor in processors() { + assert!(matches!( + processor.decode_dns_questions(&dns, true, true), + Err(DnsQuestionDecodeError::Invalid) + )); } - assert_eq!( - DnsProcessor::read_wire_domain_name(&dns, &mut last_owner) - .unwrap() - .as_str(), - "example.com" - ); } #[test] @@ -1791,12 +1872,18 @@ mod protocol_regression_tests { ] { let mut read_cursor = offset; assert!( - DnsProcessor::read_wire_domain_name(bytes, &mut read_cursor).is_err(), + DnsProcessor::read_wire_domain_name( + &mut DnsNameDecoder::new(bytes, 0), + &mut read_cursor + ) + .is_err(), "{case}" ); let mut skip_cursor = offset; assert!( - DnsProcessor::skip_wire_domain_name(bytes, &mut skip_cursor).is_err(), + DnsNameDecoder::new(bytes, 0) + .skip(&mut skip_cursor) + .is_err(), "{case}" ); } diff --git a/src/parquet_writer.rs b/src/parquet_writer.rs index 1147ec9..2fd5e37 100644 --- a/src/parquet_writer.rs +++ b/src/parquet_writer.rs @@ -387,6 +387,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, }; @@ -427,6 +428,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, }; @@ -467,6 +469,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, }; @@ -509,6 +512,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, }; @@ -550,6 +554,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, }; @@ -616,6 +621,7 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, allow_fragments: false, full_fragments: false, }; From 2462d718c58a49c15ed3d8a9f6a547326e09502b Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 07:41:52 +0300 Subject: [PATCH 23/28] Keep short DNS name validation in the hot caller path --- src/dns_processor/name_decoder.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/dns_processor/name_decoder.rs b/src/dns_processor/name_decoder.rs index d7171cd..0784f3c 100644 --- a/src/dns_processor/name_decoder.rs +++ b/src/dns_processor/name_decoder.rs @@ -260,7 +260,7 @@ impl<'a> DnsNameDecoder<'a> { Ok(suffix) } - #[inline] + #[inline(always)] pub(super) fn skip(&mut self, cursor: &mut usize) -> Result { let start = *cursor; let mut position = start; @@ -290,8 +290,7 @@ impl<'a> DnsNameDecoder<'a> { // Cheap short walks avoid cache bookkeeping. The full // walker enforces smaller configured limits and caches // longer chains, counting every cached transition. - self.read(cursor)?; - return Ok(false); + return self.skip_cached(cursor); } let next = *self .data @@ -318,8 +317,7 @@ impl<'a> DnsNameDecoder<'a> { if resume.is_some() && end - segment_start > DIRECT_SUFFIX_BYTES { // Bound repeated literal-label work too, including RR // owner names whose expanded size is not materialized. - self.read(cursor)?; - return Ok(false); + return self.skip_cached(cursor); } self.data .get(..segment_end) @@ -331,6 +329,12 @@ impl<'a> DnsNameDecoder<'a> { } } + #[inline(never)] + fn skip_cached(&mut self, cursor: &mut usize) -> Result { + self.read(cursor)?; + Ok(false) + } + pub(super) fn labels(&self, name: ValidatedName) -> NameLabels<'_, 'a> { NameLabels { decoder: self, From 400acc5c3c1ca74b958b42c4fe20f3234abe2fce Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 08:00:00 +0300 Subject: [PATCH 24/28] Avoid cache bookkeeping for simple DNS question and owner names --- docs/architecture.md | 2 ++ src/dns_processor/name_decoder.rs | 13 ++++---- src/dns_processor/parser.rs | 54 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7b25d85..04b4149 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -239,6 +239,8 @@ or second encoding of canonical addresses and ports. pointer-only segments. Compressed questions are passed to Hickory already expanded, avoiding a second recursive traversal without replacing Hickory's question semantics. Literal-root validation for OPT remains distinct from a compressed name that expands to root. + The optional fast path keeps a single-pass formatter for uncompressed questions; seeing a + pointer restarts validation at the original name offset through the shared cached decoder. For ordinary QUERY messages (OPCODE 0), more than one question is rejected under RFC 9619. A complete response cannot claim answer or authority records that are absent from its DNS payload, whether or not it has additional records. TSIG status extraction consumes and diff --git a/src/dns_processor/name_decoder.rs b/src/dns_processor/name_decoder.rs index 0784f3c..96e7932 100644 --- a/src/dns_processor/name_decoder.rs +++ b/src/dns_processor/name_decoder.rs @@ -75,6 +75,10 @@ impl<'a> DnsNameDecoder<'a> { } } + pub(super) fn data(&self) -> &'a [u8] { + self.data + } + fn cached(&self, start: usize) -> Option { let state = self.cache.as_ref()?; if let Some(cache) = &state.overflow { @@ -267,11 +271,6 @@ impl<'a> DnsNameDecoder<'a> { let mut resume = None; let mut segment_start = start; let mut segment_end = self.data.len(); - let direct_limit = if self.max_jumps == 0 { - DIRECT_POINTER_JUMPS - } else { - self.max_jumps.min(DIRECT_POINTER_JUMPS) - }; let mut jumps = 0; loop { let length = *self @@ -286,7 +285,9 @@ impl<'a> DnsNameDecoder<'a> { return Ok(resume.is_none() && position == start); } _ if length & POINTER_TAG == POINTER_TAG => { - if jumps == direct_limit { + // Every setting permits the first jump. Defer policy work + // until nested compression, keeping literal/direct names cheap. + if jumps != 0 && (jumps == DIRECT_POINTER_JUMPS || jumps == self.max_jumps) { // Cheap short walks avoid cache bookkeeping. The full // walker enforces smaller configured limits and caches // longer chains, counting every cached transition. diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index 01be8ac..cf89bda 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -941,6 +941,60 @@ impl DnsProcessor { fn read_wire_domain_name( names: &mut DnsNameDecoder<'_>, cursor: &mut usize, + ) -> Result { + // Most questions contain no compression at all. Keep their original + // single-pass formatter free of cache metadata and callback overhead. + // A pointer restarts through the shared decoder from the original cursor. + let dns_data = names.data(); + let mut position = *cursor; + let mut output = DnsNameBuf::default(); + let mut expanded_wire_len = 1_usize; + let mut oversized = false; + loop { + let length = *dns_data.get(position).ok_or("DNS name truncated")?; + match length { + 0 => { + if oversized { + return Err(DnsQuestionDecodeError::OversizedQname); + } + if output.as_str().is_empty() { + output + .try_push('.') + .map_err(|_| DnsQuestionDecodeError::Invalid)?; + } + *cursor = position + 1; + return Ok(output); + } + _ if length & 0xc0 == 0xc0 => { + return Self::read_compressed_wire_domain_name(names, cursor); + } + _ if length & 0xc0 != 0 => return Err(DnsQuestionDecodeError::Invalid), + _ => { + let end = position + 1 + usize::from(length); + let label = dns_data + .get(position + 1..end) + .ok_or("DNS label truncated")?; + expanded_wire_len = expanded_wire_len.saturating_add(1 + label.len()); + oversized |= expanded_wire_len > Name::MAX_LENGTH; + if !oversized { + if !output.as_str().is_empty() { + output + .try_push('.') + .map_err(|_| DnsQuestionDecodeError::Invalid)?; + } + if !Self::write_label_ascii(label, &mut output) { + return Err(DnsQuestionDecodeError::Invalid); + } + } + position = end; + } + } + } + } + + fn read_compressed_wire_domain_name( + names: &mut DnsNameDecoder<'_>, + cursor: &mut usize, ) -> Result { let mut output = DnsNameBuf::default(); let mut presentation_valid = true; From c5fe0f7ff78ecd78ef0ea27cf4190da5014a0635 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 08:10:52 +0300 Subject: [PATCH 25/28] Add repeated DNS compression benchmarks and measured performance report --- benches/README.md | 4 + benches/dns-compression-benchmark.py | 282 +++++++++++++++++ benches/dns-compression.md | 295 ++++++++++++++++++ benches/dns-compression/build-scan.py | 57 ++++ benches/dns-compression/scan.rs | 165 ++++++++++ .../dns-compression/uncached-control.patch | 64 ++++ 6 files changed, 867 insertions(+) create mode 100644 benches/dns-compression-benchmark.py create mode 100644 benches/dns-compression.md create mode 100644 benches/dns-compression/build-scan.py create mode 100644 benches/dns-compression/scan.rs create mode 100644 benches/dns-compression/uncached-control.patch diff --git a/benches/README.md b/benches/README.md index 418f3d6..96b2806 100644 --- a/benches/README.md +++ b/benches/README.md @@ -9,6 +9,10 @@ This directory is the canonical location for repeatable performance measurements comparisons. - `protocol-correctness-audit.md`: final validation, restored throughput and measured memory costs of the 2026-09-05 protocol and matcher fixes, with earlier experiments identified separately. +- `dns-compression.md`: configurable compression limits, message-local suffix caching, + repeated performance measurements and reproduction instructions. +- `dns-compression-benchmark.py` and `dns-compression/`: synthetic end-to-end and isolated + scanner benchmarks for short names, shared suffixes and deep pointer chains. ## Safety Contract diff --git a/benches/dns-compression-benchmark.py b/benches/dns-compression-benchmark.py new file mode 100644 index 0000000..e37c4bd --- /dev/null +++ b/benches/dns-compression-benchmark.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +r"""Compare two prebuilt DPP binaries on generated DNS compression workloads. + +Uses only synthetic captures, never downloads inputs, and validates transaction +counters and CSV checksums before reporting paired timing ratios. Build both +binaries with the same Cargo profile, compiler and features (prefer `perf`). + +Example: + python3 benches/dns-compression-benchmark.py \ + --baseline /tmp/dpp-before --candidate target/perf/dpp \ + --outdir /tmp/dpp-compression-benchmark --runs 5 + +By default the baseline is a binary predating --max-dns-compression-jumps, so +the option is omitted for it. Use --baseline-limit for a same-policy control. +The candidate is exercised with 32 and 0; capped runs omit deeper-chain cases. +For an uncontaminated timing comparison, do not compile or run other benchmarks +concurrently. CPU scheduling and CSV/matcher work can mask parser-only costs. +""" + +import argparse +import csv +import hashlib +import json +import os +from pathlib import Path +import platform +import statistics +import struct +import subprocess +import time + + +def pointer(offset): + assert 0 <= offset <= 0x3FFF + return struct.pack("!H", 0xC000 | offset) + + +def dns_messages(case): + count, style = CASES[case] + qname = b"\x01a\x00" + if case == "three-label-eight": + qname = b"\x03www\x07example\x03com\x00" + elif case == "long-name-eight": + qname = (b"\x14abcdefghijklmnopqrst" * 10) + b"\x00" + question = qname + struct.pack("!HH", 16, 1) + query = struct.pack("!6H", 0, 0x0100, 1, 0, 0, 0) + question + response = bytearray(struct.pack("!6H", 0, 0x8180, 1, count, 0, 0) + question) + previous, depth = 12, 0 + max_depth = 0 + for index in range(count): + start = len(response) + if style == "literal": + owner = b"\x01a\x00" + owner_depth = 0 + elif style == "distinct": + label = f"rr{index}".encode("ascii") + owner = bytes([len(label)]) + label + b"\x01a\x00" + owner_depth = 0 + elif style == "suffix": + label = f"rr{index}".encode("ascii") + owner = bytes([len(label)]) + label + pointer(12) + owner_depth = 1 + elif style.startswith("chain") or style == "deep": + owner = pointer(previous) + owner_depth = depth + 1 + else: + owner = pointer(12) + owner_depth = 1 + response += owner + struct.pack("!HHIH", 16, 1, 60, 2) + b"\x01x" + max_depth = max(max_depth, owner_depth) + if start <= 0x3FFF and (style == "deep" or style.startswith("chain") and owner_depth < int(style[5:])): + previous, depth = start, owner_depth + assert len(response) <= 65507 + return query, bytes(response), max_depth + + +CASES = { + "empty": (0, "direct"), + "one": (1, "direct"), + "two": (2, "direct"), + "eight": (8, "direct"), + "three-label-eight": (8, "direct"), + "long-name-eight": (8, "direct"), + "literal-eight": (8, "literal"), + "chain2-eight": (8, "chain2"), + "chain8-eight": (8, "chain8"), + "distinct-128": (128, "distinct"), + "suffix-128": (128, "suffix"), + "chain32-128": (128, "chain32"), + "flat-4600": (4600, "direct"), + "deep-4600": (4600, "deep"), +} + + +def frame(message, transaction, response): + client = bytes([10, 0, 0, 1]) + server = bytes([1, 1, 1, 1]) + source, destination = (server, client) if response else (client, server) + port = 53000 + transaction % 1000 + udp = struct.pack("!4H", 53 if response else port, port if response else 53, + 8 + len(message), 0) + ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 28 + len(message), + transaction & 0xFFFF, 0, 64, 17, 0, source, destination) + message = struct.pack("!H", transaction & 0xFFFF) + message[2:] + return bytes(12) + b"\x08\x00" + ip + udp + message + + +def make_capture(path, case, transactions): + query, response, max_depth = dns_messages(case) + path.with_suffix(".dns").write_bytes(response) + with path.open("wb") as handle: + handle.write(struct.pack("= fixture["max_jumps"]]: + for run in range(args.runs + 1): + # Alternate order to reduce systematic temperature/cache bias. + order = ["baseline", "candidate"] if run % 2 == 0 else ["candidate", "baseline"] + hashes = [] + for name in order: + output = args.outdir / f"{case}-{name}.csv" + report, wall, digest = run_one( + binaries[name], args.outdir / f"{case}.pcap", output, + fast, threads, limit if name == "candidate" else args.baseline_limit, + fixture["transactions"], environment) + hashes.append(digest) + if run == 0: + continue + metrics = report["metrics"] + row = {"case": case, "decoder": "fast" if fast else "default", + "threads": threads, "limit": limit, "run": run, "binary": name, + "wall_seconds": wall, "processing_seconds": metrics["processing_seconds"], + "rss_kib": metrics["max_memory_usage_kib"], "output_sha256": digest} + writer.writerow(row) + rows.append(row) + if hashes[0] != hashes[1]: + raise RuntimeError(f"CSV mismatch: {case}, fast={fast}, threads={threads}, limit={limit}") + handle.flush() + selected = [row for row in rows if row["case"] == case and row["decoder"] == ("fast" if fast else "default") + and row["threads"] == threads and row["limit"] == limit] + baseline = statistics.median(row["processing_seconds"] for row in selected if row["binary"] == "baseline") + candidate = statistics.median(row["processing_seconds"] for row in selected if row["binary"] == "candidate") + paired = {run: {row["binary"]: row["processing_seconds"] for row in selected if row["run"] == run} + for run in range(1, args.runs + 1)} + ratios = [pair["candidate"] / pair["baseline"] for pair in paired.values()] + summary = {"case": case, "decoder": "fast" if fast else "default", + "threads": threads, "limit": limit, "runs": args.runs, + "baseline_median_seconds": baseline, "candidate_median_seconds": candidate, + "paired_ratio_median": statistics.median(ratios), + "paired_ratio_p25": percentile(ratios, .25), + "paired_ratio_p75": percentile(ratios, .75), + "paired_ratio_min": min(ratios), "paired_ratio_max": max(ratios)} + summaries.append(summary) + (args.outdir / "summary.json").write_text(json.dumps(summaries, indent=2) + "\n") + print(f"{case:16} {'fast' if fast else 'default':7} threads={threads} limit={limit:2} " + f"baseline={baseline:.6f}s candidate={candidate:.6f}s " + f"paired-ratio={summary['paired_ratio_median']:.3f} " + f"IQR=[{summary['paired_ratio_p25']:.3f},{summary['paired_ratio_p75']:.3f}]", flush=True) + + +if __name__ == "__main__": + main() diff --git a/benches/dns-compression.md b/benches/dns-compression.md new file mode 100644 index 0000000..d1af7a6 --- /dev/null +++ b/benches/dns-compression.md @@ -0,0 +1,295 @@ +# DNS compression validation and performance + +## Behavior and validation + +`--max-dns-compression-jumps` defaults to 32. The environment equivalent is +`DPP_MAX_DNS_COMPRESSION_JUMPS`; an explicit CLI value takes precedence. Zero disables +the jump-count policy, while backward-pointer, bounds and nonoverlap checks remain +mandatory. The policy covers questions, scanned RR owners and TSIG algorithm names, +including partial responses and the optional wire fast path's Hickory fallback. + +The decoder belongs to one immutable DNS message. Cached suffixes retain their +encoded segment end, expanded length, pointer depth and first label. Reuse checks +the segment end against the new referring name and charges the entire cached +depth against the configured limit. Entries are published only after a complete +successful walk. No cache survives the message or crosses worker ownership. + +Short RR-owner walks use a direct scanner: at most eight pointers and 64 literal +bytes per referenced segment. Crossing either threshold switches to the cached +scanner from the original name offset. These thresholds select an implementation, +not a different acceptance policy. Uncompressed questions in the optional fast +path retain a single-pass formatter. Compressed questions are validated before +Hickory receives their expanded wire form, avoiding recursive replay of long +pointer chains. + +The tests cover exact jump boundaries, zero/unlimited, warmed-cache depth and +overlap checks, literal versus compressed OPT roots, TSIG, partial fragments, +malformed and oversized questions, per-message isolation, and bounded traversal +work. The strictly backward, nonoverlapping walker accepts a synthetic raw name +with 8,193 pointer transitions when unlimited; normal DNS record framing further +limits how many such segments fit into a message. The deep benchmark is a valid +64,419-byte DNS response with 4,600 RRs and a maximum depth of 1,170. + +## Measurement method + +The end-to-end baseline is commit `7807751`, immediately before configurable +limits and suffix caching. The measured implementation is `400acc5`. Both binaries +use Rust 1.97.1, the `perf` profile and default jemalloc features, on macOS/aarch64. +`perf` disables overflow checks; these measurements +must not be interpreted as measurements of the production `release` profile. + +`dns-compression-benchmark.py` generates deterministic synthetic PCAPs and `.dns` +messages locally. Each configuration gets an excluded warm-up pair followed by +10 measured pairs, alternating which binary runs first. Builds and other test +processes are stopped during measurement. All `DPP_*` environment overrides are +removed for each child process. + +The harness validates packet, query, response, match and timeout counters on every +run, then compares CSV contents. Parallel output is sorted before comparison. +It records binary SHA-256, fixture dimensions, per-run processing time, wall time, +RSS and checksums. The main statistic is the median of the paired +candidate/baseline processing-time ratios; below 1 is faster. The interquartile +range (IQR) describes dispersion, **not** a confidence interval. A single favorable +run is not evidence of an improvement. + +The isolated scanner benchmark uses a fresh message-local decoder per iteration +and a historical uncached walker with the **same** configurable limit. This +separates scanner/cache cost from the end-to-end changes in question parsing and +mandatory validation. The full-application uncached control makes the same +comparison for RR-name skipping while keeping the new question parser (including +its suffix cache) and policy. + +## Reproduction + +Build the baseline and candidate with the same compiler, target and allocator: + +```bash +bench_dir=$(mktemp -d /tmp/dpp-compression.XXXXXX) +mkdir "$bench_dir/baseline" +git archive 7807751 | tar -x -C "$bench_dir/baseline" +cargo build --manifest-path "$bench_dir/baseline/Cargo.toml" \ + --target-dir "$bench_dir/baseline-target" --profile perf --locked --offline +cp "$bench_dir/baseline-target/perf/dpp" "$bench_dir/dpp-before" +cargo build --target-dir "$bench_dir/candidate-target" --profile perf --locked --offline +cp "$bench_dir/candidate-target/perf/dpp" "$bench_dir/dpp-after" + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/short" --runs 10 --transactions 1000000 \ + --cases empty,one,two,eight,literal-eight,three-label-eight,chain8-eight \ + --limits 32 --decoders both + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/unlimited" --runs 10 --transactions 200000 \ + --cases one,three-label-eight,long-name-eight,chain2-eight --limits 0 --decoders both + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/dense" --runs 10 --transactions 200000 \ + --cases distinct-128,suffix-128,chain32-128,flat-4600,deep-4600 \ + --limits 32,0 --decoders both + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/staged" --runs 10 --transactions 200000 \ + --cases one,chain32-128,deep-4600 --threads 5 --limits 32,0 --decoders both +``` + +Dense fixtures scale the transaction count by 20 for 128 RRs and by 1,000 for +4,600 RRs. The harness omits configurations whose depth exceeds the requested +nonzero limit, so the deep case runs only with zero. Add `--threads 5` to exercise +the staged pipeline, and use a new output directory for each experiment. + +Build and run the isolated scanner using dependency paths reported by Cargo for +the current compiler, rather than picking arbitrary old `.rlib` files: + +```bash +python3 benches/dns-compression/build-scan.py --output "$bench_dir/dns-compression-scan" +"$bench_dir/dns-compression-scan" "$bench_dir/short" 10 100 > "$bench_dir/scan-short.csv" +"$bench_dir/dns-compression-scan" "$bench_dir/unlimited" 10 100 > "$bench_dir/scan-unlimited.csv" +"$bench_dir/dns-compression-scan" "$bench_dir/dense" 10 100 > "$bench_dir/scan-dense.csv" +``` + +To reproduce the end-to-end control with uncached RR-name skipping, export the +candidate commit and apply the benchmark-only patch in that separate directory: + +```bash +mkdir "$bench_dir/control" +git archive HEAD | tar -x -C "$bench_dir/control" +git -C "$bench_dir/control" apply "$PWD/benches/dns-compression/uncached-control.patch" +cargo build --manifest-path "$bench_dir/control/Cargo.toml" \ + --target-dir "$bench_dir/control-target" --profile perf --locked --offline +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/control-target/perf/dpp" --candidate "$bench_dir/dpp-after" \ + --baseline-limit 32 --limits 32 --decoders fast \ + --cases one,two,eight,literal-eight,three-label-eight \ + --outdir "$bench_dir/control-results" --runs 10 --transactions 1000000 +``` + +Use an explicit `--baseline-limit 0` when comparing unlimited configurations with +this control. Leave `--baseline-limit` unset for the historical binary, which +predates the option. The patch is a measurement control, not a production mode. + +The scripts use synthetic data only and do not upload anything. Keep raw output +under the selected benchmark directory rather than committing generated PCAPs. +Keep build directories separate: exporting several revisions into one shared +Cargo target directory can leave the top-level executable from the last copy even +when a later build of another copy reports `Fresh`. + +Final correctness checks on `400acc5` passed: 353 workspace tests, formatting, +`git diff --check`, and Clippy with the existing `too_many_arguments`, +`type_complexity` and `large_enum_variant` allowances. CLI smoke checks cover +default rejection at 33 jumps, explicit 33, zero, environment configuration and +CLI precedence, in both decoder modes. Before the final inlining and plain-QNAME +optimizations, a separate randomized comparison of the cached and uncached +validators covered six million calls over 20,000 messages, with cold and warm +caches and limits 0 through 11. The final changes also received an independent +review of cursor handling, error classification and limit equivalence. + +## Results + +### Short messages against `7807751` + +Ten alternating pairs per configuration, 1,000,000 query/response transactions, +one processing thread, candidate limit 32. Each cell is median paired ratio +followed by its IQR; below 1 is faster. + +| Response owners | Default decoder | Optional fast decoder | +| --- | ---: | ---: | +| None | 0.917 [0.908–0.927] | 1.000 [0.995–1.006] | +| One compressed owner | 0.908 [0.899–0.915] | 1.004 [0.994–1.020] | +| Two compressed owners | 0.941 [0.925–0.944] | 1.024 [1.015–1.033] | +| Eight compressed owners | 0.939 [0.934–0.953] | 1.036 [1.030–1.046] | +| Eight literal owners | 0.941 [0.938–0.955] | 1.043 [1.039–1.053] | +| Eight owners sharing `www.example.com` | 0.923 [0.916–0.930] | 1.014 [0.994–1.022] | +| Eight owners, chain depth up to eight | 0.941 [0.939–0.945] | 1.014 [1.003–1.020] | + +The default decoder uses approximately 6–9% less processing time in this set. +The optional fast decoder has a **remaining end-to-end regression** on several +small synthetic cases, up to 4.3% for eight literal owners. For that case the +median times are 0.267963 s before and 0.278825 s after, per million transactions. +This is not dismissed as noise: its paired IQR is wholly above 1. The complete +change includes configurable validation, different question parsing and cache +context; the control below isolates the adaptive RR scanner more narrowly. + +### Same-policy RR-scanner control + +Ten alternating pairs, 1,000,000 transactions per case, optional fast question +decoder, one processing thread, both binaries explicitly limited to 32 jumps: + +| Response owners | Uncached control median (s) | Candidate median (s) | Median paired ratio | Ratio IQR | +| --- | ---: | ---: | ---: | ---: | +| One compressed owner | 0.233462 | 0.234452 | 1.001 | 0.997–1.020 | +| Two compressed owners | 0.242091 | 0.242340 | 0.996 | 0.989–1.013 | +| Eight compressed owners | 0.283838 | 0.286409 | 1.006 | 0.992–1.017 | +| Eight literal owners | 0.277441 | 0.279397 | 1.007 | 0.988–1.028 | +| Eight owners sharing `www.example.com` | 0.338048 | 0.337085 | 0.998 | 0.992–1.006 | + +These runs do not show a consistent short-message penalty from the adaptive RR +scanner: each IQR includes 1. This is narrower than a claim that the complete +change is free, or that performance is identical on every machine and workload. +Ratios are medians of pairs and need not equal the ratio of the two time medians. + +### Unlimited and dense messages + +Ten pairs per configuration. Unlimited short cases use 200,000 transactions; +dense cases use 10,000 transactions for 128 RRs and 200 for 4,600 RRs. + +| Case | Limit | Default decoder ratio [IQR] | Optional fast decoder ratio [IQR] | +| --- | ---: | ---: | ---: | +| One owner | 0 | 0.925 [0.915–0.947] | 1.022 [1.004–1.031] | +| Eight owners sharing `www.example.com` | 0 | 0.920 [0.907–0.943] | 1.002 [0.990–1.009] | +| Eight owners sharing a 211-byte name | 0 | 0.992 [0.984–1.000] | 0.981 [0.958–0.984] | +| Eight owners, chain depth two | 0 | 0.943 [0.933–0.967] | 1.010 [0.992–1.024] | +| 128 distinct literal owners | 32 | 0.995 [0.984–1.035] | 1.025 [0.969–1.035] | +| 128 distinct literal owners | 0 | 1.029 [1.011–1.076] | 1.000 [0.978–1.026] | +| 128 owners with a shared suffix | 32 | 1.000 [0.970–1.016] | 0.995 [0.976–1.049] | +| 128 owners with a shared suffix | 0 | 1.004 [0.979–1.019] | 0.979 [0.955–1.011] | +| 128 owners, chain depth 32 | 32 | 0.707 [0.698–0.717] | 0.713 [0.690–0.728] | +| 128 owners, chain depth 32 | 0 | 0.714 [0.690–0.723] | 0.702 [0.697–0.726] | +| 4,600 owners pointing directly to the question | 32 | 1.028 [0.993–1.119] | 1.004 [0.973–1.049] | +| 4,600 owners pointing directly to the question | 0 | 1.009 [0.973–1.038] | 1.012 [0.987–1.063] | +| 4,600 owners, chain depth 1,170 | 0 | 0.036 [0.035–0.037] | 0.037 [0.037–0.038] | + +The depth-32 workload needs about 30% less processing time. The deep unlimited +workload improves from median 1.443625 s to 0.052278 s with the default decoder, +and from 1.440767 s to 0.053734 s with the fast decoder (about 27 times faster). +The distinct-owner unlimited/default configuration also has an observed +2.9% +paired median with IQR above 1. Other flat dense configurations mostly overlap 1. +Their 30–40 ms end-to-end duration limits sensitivity to scanner costs; the +isolated measurements below provide a second, narrower view. + +### Five-thread staged pipeline + +Ten pairs, 200,000 short transactions, 10,000 depth-32 transactions and 200 deep +transactions, using the same binary pair: + +| Case | Limit | Default decoder ratio [IQR] | Optional fast decoder ratio [IQR] | +| --- | ---: | ---: | ---: | +| One owner | 32 | 0.961 [0.947–0.985] | 0.977 [0.948–1.000] | +| One owner | 0 | 0.942 [0.924–0.968] | 1.021 [0.995–1.059] | +| 128 owners, chain depth 32 | 32 | 0.840 [0.808–0.858] | 0.834 [0.824–0.856] | +| 128 owners, chain depth 32 | 0 | 0.811 [0.796–0.845] | 0.792 [0.781–0.800] | +| 4,600 owners, chain depth 1,170 | 0 | 0.066 [0.065–0.068] | 0.065 [0.064–0.067] | + +The deep default-decoder medians are 0.561074 s before and 0.036981 s after, +approximately a 15-fold improvement. Every pair has identical normalized CSV +contents and expected transaction counters. + +RSS is retained in the raw data but is not used to claim peak-memory savings. +The application samples RSS every 100 ms, longer than many candidate runs; a +shorter run can miss a memory peak that the slower baseline happens to sample. + +### Isolated name scanning with equal limits + +Ten alternating pairs per case/limit, one excluded warm-up pair, 100 ms per +sample. Times include skipping the question and all RR owners of one DNS message, +with a fresh decoder each time; they exclude question formatting, matching and +CSV output. The baseline walker receives the same limit as the candidate. + +| Case | Median ns/message before → after (limit 32) | Ratio at 32 [IQR] | Ratio at 0 [IQR] | +| --- | ---: | ---: | ---: | +| No RR owners | 2.75 → 3.17 | 1.150 [1.133–1.158] | 0.974 [0.966–0.983] | +| One owner | 5.63 → 5.24 | 0.927 [0.920–0.971] | 0.792 [0.785–0.807] | +| Two owners | 8.84 → 7.73 | 0.873 [0.864–0.899] | 0.750 [0.741–0.764] | +| Eight owners | 33.07 → 27.88 | 0.837 [0.835–0.856] | 0.832 [0.823–0.840] | +| Eight literal owners | 22.02 → 16.09 | 0.733 [0.727–0.742] | 0.686 [0.680–0.691] | +| Eight owners sharing `www.example.com` | 50.95 → 52.42 | 1.029 [1.018–1.046] | 1.022 [1.020–1.038] | +| Eight owners sharing a 211-byte name | 145.81 → 79.04 | 0.539 [0.533–0.556] | 0.544 [0.530–0.549] | +| Eight owners, chain depth two | 45.66 → 40.08 | 0.879 [0.872–0.890] | 0.866 [0.848–0.875] | +| Eight owners, chain depth eight | 82.91 → 73.44 | 0.881 [0.868–0.899] | 0.920 [0.913–0.930] | +| 128 distinct literal owners | 599.77 → 493.21 | 0.825 [0.814–0.833] | 0.820 [0.797–0.827] | +| 128 owners with a shared suffix | 741.39 → 686.99 | 0.921 [0.916–0.936] | 0.858 [0.840–0.869] | +| 128 owners, chain depth 32 | 5,987.28 → 2,712.57 | 0.452 [0.451–0.458] | 0.460 [0.455–0.465] | +| 4,600 flat owners | 22,992.61 → 17,816.45 | 0.777 [0.772–0.784] | 0.779 [0.768–0.790] | +| 4,600 owners, chain depth 1,170 | Rejected by policy | — | 0.0154 [0.0151–0.0156] | + +The unlimited deep-case medians are 6.966778 ms and 107.433 microseconds per +message, approximately a 65-fold scanner improvement. Most shallow cases also +improve. Two small costs remain measurable: approximately 0.4 ns per empty +response at limit 32, and 1.5 ns per response with eight three-label owners. +The latter is about 2–3% of isolated scanning. Therefore the result is **not** +"zero overhead in every case," even though the same-policy end-to-end control +cannot consistently distinguish these costs from its variation. + +### Recorded artifacts + +Run-level CSV, summaries and metadata from this machine are under +`/tmp/dpp-compression-final-400-{short,unlimited,dense,staged}` and +`/tmp/dpp-compression-causal-control` (the additional two-owner control is separate). +The isolated results are `/tmp/dpp-compression-final-micro.csv` and +`/tmp/dpp-compression-final-micro-summary.json`; the combined machine-readable +summary is `/tmp/dpp-compression-final-summary.json`. +These temporary files are not required to reproduce the measurements. + +| Binary | SHA-256 | +| --- | --- | +| Historical `7807751` | `8199ab733f9ff1f0c839fa04cbc556d0af410bdde434175f5572c06437628e59` | +| Final `400acc5` | `76c8ea5e7f3d2dfaa8e0190ec54893dbe70ddc5f54ae34ab097fb68431da60ce` | +| Same-source candidate used for the causal control, before refreshing build metadata | `f3cf38d9931b436a9130bcc36feac698be4bd925891c6554f40090ec176a74d7` | +| Uncached RR-scanner control | `cbc0c0f0b0dad398a2d4d541546aabc403f2eb5a7c094f0921c2ad59a301fbed` | + +Measurements use macOS 15.8/aarch64 and Python 3.14.6. Absolute timings and small +relative differences are machine- and workload-dependent; these synthetic runs +do not establish universal performance equivalence. diff --git a/benches/dns-compression/build-scan.py b/benches/dns-compression/build-scan.py new file mode 100644 index 0000000..8af9d5d --- /dev/null +++ b/benches/dns-compression/build-scan.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Build the standalone scanner microbenchmark with current Cargo artifacts. + +Uses the perf profile and default jemalloc feature, matching the recorded runs. +Cargo JSON selects artifacts for the active compiler; stale .rlib files from +another toolchain in target/perf/deps are deliberately ignored. +""" + +import argparse +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + directory = Path(__file__).resolve().parent + root = directory.parent.parent + build = subprocess.run( + ["cargo", "build", "-p", "dpp", "--profile", "perf", "--locked", "--offline", + "--message-format=json"], + cwd=root, capture_output=True, text=True, check=True, + ) + sys.stderr.write(build.stderr) + libraries = {"arrayvec": set(), "tikv_jemallocator": set()} + for line in build.stdout.splitlines(): + record = json.loads(line) + if record.get("reason") != "compiler-artifact": + continue + name = record["target"]["name"] + if name in libraries: + libraries[name].update(path for path in record["filenames"] if path.endswith(".rlib")) + if any(len(paths) != 1 for paths in libraries.values()): + raise RuntimeError(f"Expected one current artifact per dependency: {libraries}") + selected = {name: Path(next(iter(paths))) for name, paths in libraries.items()} + if len({path.parent for path in selected.values()}) != 1: + raise RuntimeError("Dependency artifacts use different target directories") + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + command = [os.environ.get("RUSTC", "rustc"), "--edition=2024", "-C", "opt-level=3", + "-C", "lto", "-C", "codegen-units=1", "-C", "panic=abort", + "-C", "overflow-checks=off", str(directory / "scan.rs"), + "-L", f"dependency={selected['arrayvec'].parent}"] + for name, path in selected.items(): + command += ["--extern", f"{name}={path}"] + command += ["-o", str(output)] + print(shlex.join(command), flush=True) + subprocess.run(command, cwd=root, check=True) + + +if __name__ == "__main__": + main() diff --git a/benches/dns-compression/scan.rs b/benches/dns-compression/scan.rs new file mode 100644 index 0000000..557eb33 --- /dev/null +++ b/benches/dns-compression/scan.rs @@ -0,0 +1,165 @@ +// Isolated RR-owner validation benchmark. Build with the same optimizer and +// allocator as both E2E binaries. See the compression benchmark report for the +// rustc --extern command. Inputs are *.dns files generated alongside PCAPs by +// dns-compression-benchmark.py. Each iteration starts a new message-local cache. +// +// Usage: dns-compression-scan FIXTURE_DIR [RUNS=10] [SAMPLE_MILLISECONDS=100] +// Stdout is CSV: timings and paired ratios for every measured repetition. +// One unrecorded warm-up pair precedes each case/limit configuration. +// The uncached control is the scanner from 7807751 plus the configurable cap. +use std::{hint::black_box, time::Instant}; +const DNS_POINTER_MASK: u8 = 0xc0; +const DNS_POINTER_TAG: u8 = 0xc0; +const DNS_LABEL_LEN_MASK: u8 = 0x3f; +#[global_allocator] +static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; +struct Old; +impl Old { + fn skip_wire_domain_name( + dns_data: &[u8], + cursor: &mut usize, + max_jumps: usize, + ) -> Result { + let start = *cursor; + let mut position = *cursor; + let mut resume_position = None; + let mut segment_start = position; + let mut segment_end = dns_data.len(); + let mut jumps = 0; + + loop { + let length = *dns_data + .get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or("DNS name truncated")?; + + match length { + 0 => { + position += 1; + *cursor = resume_position.unwrap_or(position); + return Ok(resume_position.is_none() && position == start + 1); + } + _ if (length & DNS_POINTER_MASK) == DNS_POINTER_TAG => { + let next = *dns_data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1)) + .ok_or("DNS compression pointer truncated")?; + let offset = + (((length & DNS_LABEL_LEN_MASK) as usize) << 8) | usize::from(next); + + jumps += 1; + if max_jumps != 0 && jumps > max_jumps { + return Err("DNS compression jump limit exceeded"); + } + if offset >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + + if resume_position.is_none() { + resume_position = Some(position + 2); + } + + // Historical uncached walker, with the same configurable hop cap + // as the candidate, isolates cache/bookkeeping cost from limit cost. + segment_end = segment_start; + segment_start = offset; + position = offset; + } + _ if (length & DNS_POINTER_MASK) != 0 => { + return Err("Unsupported DNS label encoding"); + } + _ => { + let label_len = usize::from(length); + dns_data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1..position + 1 + label_len)) + .ok_or("DNS label truncated")?; + position += 1 + label_len; + } + } + } + } +} +#[allow(dead_code)] +#[path = "../../src/dns_processor/name_decoder.rs"] +mod candidate; +#[inline(never)] +fn old(data: &[u8], limit: usize) -> usize { + let count = u16::from_be_bytes([data[6], data[7]]); + let mut cursor = 12; + black_box(Old::skip_wire_domain_name(data, &mut cursor, limit).unwrap()); + cursor += 4; + for _ in 0..count { + black_box(Old::skip_wire_domain_name(data, &mut cursor, limit).unwrap()); + let n = u16::from_be_bytes([data[cursor + 8], data[cursor + 9]]) as usize; + cursor += 10 + n; + } + assert_eq!(cursor, data.len()); + cursor +} +#[inline(never)] +fn new(data: &[u8], limit: usize) -> usize { + let mut decoder = candidate::DnsNameDecoder::new(data, limit); + let count = u16::from_be_bytes([data[6], data[7]]); + let mut cursor = 12; + black_box(decoder.skip(&mut cursor).unwrap()); + cursor += 4; + for _ in 0..count { + black_box(decoder.skip(&mut cursor).unwrap()); + let n = u16::from_be_bytes([data[cursor + 8], data[cursor + 9]]) as usize; + cursor += 10 + n; + } + assert_eq!(cursor, data.len()); + cursor +} +fn main() { + let mut args = std::env::args().skip(1); + let directory = std::path::PathBuf::from(args.next().expect("fixture directory required")); + let repetitions: usize = args.next().map(|v| v.parse().unwrap()).unwrap_or(10); + let milliseconds: u128 = args.next().map(|v| v.parse().unwrap()).unwrap_or(100); + assert!(repetitions > 0 && milliseconds > 0); + assert!(args.next().is_none(), "too many arguments"); + let mut fixtures: Vec<_> = std::fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "dns")) + .collect(); + fixtures.sort(); + assert!(!fixtures.is_empty(), "no .dns fixtures found"); + println!("case,limit,run,old_ns,new_ns,ratio"); + for path in fixtures { + let case = path.file_stem().unwrap().to_str().unwrap(); + let data = std::fs::read(&path).unwrap(); + for limit in [32, 0] { + // The generated deep case intentionally needs unlimited traversal. + if case == "deep-4600" && limit == 32 { + continue; + } + for round in 0..=repetitions { + let mut numbers = [0., 0.]; + for variant in [round % 2, (round + 1) % 2] { + let scan = if variant == 0 { old } else { new }; + let mut runs = 0; + let started = Instant::now(); + // Avoid a 100-packet batch overshooting the duration on deep chains. + let batch = if data.len() > 16_000 { 1 } else { 100 }; + while started.elapsed().as_millis() < milliseconds { + for _ in 0..batch { + black_box(scan(black_box(&data), black_box(limit))); + } + runs += batch; + } + numbers[variant] = started.elapsed().as_secs_f64() * 1e9 / runs as f64; + } + if round != 0 { + println!( + "{case},{limit},{round},{:.2},{:.2},{:.6}", + numbers[0], + numbers[1], + numbers[1] / numbers[0] + ); + } + } + } + } +} diff --git a/benches/dns-compression/uncached-control.patch b/benches/dns-compression/uncached-control.patch new file mode 100644 index 0000000..ac9a8b8 --- /dev/null +++ b/benches/dns-compression/uncached-control.patch @@ -0,0 +1,64 @@ +--- a/src/dns_processor/name_decoder.rs ++++ b/src/dns_processor/name_decoder.rs +@@ -273,41 +273,25 @@ + let mut segment_end = self.data.len(); + let mut jumps = 0; + loop { +- let length = *self +- .data +- .get(..segment_end) ++ let length = *self.data.get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS name truncated"))?; + match length { + 0 => { + *cursor = resume.unwrap_or(position + 1); +- // OPT requires a literal root, never an expanded root. + return Ok(resume.is_none() && position == start); + } + _ if length & POINTER_TAG == POINTER_TAG => { +- // Every setting permits the first jump. Defer policy work +- // until nested compression, keeping literal/direct names cheap. +- if jumps != 0 && (jumps == DIRECT_POINTER_JUMPS || jumps == self.max_jumps) { +- // Cheap short walks avoid cache bookkeeping. The full +- // walker enforces smaller configured limits and caches +- // longer chains, counting every cached transition. +- return self.skip_cached(cursor); +- } +- let next = *self +- .data +- .get(..segment_end) ++ let next = *self.data.get(..segment_end) + .and_then(|segment| segment.get(position + 1)) +- .ok_or_else(|| { +- self.boundary_error(segment_end, "DNS compression pointer truncated") +- })?; ++ .ok_or_else(|| self.boundary_error(segment_end, "DNS compression pointer truncated"))?; + let target = (usize::from(length & 0x3f) << 8) | usize::from(next); + if target >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + jumps += 1; +- if resume.is_none() { +- resume = Some(position + 2); +- } ++ self.check_jumps(jumps)?; ++ if resume.is_none() { resume = Some(position + 2); } + segment_end = segment_start; + segment_start = target; + position = target; +@@ -315,13 +299,7 @@ + _ if length & POINTER_TAG != 0 => return Err("Unsupported DNS label encoding"), + _ => { + let end = position + 1 + usize::from(length); +- if resume.is_some() && end - segment_start > DIRECT_SUFFIX_BYTES { +- // Bound repeated literal-label work too, including RR +- // owner names whose expanded size is not materialized. +- return self.skip_cached(cursor); +- } +- self.data +- .get(..segment_end) ++ self.data.get(..segment_end) + .and_then(|segment| segment.get(position + 1..end)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS label truncated"))?; + position = end; From 9b329b9b177fa7e2bdc5a60f6393f81e8bbfae72 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 09:01:02 +0300 Subject: [PATCH 26/28] Restore inlining across the DNS resource record scan --- src/dns_processor/parser.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index cf89bda..49b2fd2 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -771,6 +771,9 @@ impl DnsProcessor { ) } + // Inline the section loop together with the metadata reader so callers can + // keep the cursor local and discard unused RR fields in the hot scan. + #[inline(always)] fn skip_dns_resource_records( dns_data: &[u8], names: &mut DnsNameDecoder<'_>, @@ -875,6 +878,8 @@ impl DnsProcessor { Ok(error) } + // Inlining lets answer/authority scans omit TTL and metadata materialization. + #[inline(always)] fn read_dns_resource_record_meta( dns_data: &[u8], names: &mut DnsNameDecoder<'_>, From 518a3c65e8ffc92a368fc427e18ed474765cab27 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 09:09:20 +0300 Subject: [PATCH 27/28] Document DNS inlining diagnosis and repeated regression checks --- benches/README.md | 2 +- benches/dns-compression-benchmark.py | 8 +- benches/dns-compression.md | 169 ++++++++++++++++++++++++++- benches/dns-compression/scan.rs | 10 +- 4 files changed, 183 insertions(+), 6 deletions(-) diff --git a/benches/README.md b/benches/README.md index 96b2806..d68fd51 100644 --- a/benches/README.md +++ b/benches/README.md @@ -10,7 +10,7 @@ This directory is the canonical location for repeatable performance measurements - `protocol-correctness-audit.md`: final validation, restored throughput and measured memory costs of the 2026-09-05 protocol and matcher fixes, with earlier experiments identified separately. - `dns-compression.md`: configurable compression limits, message-local suffix caching, - repeated performance measurements and reproduction instructions. + repeated performance measurements, RR-loop inlining investigation and reproduction instructions. - `dns-compression-benchmark.py` and `dns-compression/`: synthetic end-to-end and isolated scanner benchmarks for short names, shared suffixes and deep pointer chains. diff --git a/benches/dns-compression-benchmark.py b/benches/dns-compression-benchmark.py index e37c4bd..4fc0939 100644 --- a/benches/dns-compression-benchmark.py +++ b/benches/dns-compression-benchmark.py @@ -44,7 +44,11 @@ def dns_messages(case): qname = (b"\x14abcdefghijklmnopqrst" * 10) + b"\x00" question = qname + struct.pack("!HH", 16, 1) query = struct.pack("!6H", 0, 0x0100, 1, 0, 0, 0) + question - response = bytearray(struct.pack("!6H", 0, 0x8180, 1, count, 0, 0) + question) + answer_count = 0 if case in ("authority-eight", "additional-eight") else count + authority_count = count if case == "authority-eight" else 0 + additional_count = count if case == "additional-eight" else 0 + response = bytearray(struct.pack("!6H", 0, 0x8180, 1, answer_count, + authority_count, additional_count) + question) previous, depth = 12, 0 max_depth = 0 for index in range(count): @@ -79,6 +83,8 @@ def dns_messages(case): "one": (1, "direct"), "two": (2, "direct"), "eight": (8, "direct"), + "authority-eight": (8, "direct"), + "additional-eight": (8, "direct"), "three-label-eight": (8, "direct"), "long-name-eight": (8, "direct"), "literal-eight": (8, "literal"), diff --git a/benches/dns-compression.md b/benches/dns-compression.md index d1af7a6..a8f4e6b 100644 --- a/benches/dns-compression.md +++ b/benches/dns-compression.md @@ -22,6 +22,10 @@ path retain a single-pass formatter. Compressed questions are validated before Hickory receives their expanded wire form, avoiding recursive replay of long pointer chains. +The RR section loop and metadata reader are both explicitly inlined. This lets +the compiler remove metadata unused by answer/authority scans and retain the +scan cursor in registers; see the follow-up investigation below. + The tests cover exact jump boundaries, zero/unlimited, warmed-cache depth and overlap checks, literal versus compressed OPT roots, TSIG, partial fragments, malformed and oversized questions, per-message isolation, and bounded traversal @@ -30,7 +34,164 @@ with 8,193 pointer transitions when unlimited; normal DNS record framing further limits how many such segments fit into a message. The deep benchmark is a valid 64,419-byte DNS response with 4,600 RRs and a maximum depth of 1,170. -## Measurement method +## Follow-up: RR-loop inlining (`9b329b9`) + +The initial short-message regression was investigated with single-factor builds +from the same source directory, compiler, dependencies and profile. Five alternating +pairs were used for screening, with ten-pair confirmation of the selected fix. +Production validation and cache behavior are unchanged by the fix: it adds only +`#[inline(always)]` to `skip_dns_resource_records` and +`read_dns_resource_record_meta`, plus comments explaining why both are needed. + +### Mechanism and controls + +The arm64 disassembly of `7807751` contains the RR scan directly inside +`decode_response_code_with_sections`. The compiler keeps the scan cursor in a +register and removes unused metadata, including TTL, from answer/authority scans. +In `400acc5`, `read_dns_resource_record_meta` became a separate 656-byte function +called for every RR. Each call creates a 64-byte stack frame, saves/restores six +registers, reads TTL and writes the full metadata result to the stack. The caller +then reads only the fields needed by that section. + +The earlier uncached-scanner control **also retained this separate function**. +Consequently, disabling suffix caching in that control did not isolate the loss +of inlining around it. This is an indirect compiler consequence of integrating +the new decoder, rather than the cost of HashMap lookups on these fixtures. + +Forcing only the metadata reader inline (F) removed the per-RR calls and dead +metadata, but left the section loop as a separate function; timing did not +improve consistently. Forcing both levels inline (G, the selected fix) restored +the loop within the caller, including a register-held cursor, and produced the +measured improvement. Overflow, fixed-header length and full RDATA bounds checks +remain in the resulting code. + +| Screen: one change from the stated control | Literal-eight ratio [IQR] | Conclusion | +| --- | ---: | --- | +| Historical `7807751` → rebuilt unmodified `400acc5` (B0) | 1.043 [1.003–1.047] | Original regression reproduced in this session | +| Original `400acc5` binary → B0 | 1.003 [0.997–1.007] | Scratch rebuild does not explain it | +| B0 → remove question-capacity bound (C, diagnostic only) | 1.006 [0.999–1.011] | No benefit; allocation safeguard retained | +| B0 → lazily boxed cache state (D) | 1.016 [1.012–1.025] | Slower; inline state retained | +| B0 → earlier plain-QNAME formatter shape (E) | 0.999 [0.993–0.999] | Does not recover the original regression | +| B0 → inline metadata reader only (F) | 1.006 [1.000–1.013] | Assembly improves, end-to-end timing does not | +| B0 → inline metadata reader and section loop (G) | 0.955 [0.953–0.961] | Selected minimal fix | +| G → pass cold-path cursor by value (H) | 1.005 [0.992–1.015] | Removes a stack store but no demonstrated timing benefit; omitted | + +G also reduced the paired median on eight compressed owners to 0.973 +[0.970–0.995] and on two owners to 0.980 [0.956–0.981]. These are screening +results, not a claim that each removed instruction accounts for a known fraction +of elapsed time. All controls produced matching CSV contents and counters. +Workspace tests (353), Clippy with the existing lint allowances, formatting and +diff checks passed after applying G. + +### Confirmation results + +Each configuration below has ten alternating pairs and an excluded warm-up +pair. The direct causal comparison uses the rebuilt pre-fix source (B0) and +production `9b329b9`, both with limit 32 and the optional fast decoder, one +processing thread and one million transactions: + +| Case | Pre-fix median (s) | Fixed median (s) | Paired ratio [IQR] | +| --- | ---: | ---: | ---: | +| Eight literal owners | 0.274228 | 0.263665 | 0.958 [0.950–0.972] | +| Eight compressed owners | 0.276779 | 0.269366 | 0.971 [0.966–0.977] | +| Two compressed owners | 0.237620 | 0.234812 | 0.984 [0.977–0.997] | + +This confirms approximately 4.2%, 2.9% and 1.6% less processing time respectively +from the selected inlining change. A separate ten-pair comparison against the +historical `7807751` binary, at the same transaction count and candidate limit, +shows the remaining differences: + +| Case | Historical median (s) | Fixed median (s) | Paired ratio [IQR] | +| --- | ---: | ---: | ---: | +| Eight literal owners | 0.260786 | 0.263337 | 1.005 [0.995–1.029] | +| Eight compressed owners | 0.274985 | 0.277004 | 1.007 [0.997–1.018] | +| Two compressed owners | 0.235979 | 0.237861 | 1.008 [1.004–1.015] | +| One compressed owner | 0.230069 | 0.233360 | 1.011 [1.007–1.024] | +| No RR owners | 0.222002 | 0.223651 | 1.008 [1.003–1.012] | +| Eight owners sharing `www.example.com` | 0.327628 | 0.329540 | 1.003 [0.994–1.011] | +| Eight owners, chain depth eight | 0.313246 | 0.318369 | 1.014 [1.006–1.023] | +| Eight authority owners | 0.270605 | 0.273137 | 1.010 [0.995–1.022] | +| Eight additional owners | 0.281450 | 0.284516 | 1.012 [0.985–1.025] | + +The earlier 3–4% penalties on eight literal or directly compressed answer owners +no longer reproduce; their remaining median differences are below 1%, with IQRs +crossing 1. A smaller +0.8–1.4% difference remains on several other cases with IQR above 1. Those costs +have **not** been assigned to a specific instruction or exclusively to the jump +limit. The experiment localizes and fixes the main regression, not every last +nanosecond of the difference from the historical decoder. + +Forced inlining can change code size and instruction locality. Confirmation +therefore also includes the default decoder, all three RR sections, dense/deep +responses and the staged pipeline; the tables below retain the observed +differences rather than assuming all workloads benefit. + +The default decoder's short-message comparisons against `7807751` use one million +transactions, one thread and limit 32: + +| Case | Historical median (s) | Fixed median (s) | Paired ratio [IQR] | +| --- | ---: | ---: | ---: | +| One owner | 0.317102 | 0.286759 | 0.901 [0.894–0.912] | +| Eight literal owners | 0.351446 | 0.318372 | 0.908 [0.897–0.913] | +| Eight owners sharing `www.example.com` | 0.450267 | 0.410812 | 0.911 [0.909–0.917] | + +Dense cases use 10,000 transactions for 128 RRs and 200 for 4,600 RRs; staged +one-owner runs use 200,000 transactions. Each cell remains a paired ratio to +`7807751`, followed by its IQR: + +| Case | Threads | Limit | Default decoder | Optional fast decoder | +| --- | ---: | ---: | ---: | ---: | +| 128 owners, chain depth 32 | 1 | 32 | 0.704 [0.688–0.718] | 0.704 [0.691–0.713] | +| 4,600 flat owners | 1 | 32 | 1.004 [0.968–1.043] | 1.025 [0.995–1.066] | +| 128 distinct literal owners | 1 | 32 | 0.992 [0.981–1.010] | 1.037 [0.994–1.059] | +| 128 owners with a shared suffix | 1 | 32 | 0.982 [0.959–1.016] | 1.019 [1.002–1.041] | +| 4,600 owners, chain depth 1,170 | 1 | 0 | 0.0369 [0.0361–0.0374] | 0.0362 [0.0355–0.0364] | +| One owner | 5 | 32 | 0.970 [0.946–0.987] | 1.016 [0.943–1.022] | +| 4,600 owners, chain depth 1,170 | 5 | 0 | 0.0657 [0.0639–0.0663] | 0.0635 [0.0627–0.0646] | + +The shared-suffix fast case retains an observed +1.9% median with IQR above 1; +it is not presented as a demonstrated zero-cost case. Deep-chain improvements +remain about 27-fold with one thread and 15–16-fold with five threads. The final +matrix comprises 29 configurations and 580 measured executions, plus warm-ups; +every pair matched counters and CSV contents. RSS sampling remains too sparse +to compare peaks for these short runs. + +### Follow-up reproduction and provenance + +Use the existing harness with the pre-fix source `c5fe0f7` (production code +identical to `400acc5`) and fixed source `9b329b9`, built with the same compiler, +profile and features. Keep each build in its own Cargo target directory and +copy each binary before building another revision. For the direct comparison: + +```bash +python3 benches/dns-compression-benchmark.py \ + --baseline /path/to/dpp-before-inline --baseline-limit 32 \ + --candidate /path/to/dpp-after-inline --limits 32 --decoders fast \ + --cases literal-eight,eight,two --transactions 1000000 --runs 10 \ + --outdir /tmp/dpp-inline-confirmation +``` + +For comparison with `7807751`, omit `--baseline-limit` because that version has +no configurable cap. Include `authority-eight,additional-eight` to cover the +other RR sections. The scanner harness now sums all three section counts and +has passed a bounds/end-position smoke check on answer, authority and additional +fixtures with both 32 and zero limits. + +The local investigation artifacts are under `/tmp/dpp-compression-ablation`: +`manifest.json` records the build variants and source/patch/binary hashes; +`final-provenance.json` records the final matrix; `final-*` directories contain +the raw samples, checksums and summaries; `final-summary.json` collects all 29 +configurations with absolute times, ratios and sampled RSS. Disassembly excerpts and the analysis +are in `/tmp/dpp-compression-disassembly`. The parser-only harness prepared during +the investigation was not used for these conclusions; adding a benchmark caller +could itself change the inlining decisions being investigated. + +| Follow-up binary | SHA-256 | +| --- | --- | +| Rebuilt pre-fix control B0 | `22cb75b59057420f50a593c9d08b226f88d0df88337bc579dbfdf822eb4dffdd` | +| Production `9b329b9` | `70c1d17e3a956bd17de4bd788664b26c42eca5660797a6fd3b9bb050daf079b1` | + +## Initial measurement method (`400acc5`) The end-to-end baseline is commit `7807751`, immediately before configurable limits and suffix caching. The measured implementation is `400acc5`. Both binaries @@ -147,7 +308,7 @@ validators covered six million calls over 20,000 messages, with cold and warm caches and limits 0 through 11. The final changes also received an independent review of cursor handling, error classification and limit equivalence. -## Results +## Initial results (`400acc5`) ### Short messages against `7807751` @@ -247,6 +408,10 @@ Ten alternating pairs per case/limit, one excluded warm-up pair, 100 ms per sample. Times include skipping the question and all RR owners of one DNS message, with a fresh decoder each time; they exclude question formatting, matching and CSV output. The baseline walker receives the same limit as the candidate. +This historical table uses the scanner harness from `c5fe0f7`, which reads +ANCOUNT. The current harness sums ANCOUNT, NSCOUNT and ARCOUNT so generated +authority/additional fixtures can also be scanned; that extension received a +correctness smoke check, not a new set of timings for this table. | Case | Median ns/message before → after (limit 32) | Ratio at 32 [IQR] | Ratio at 0 [IQR] | | --- | ---: | ---: | ---: | diff --git a/benches/dns-compression/scan.rs b/benches/dns-compression/scan.rs index 557eb33..d44ed4f 100644 --- a/benches/dns-compression/scan.rs +++ b/benches/dns-compression/scan.rs @@ -85,7 +85,10 @@ impl Old { mod candidate; #[inline(never)] fn old(data: &[u8], limit: usize) -> usize { - let count = u16::from_be_bytes([data[6], data[7]]); + let count: usize = [6, 8, 10] + .into_iter() + .map(|offset| usize::from(u16::from_be_bytes([data[offset], data[offset + 1]]))) + .sum(); let mut cursor = 12; black_box(Old::skip_wire_domain_name(data, &mut cursor, limit).unwrap()); cursor += 4; @@ -100,7 +103,10 @@ fn old(data: &[u8], limit: usize) -> usize { #[inline(never)] fn new(data: &[u8], limit: usize) -> usize { let mut decoder = candidate::DnsNameDecoder::new(data, limit); - let count = u16::from_be_bytes([data[6], data[7]]); + let count: usize = [6, 8, 10] + .into_iter() + .map(|offset| usize::from(u16::from_be_bytes([data[offset], data[offset + 1]]))) + .sum(); let mut cursor = 12; black_box(decoder.skip(&mut cursor).unwrap()); cursor += 4; From a7c67929f64fe79de9f3cf693ef953e9bb5bcf83 Mon Sep 17 00:00:00 2001 From: Konstantin Mikhailov Date: Sun, 27 Sep 2026 09:22:31 +0300 Subject: [PATCH 28/28] Align Rust 1.98.1 toolchain and fix new Clippy lint --- .github/workflows/ci.yml | 4 ++-- .github/workflows/rust.yml | 4 ++-- Cargo.lock | 2 +- Cargo.toml | 4 ++-- README.md | 2 +- benches/allocator-benchmarking.md | 2 +- clippy.toml | 2 +- docs/allocator-guide.md | 2 +- docs/synthetic-pcap-generator.md | 2 +- rust-toolchain.toml | 2 +- src/dns_processor/anonymizer.rs | 2 +- src/dns_processor/reassembly.rs | 4 ++-- tools/dns-catalog-builder/Cargo.toml | 2 +- tools/dns-pcap-generator/Cargo.toml | 2 +- tools/dns-pcap-generator/src/packet.rs | 8 ++++---- 15 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce61dc1..015ea49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: - name: Install libpcap development headers run: sudo apt-get update && sudo apt-get install -y libpcap-dev - - name: Install Rust 1.97.1 - uses: dtolnay/rust-toolchain@1.97.1 + - name: Install Rust 1.98.1 + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a4c9473..09020f6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -36,8 +36,8 @@ jobs: - name: Install PCAP lib (only on Linux) run: sudo apt-get install -y libpcap-dev - - name: Set up Rust 1.97.1 - uses: dtolnay/rust-toolchain@1.97.1 + - name: Set up Rust 1.98.1 + uses: dtolnay/rust-toolchain@1.98.1 - name: Get package version id: cargo-version diff --git a/Cargo.lock b/Cargo.lock index 7505c28..89cb254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -665,7 +665,7 @@ dependencies = [ [[package]] name = "dpp" -version = "0.3.3" +version = "0.4.0" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 1707725..e6c4c7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,9 @@ [package] name = "dpp" -version = "0.3.3" +version = "0.4.0" edition = "2024" -rust-version = "1.97.1" +rust-version = "1.98.1" build = "build.rs" # Custom build script to include version information from git [workspace] diff --git a/README.md b/README.md index 8692fb0..95ffed7 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ ownership boundaries, and matcher invariants, see [docs/architecture.md](docs/ar ## Prerequisites -- [Rust](https://rustup.rs/) 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +- [Rust](https://rustup.rs/) 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. - Cargo - PCAP files for offline processing - `libpcap` development headers for fallback support on non-classic formats diff --git a/benches/allocator-benchmarking.md b/benches/allocator-benchmarking.md index 177c298..c259dd8 100644 --- a/benches/allocator-benchmarking.md +++ b/benches/allocator-benchmarking.md @@ -24,7 +24,7 @@ Exactly one allocator feature must be enabled at build time. ## Build Matrix -Requires Rust 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +Requires Rust 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. Build each variant into a separate binary path. diff --git a/clippy.toml b/clippy.toml index dfe7be0..a9e7bc9 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1 @@ -msrv = "1.97.1" +msrv = "1.98.1" diff --git a/docs/allocator-guide.md b/docs/allocator-guide.md index 9b28a8e..1255c74 100644 --- a/docs/allocator-guide.md +++ b/docs/allocator-guide.md @@ -30,7 +30,7 @@ Invalid feature combinations fail the build explicitly. ## Build Commands -Requires Rust 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +Requires Rust 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. Default allocator build: diff --git a/docs/synthetic-pcap-generator.md b/docs/synthetic-pcap-generator.md index 6177bd2..d84bbff 100644 --- a/docs/synthetic-pcap-generator.md +++ b/docs/synthetic-pcap-generator.md @@ -22,7 +22,7 @@ The generator also models: ## Usage -Requires Rust 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +Requires Rust 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. Build the standalone binary: diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 010f002..c3f67b6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.97.1" +channel = "1.98.1" profile = "minimal" components = ["rustfmt", "clippy"] diff --git a/src/dns_processor/anonymizer.rs b/src/dns_processor/anonymizer.rs index 6bef1f3..cfff7d5 100644 --- a/src/dns_processor/anonymizer.rs +++ b/src/dns_processor/anonymizer.rs @@ -79,7 +79,7 @@ impl Anonymizer { )); } let mut salt = [0u8; 32]; - for (output, pair) in salt.iter_mut().zip(salt_hex.chunks_exact(2)) { + for (output, pair) in salt.iter_mut().zip(salt_hex.as_chunks::<2>().0) { let digits = std::str::from_utf8(pair).expect("ASCII hex digits checked"); *output = u8::from_str_radix(digits, 16).expect("ASCII hex digits checked"); } diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs index f988710..02dd07d 100644 --- a/src/dns_processor/reassembly.rs +++ b/src/dns_processor/reassembly.rs @@ -346,8 +346,8 @@ fn fragment_matches_payload(part: &FragmentPart, payload: &[u8]) -> bool { fn ipv4_header_checksum(header: &[u8]) -> u16 { let mut sum = 0_u32; - for word in header.chunks_exact(2) { - sum += u32::from(u16::from_be_bytes([word[0], word[1]])); + for word in header.as_chunks::<2>().0 { + sum += u32::from(u16::from_be_bytes(*word)); } while sum >> 16 != 0 { sum = (sum & 0xffff) + (sum >> 16); diff --git a/tools/dns-catalog-builder/Cargo.toml b/tools/dns-catalog-builder/Cargo.toml index 4c1fa98..443028e 100644 --- a/tools/dns-catalog-builder/Cargo.toml +++ b/tools/dns-catalog-builder/Cargo.toml @@ -2,7 +2,7 @@ name = "dns-catalog-builder" version = "0.21.0" edition = "2024" -rust-version = "1.97.1" +rust-version = "1.98.1" [dependencies] clap = { version = "4.6", features = ["derive"] } diff --git a/tools/dns-pcap-generator/Cargo.toml b/tools/dns-pcap-generator/Cargo.toml index 9da2c49..6039a97 100644 --- a/tools/dns-pcap-generator/Cargo.toml +++ b/tools/dns-pcap-generator/Cargo.toml @@ -2,7 +2,7 @@ name = "dns-pcap-generator" version = "0.21.0" edition = "2024" -rust-version = "1.97.1" +rust-version = "1.98.1" [dependencies] clap = { version = "4.6", features = ["derive"] } diff --git a/tools/dns-pcap-generator/src/packet.rs b/tools/dns-pcap-generator/src/packet.rs index e2f59d7..9efb928 100644 --- a/tools/dns-pcap-generator/src/packet.rs +++ b/tools/dns-pcap-generator/src/packet.rs @@ -315,11 +315,11 @@ fn root_name_server_target(entropy: u64) -> &'static str { fn internet_checksum(bytes: &[u8]) -> u16 { let mut sum = 0_u32; - let mut chunks = bytes.chunks_exact(2); - for chunk in &mut chunks { - sum += u32::from(u16::from_be_bytes([chunk[0], chunk[1]])); + let (chunks, remainder) = bytes.as_chunks::<2>(); + for chunk in chunks { + sum += u32::from(u16::from_be_bytes(*chunk)); } - if let [last] = chunks.remainder() { + if let [last] = remainder { sum += u32::from(*last) << 8; } while (sum >> 16) != 0 {