From 4e72b666aeadb13939de9802fdde723914500ec5 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:06:03 +0100 Subject: [PATCH 1/3] Refactor Message::encode() to unify truncation path Collapse encode() and encodeWithTruncation() into a single linear pass that streams answers with partial truncation, then attempts authority and additional all-or-nothing, and only rebuilds the header when counts or the TC flag actually change. Add tests covering section priority, answer-truncation clearing populated non-answer sections, TC preservation on re-encode, the exact maxSize boundary, and NODATA-style drops. --- src/DNS/Message.php | 175 ++++++++++++------------------ tests/unit/DNS/MessageTest.php | 191 ++++++++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 110 deletions(-) diff --git a/src/DNS/Message.php b/src/DNS/Message.php index 906847d..b301196 100644 --- a/src/DNS/Message.php +++ b/src/DNS/Message.php @@ -186,10 +186,11 @@ public static function decode(string $packet): self /** * Encode the message to a binary DNS packet. * - * When maxSize is specified, truncation follows RFC 1035 Section 6.2 and RFC 2181 Section 9: - * - Truncation starts at the end and works forward (additional → authority → answers) - * - TC flag is only set when required RRSets (answers) couldn't be fully included - * - Complete RRSets are preserved; partial RRSets are omitted entirely + * When maxSize is specified, truncation follows RFC 1035 Section 6.2 and + * RFC 2181 Section 9: + * - Sections are dropped from the end first (additional → authority → answers) + * - Authority and additional are all-or-nothing; answers allow partial inclusion + * - TC flag is only set when answer records couldn't all fit * - Questions are always preserved * * @param int|null $maxSize Maximum packet size (e.g., 512 for UDP per RFC 1035) @@ -197,128 +198,86 @@ public static function decode(string $packet): self */ public function encode(?int $maxSize = null): string { - // Build full packet first $packet = $this->header->encode(); - foreach ($this->questions as $question) { $packet .= $question->encode(); } + // Answers: include as many complete records as fit (partial allowed). + $answerCount = 0; foreach ($this->answers as $answer) { - $packet .= $answer->encode($packet); + $encoded = $answer->encode($packet); + if ($maxSize !== null && strlen($packet) + strlen($encoded) > $maxSize) { + break; + } + $packet .= $encoded; + $answerCount++; } - - foreach ($this->authority as $authority) { - $packet .= $authority->encode($packet); + $answersTruncated = $answerCount < count($this->answers); + + // Authority then additional: all-or-nothing, and only once answers all fit. + // Order matches RFC 1035 Section 6.2 (drop additional before authority). + $authorityCount = 0; + $additionalCount = 0; + if (!$answersTruncated) { + $withAuthority = $this->appendRecords($packet, $this->authority); + if ($maxSize === null || strlen($withAuthority) <= $maxSize) { + $packet = $withAuthority; + $authorityCount = count($this->authority); + + $withAdditional = $this->appendRecords($packet, $this->additional); + if ($maxSize === null || strlen($withAdditional) <= $maxSize) { + $packet = $withAdditional; + $additionalCount = count($this->additional); + } + } } - foreach ($this->additional as $additional) { - $packet .= $additional->encode($packet); - } + $sectionsUnchanged = + $answerCount === count($this->answers) + && $authorityCount === count($this->authority) + && $additionalCount === count($this->additional); - // No truncation needed - if ($maxSize === null || strlen($packet) <= $maxSize) { + if ($sectionsUnchanged) { return $packet; } - // RFC-compliant truncation: work backward from end - // Per RFC 1035 Section 6.2 and RFC 2181 Section 9 - return $this->encodeWithTruncation($maxSize); + // When authority is dropped, an authoritative NODATA/NXDOMAIN response + // loses the SOA it needs to remain RFC-valid, so clear the AA flag. + $authorityDropped = $authorityCount < count($this->authority); + $isNodataOrNxdomain = ($this->header->responseCode === self::RCODE_NOERROR && $answerCount === 0) + || $this->header->responseCode === self::RCODE_NXDOMAIN; + $authoritative = ($authorityDropped && $isNodataOrNxdomain) + ? false + : $this->header->authoritative; + + // Per RFC 2181 Section 9, TC signals truncated required data (answers). + $header = new Header( + id: $this->header->id, + isResponse: $this->header->isResponse, + opcode: $this->header->opcode, + authoritative: $authoritative, + truncated: $answersTruncated, + recursionDesired: $this->header->recursionDesired, + recursionAvailable: $this->header->recursionAvailable, + responseCode: $this->header->responseCode, + questionCount: count($this->questions), + answerCount: $answerCount, + authorityCount: $authorityCount, + additionalCount: $additionalCount, + ); + + return $header->encode() . substr($packet, Header::LENGTH); } /** - * Encode with RFC-compliant truncation strategy. - * - * Truncation order per RFC 1035 Section 6.2: - * 1. Drop additional section first - * 2. If still too big, drop authority section - * 3. If still too big, include as many complete answer RRSets as fit, set TC - * - * TC flag is only set when answer section data is truncated (RFC 2181 Section 9). + * @param list $records */ - private function encodeWithTruncation(int $maxSize): string + private function appendRecords(string $packet, array $records): string { - // Step 1: Try without additional section - $withoutAdditional = self::response( - $this->header, - $this->header->responseCode, - questions: $this->questions, - answers: $this->answers, - authority: $this->authority, - additional: [], - authoritative: $this->header->authoritative, - truncated: false, - recursionAvailable: $this->header->recursionAvailable - ); - - $packet = $withoutAdditional->encode(); - if (strlen($packet) <= $maxSize) { - return $packet; - } - - // Step 2: Try without authority section. - // NODATA (NOERROR + no answers) and NXDOMAIN require SOA in authority per RFC; - // when we drop authority for size, mark as non-authoritative so validation allows it. - $isNodataOrNxdomain = ($this->header->responseCode === self::RCODE_NOERROR && $this->answers === []) - || $this->header->responseCode === self::RCODE_NXDOMAIN; - $withoutAuthority = self::response( - $this->header, - $this->header->responseCode, - questions: $this->questions, - answers: $this->answers, - authority: [], - additional: [], - authoritative: $isNodataOrNxdomain ? false : $this->header->authoritative, - truncated: false, - recursionAvailable: $this->header->recursionAvailable - ); - - $packet = $withoutAuthority->encode(); - if (strlen($packet) <= $maxSize) { - return $packet; - } - - // Step 3: Truncate answers - find how many complete records fit - // Build base packet with header + questions - $basePacket = $this->header->encode(); - foreach ($this->questions as $question) { - $basePacket .= $question->encode(); - } - - $fittingAnswers = []; - $tempPacket = $basePacket; - - foreach ($this->answers as $answer) { - $encodedAnswer = $answer->encode($tempPacket); - if (strlen($tempPacket) + strlen($encodedAnswer) <= $maxSize) { - $tempPacket .= $encodedAnswer; - $fittingAnswers[] = $answer; - } else { - // This answer doesn't fit, stop here - break; - } + foreach ($records as $record) { + $packet .= $record->encode($packet); } - - // Determine if we need to set TC flag - // Per RFC 2181 Section 9: TC is set only when required RRSet data couldn't fit - $needsTruncation = count($fittingAnswers) < count($this->answers); - - // When authority is empty (dropped for truncation), NODATA/NXDOMAIN must be non-authoritative - $isNodataOrNxdomainTruncated = ($this->header->responseCode === self::RCODE_NOERROR && $fittingAnswers === []) - || $this->header->responseCode === self::RCODE_NXDOMAIN; - - $truncatedResponse = self::response( - $this->header, - $this->header->responseCode, - questions: $this->questions, - answers: $fittingAnswers, - authority: [], - additional: [], - authoritative: $isNodataOrNxdomainTruncated ? false : $this->header->authoritative, - truncated: $needsTruncation, - recursionAvailable: $this->header->recursionAvailable - ); - - return $truncatedResponse->encode(); + return $packet; } } diff --git a/tests/unit/DNS/MessageTest.php b/tests/unit/DNS/MessageTest.php index 2482d5a..c8ab7a6 100644 --- a/tests/unit/DNS/MessageTest.php +++ b/tests/unit/DNS/MessageTest.php @@ -457,8 +457,9 @@ public function testEncodeWithoutMaxSizeDoesNotTruncate(): void } /** - * NODATA (NOERROR + no answers) with SOA in authority must be encodable when truncation - * drops the authority section; we mark as non-authoritative to satisfy validation. + * NODATA (NOERROR + no answers) with SOA in authority must be encodable when + * truncation drops the authority section; the AA flag is cleared so the + * resulting packet stays RFC-valid. */ public function testEncodeNodataWithTruncationDroppingAuthority(): void { @@ -491,4 +492,190 @@ public function testEncodeNodataWithTruncationDroppingAuthority(): void $this->assertCount(0, $decoded->authority); $this->assertFalse($decoded->header->authoritative, 'Dropped authority => non-authoritative'); } + + /** + * When authority doesn't fit, additional is dropped too — even if it + * would have fit alongside answers on its own. Locks in the section + * priority (additional depends on authority being included first). + */ + public function testTruncationDropsAdditionalWhenAuthorityOverflows(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0xAB01); + + $answers = [ + new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.1'), + ]; + + // Oversized authority — will not fit + $authority = []; + for ($i = 0; $i < 30; $i++) { + $authority[] = new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 3600, 'ns' . $i . '.example.com'); + } + + // Tiny additional — would fit on its own with just the answers + $additional = [ + new Record('glue.example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.2'), + ]; + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: $authority, + additional: $additional + ); + + $truncated = $response->encode(512); + $decoded = Message::decode($truncated); + + $this->assertFalse($decoded->header->truncated, 'TC should not be set when only authority/additional dropped'); + $this->assertCount(1, $decoded->answers); + $this->assertCount(0, $decoded->authority); + $this->assertCount(0, $decoded->additional, 'Additional must be dropped whenever authority is dropped'); + $this->assertLessThanOrEqual(512, strlen($truncated)); + } + + /** + * When answers are partially truncated, authority and additional are + * always cleared regardless of how much room remains. Prior tests used + * empty authority/additional for this path — this one populates both. + */ + public function testAnswerTruncationDropsPopulatedAuthorityAndAdditional(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0xCD02); + + $answers = []; + for ($i = 0; $i < 100; $i++) { + $answers[] = new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '10.0.' . ($i % 256) . '.' . ($i % 256)); + } + + $authority = []; + for ($i = 0; $i < 5; $i++) { + $authority[] = new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 3600, 'ns' . $i . '.example.com'); + } + + $additional = []; + for ($i = 0; $i < 5; $i++) { + $additional[] = new Record('ns' . $i . '.example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.2.' . $i); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: $authority, + additional: $additional + ); + + $truncated = $response->encode(512); + $decoded = Message::decode($truncated); + + $this->assertTrue($decoded->header->truncated, 'TC must be set when answers are partial'); + $this->assertGreaterThan(0, count($decoded->answers)); + $this->assertLessThan(100, count($decoded->answers)); + $this->assertCount(0, $decoded->authority, 'Authority cleared under answer truncation'); + $this->assertCount(0, $decoded->additional, 'Additional cleared under answer truncation'); + $this->assertLessThanOrEqual(512, strlen($truncated)); + } + + /** + * Re-encoding a message whose header already has TC=1 must preserve + * the flag when nothing new is dropped. The encode() short-circuit + * relies on the original header bytes being returned verbatim. + */ + public function testReEncodePreservesOriginalTruncatedFlag(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0xEF03); + + $answers = [ + new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.1'), + ]; + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: [], + additional: [], + truncated: true + ); + + $encoded = $response->encode(); + $decoded = Message::decode($encoded); + + $this->assertTrue($decoded->header->truncated, 'TC flag must survive re-encoding when nothing is dropped'); + $this->assertSame($encoded, $decoded->encode(), 'Second round-trip must be byte-identical'); + } + + /** + * maxSize equal to the natural encoded length must not trigger truncation. + * Guards against an off-by-one where `>` would incorrectly become `>=`. + */ + public function testEncodeFitsExactlyAtMaxSizeBoundary(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0x1104); + + $answers = [ + new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.1'), + new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.2'), + ]; + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: [], + additional: [] + ); + + $natural = $response->encode(); + $exactSize = strlen($natural); + + $atBoundary = $response->encode($exactSize); + $this->assertSame($natural, $atBoundary, 'Encoding at exact size must match unconstrained output'); + + $belowBoundary = $response->encode($exactSize - 1); + $this->assertLessThan(count($answers), count(Message::decode($belowBoundary)->answers)); + } + + /** + * NODATA-style response: zero answers but populated authority. When the + * authority section can't fit, it's dropped without setting TC — there + * are no answer records that failed to transmit. + */ + public function testNoAnswersWithOversizedAuthorityDropsWithoutTruncation(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0x2205); + + $authority = []; + for ($i = 0; $i < 30; $i++) { + $authority[] = new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 3600, 'ns' . $i . '.example.com'); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: [], + authority: $authority, + additional: [] + ); + + $truncated = $response->encode(512); + $decoded = Message::decode($truncated); + + $this->assertFalse($decoded->header->truncated, 'TC must remain unset when there were no answers to truncate'); + $this->assertCount(0, $decoded->answers); + $this->assertCount(0, $decoded->authority, 'Oversized authority is dropped'); + $this->assertLessThanOrEqual(512, strlen($truncated)); + } } From 7ac734c0e296b5b86c836898b558b20825d1fb02 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:19:54 +0100 Subject: [PATCH 2/3] Preserve AA flag through extreme answer truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check the original message's answer set (not the post-encoding count) when deciding whether to clear AA — a TC=1 response that merely encoded zero answers isn't semantically NODATA, and TC already signals the client to retry over TCP for the full authoritative answer. Relax the Message constructor's SOA-in-authority invariant for truncated responses so those packets remain decodable. --- src/DNS/Message.php | 10 +++++++-- tests/unit/DNS/MessageTest.php | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/DNS/Message.php b/src/DNS/Message.php index b301196..2026921 100644 --- a/src/DNS/Message.php +++ b/src/DNS/Message.php @@ -57,7 +57,10 @@ public function __construct( fn ($record) => $record->type === Record::TYPE_SOA )); - if ($header->isResponse && $header->authoritative && $soaAuthorityCount < 1) { + // TC=1 signals an incomplete response, so NODATA/NXDOMAIN invariants + // that require SOA in authority don't apply — the client will retry + // over TCP for the full answer. + if ($header->isResponse && $header->authoritative && !$header->truncated && $soaAuthorityCount < 1) { if ($header->responseCode === self::RCODE_NXDOMAIN) { throw new \InvalidArgumentException('NXDOMAIN requires SOA in authority'); } @@ -244,8 +247,11 @@ public function encode(?int $maxSize = null): string // When authority is dropped, an authoritative NODATA/NXDOMAIN response // loses the SOA it needs to remain RFC-valid, so clear the AA flag. + // Use the original message's intent (not post-truncation counts): a + // TC=1 response that merely encoded zero answers isn't NODATA — the + // client will retry over TCP and the AA claim remains accurate. $authorityDropped = $authorityCount < count($this->authority); - $isNodataOrNxdomain = ($this->header->responseCode === self::RCODE_NOERROR && $answerCount === 0) + $isNodataOrNxdomain = ($this->header->responseCode === self::RCODE_NOERROR && $this->answers === []) || $this->header->responseCode === self::RCODE_NXDOMAIN; $authoritative = ($authorityDropped && $isNodataOrNxdomain) ? false diff --git a/tests/unit/DNS/MessageTest.php b/tests/unit/DNS/MessageTest.php index c8ab7a6..f7e9ee1 100644 --- a/tests/unit/DNS/MessageTest.php +++ b/tests/unit/DNS/MessageTest.php @@ -646,6 +646,45 @@ public function testEncodeFitsExactlyAtMaxSizeBoundary(): void $this->assertLessThan(count($answers), count(Message::decode($belowBoundary)->answers)); } + /** + * Extreme answer truncation (TC=1, zero answers encoded) must not be + * confused with a NODATA response. The AA flag is the server's claim + * of authority over the zone; TC=1 already tells the client to retry + * over TCP to get the full answer, so AA must survive. + */ + public function testExtremeAnswerTruncationPreservesAuthoritativeFlag(): void + { + $question = new Question('example.com', Record::TYPE_A); + $query = Message::query($question, id: 0x3306); + + // Many large answers; every one will overflow the 60-byte limit, + // so encoding ends up with zero answers and TC=1. + $answers = []; + for ($i = 0; $i < 5; $i++) { + $answers[] = new Record('verylongname' . $i . '.example.com', Record::TYPE_A, Record::CLASS_IN, 60, '10.0.0.' . $i); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: [], + additional: [], + authoritative: true + ); + + $truncated = $response->encode(40); + $decoded = Message::decode($truncated); + + $this->assertTrue($decoded->header->truncated, 'TC must be set when answers are truncated'); + $this->assertCount(0, $decoded->answers); + $this->assertTrue( + $decoded->header->authoritative, + 'AA must survive truncation — the original message had answers, TC already signals retry' + ); + } + /** * NODATA-style response: zero answers but populated authority. When the * authority section can't fit, it's dropped without setting TC — there From 3b5d76c776e5b6279b6ba494683954afa3ae1e63 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:25:33 +0100 Subject: [PATCH 3/3] Preserve inbound TC=1 flag when re-encoding drops sections When a source message already has truncated=true (e.g. a forwarded packet) and encode() drops the additional or authority section under maxSize, the rebuilt header must keep TC=1 so downstream clients still know to retry over TCP. OR the original flag into the rebuilt header's truncated bit instead of always using the locally-computed value. --- src/DNS/Message.php | 4 +++- tests/unit/DNS/MessageTest.php | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/DNS/Message.php b/src/DNS/Message.php index 2026921..3633214 100644 --- a/src/DNS/Message.php +++ b/src/DNS/Message.php @@ -258,12 +258,14 @@ public function encode(?int $maxSize = null): string : $this->header->authoritative; // Per RFC 2181 Section 9, TC signals truncated required data (answers). + // Preserve an inbound TC=1 (e.g. from a forwarded packet) — dropping + // additional/authority on re-encode must not silently clear it. $header = new Header( id: $this->header->id, isResponse: $this->header->isResponse, opcode: $this->header->opcode, authoritative: $authoritative, - truncated: $answersTruncated, + truncated: $answersTruncated || $this->header->truncated, recursionDesired: $this->header->recursionDesired, recursionAvailable: $this->header->recursionAvailable, responseCode: $this->header->responseCode, diff --git a/tests/unit/DNS/MessageTest.php b/tests/unit/DNS/MessageTest.php index f7e9ee1..1b44abd 100644 --- a/tests/unit/DNS/MessageTest.php +++ b/tests/unit/DNS/MessageTest.php @@ -646,6 +646,47 @@ public function testEncodeFitsExactlyAtMaxSizeBoundary(): void $this->assertLessThan(count($answers), count(Message::decode($belowBoundary)->answers)); } + /** + * A forwarded/relayed message with TC=1 in its source header must + * retain that flag after re-encoding even when maxSize causes the + * additional or authority section to be dropped. Otherwise the + * downstream client loses the signal to retry over TCP. + */ + public function testReEncodeWithMaxSizePreservesOriginalTruncatedFlag(): void + { + $question = new Question('example.com', Record::TYPE_MX); + $query = Message::query($question, id: 0x4407); + + $answers = [ + new Record('example.com', Record::TYPE_MX, Record::CLASS_IN, 300, 'mail.example.com', priority: 10), + ]; + + // Oversized additional — will be dropped under maxSize=512. + $additional = []; + for ($i = 0; $i < 50; $i++) { + $additional[] = new Record('mail' . $i . '.example.com', Record::TYPE_A, Record::CLASS_IN, 300, '192.168.1.' . ($i % 256)); + } + + $response = Message::response( + $query->header, + Message::RCODE_NOERROR, + questions: $query->questions, + answers: $answers, + authority: [], + additional: $additional, + truncated: true + ); + + $reEncoded = $response->encode(512); + $decoded = Message::decode($reEncoded); + + $this->assertTrue( + $decoded->header->truncated, + 'Original TC=1 must survive re-encoding even when sections are dropped' + ); + $this->assertCount(0, $decoded->additional); + } + /** * Extreme answer truncation (TC=1, zero answers encoded) must not be * confused with a NODATA response. The AA flag is the server's claim