Skip to content

Commit f2d932e

Browse files
committed
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.
1 parent 26f1ba7 commit f2d932e

2 files changed

Lines changed: 244 additions & 100 deletions

File tree

src/DNS/Message.php

Lines changed: 58 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -186,131 +186,89 @@ public static function decode(string $packet): self
186186
/**
187187
* Encode the message to a binary DNS packet.
188188
*
189-
* When maxSize is specified, truncation follows RFC 1035 Section 6.2 and RFC 2181 Section 9:
190-
* - Truncation starts at the end and works forward (additional → authority → answers)
191-
* - TC flag is only set when required RRSets (answers) couldn't be fully included
192-
* - Complete RRSets are preserved; partial RRSets are omitted entirely
189+
* When maxSize is specified, truncation follows RFC 1035 Section 6.2 and
190+
* RFC 2181 Section 9:
191+
* - Sections are dropped from the end first (additional → authority → answers)
192+
* - Authority and additional are all-or-nothing; answers allow partial inclusion
193+
* - TC flag is only set when answer records couldn't all fit
193194
* - Questions are always preserved
194195
*
195196
* @param int|null $maxSize Maximum packet size (e.g., 512 for UDP per RFC 1035)
196197
* @return string The encoded DNS packet
197198
*/
198199
public function encode(?int $maxSize = null): string
199200
{
200-
// Build full packet first
201201
$packet = $this->header->encode();
202-
203202
foreach ($this->questions as $question) {
204203
$packet .= $question->encode();
205204
}
206205

206+
// Answers: include as many complete records as fit (partial allowed).
207+
$answerCount = 0;
207208
foreach ($this->answers as $answer) {
208-
$packet .= $answer->encode($packet);
209+
$encoded = $answer->encode($packet);
210+
if ($maxSize !== null && strlen($packet) + strlen($encoded) > $maxSize) {
211+
break;
212+
}
213+
$packet .= $encoded;
214+
$answerCount++;
209215
}
210-
211-
foreach ($this->authority as $authority) {
212-
$packet .= $authority->encode($packet);
216+
$answersTruncated = $answerCount < count($this->answers);
217+
218+
// Authority then additional: all-or-nothing, and only once answers all fit.
219+
// Order matches RFC 1035 Section 6.2 (drop additional before authority).
220+
$authorityCount = 0;
221+
$additionalCount = 0;
222+
if (!$answersTruncated) {
223+
$withAuthority = $this->appendRecords($packet, $this->authority);
224+
if ($maxSize === null || strlen($withAuthority) <= $maxSize) {
225+
$packet = $withAuthority;
226+
$authorityCount = count($this->authority);
227+
228+
$withAdditional = $this->appendRecords($packet, $this->additional);
229+
if ($maxSize === null || strlen($withAdditional) <= $maxSize) {
230+
$packet = $withAdditional;
231+
$additionalCount = count($this->additional);
232+
}
233+
}
213234
}
214235

215-
foreach ($this->additional as $additional) {
216-
$packet .= $additional->encode($packet);
217-
}
236+
$sectionsUnchanged =
237+
$answerCount === count($this->answers)
238+
&& $authorityCount === count($this->authority)
239+
&& $additionalCount === count($this->additional);
218240

219-
// No truncation needed
220-
if ($maxSize === null || strlen($packet) <= $maxSize) {
241+
if ($sectionsUnchanged) {
221242
return $packet;
222243
}
223244

224-
// RFC-compliant truncation: work backward from end
225-
// Per RFC 1035 Section 6.2 and RFC 2181 Section 9
226-
return $this->encodeWithTruncation($maxSize);
245+
// Per RFC 2181 Section 9, TC signals truncated required data (answers).
246+
$header = new Header(
247+
id: $this->header->id,
248+
isResponse: $this->header->isResponse,
249+
opcode: $this->header->opcode,
250+
authoritative: $this->header->authoritative,
251+
truncated: $answersTruncated,
252+
recursionDesired: $this->header->recursionDesired,
253+
recursionAvailable: $this->header->recursionAvailable,
254+
responseCode: $this->header->responseCode,
255+
questionCount: count($this->questions),
256+
answerCount: $answerCount,
257+
authorityCount: $authorityCount,
258+
additionalCount: $additionalCount,
259+
);
260+
261+
return $header->encode() . substr($packet, Header::LENGTH);
227262
}
228263

229264
/**
230-
* Encode with RFC-compliant truncation strategy.
231-
*
232-
* Truncation order per RFC 1035 Section 6.2:
233-
* 1. Drop additional section first
234-
* 2. If still too big, drop authority section
235-
* 3. If still too big, include as many complete answer RRSets as fit, set TC
236-
*
237-
* TC flag is only set when answer section data is truncated (RFC 2181 Section 9).
265+
* @param list<Record> $records
238266
*/
239-
private function encodeWithTruncation(int $maxSize): string
267+
private function appendRecords(string $packet, array $records): string
240268
{
241-
// Step 1: Try without additional section
242-
$withoutAdditional = self::response(
243-
$this->header,
244-
$this->header->responseCode,
245-
questions: $this->questions,
246-
answers: $this->answers,
247-
authority: $this->authority,
248-
additional: [],
249-
authoritative: $this->header->authoritative,
250-
truncated: false,
251-
recursionAvailable: $this->header->recursionAvailable
252-
);
253-
254-
$packet = $withoutAdditional->encode();
255-
if (strlen($packet) <= $maxSize) {
256-
return $packet;
257-
}
258-
259-
// Step 2: Try without authority section
260-
$withoutAuthority = self::response(
261-
$this->header,
262-
$this->header->responseCode,
263-
questions: $this->questions,
264-
answers: $this->answers,
265-
authority: [],
266-
additional: [],
267-
authoritative: $this->header->authoritative,
268-
truncated: false,
269-
recursionAvailable: $this->header->recursionAvailable
270-
);
271-
272-
$packet = $withoutAuthority->encode();
273-
if (strlen($packet) <= $maxSize) {
274-
return $packet;
269+
foreach ($records as $record) {
270+
$packet .= $record->encode($packet);
275271
}
276-
277-
// Step 3: Truncate answers - find how many complete records fit
278-
// Build base packet with header + questions
279-
$basePacket = $this->header->encode();
280-
foreach ($this->questions as $question) {
281-
$basePacket .= $question->encode();
282-
}
283-
284-
$fittingAnswers = [];
285-
$tempPacket = $basePacket;
286-
287-
foreach ($this->answers as $answer) {
288-
$encodedAnswer = $answer->encode($tempPacket);
289-
if (strlen($tempPacket) + strlen($encodedAnswer) <= $maxSize) {
290-
$tempPacket .= $encodedAnswer;
291-
$fittingAnswers[] = $answer;
292-
} else {
293-
// This answer doesn't fit, stop here
294-
break;
295-
}
296-
}
297-
298-
// Determine if we need to set TC flag
299-
// Per RFC 2181 Section 9: TC is set only when required RRSet data couldn't fit
300-
$needsTruncation = count($fittingAnswers) < count($this->answers);
301-
302-
$truncatedResponse = self::response(
303-
$this->header,
304-
$this->header->responseCode,
305-
questions: $this->questions,
306-
answers: $fittingAnswers,
307-
authority: [],
308-
additional: [],
309-
authoritative: $this->header->authoritative,
310-
truncated: $needsTruncation,
311-
recursionAvailable: $this->header->recursionAvailable
312-
);
313-
314-
return $truncatedResponse->encode();
272+
return $packet;
315273
}
316274
}

tests/unit/DNS/MessageTest.php

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,4 +455,190 @@ public function testEncodeWithoutMaxSizeDoesNotTruncate(): void
455455
// Verify all answers are preserved
456456
$this->assertCount(5, $decoded->answers);
457457
}
458+
459+
/**
460+
* When authority doesn't fit, additional is dropped too — even if it
461+
* would have fit alongside answers on its own. Locks in the section
462+
* priority (additional depends on authority being included first).
463+
*/
464+
public function testTruncationDropsAdditionalWhenAuthorityOverflows(): void
465+
{
466+
$question = new Question('example.com', Record::TYPE_A);
467+
$query = Message::query($question, id: 0xAB01);
468+
469+
$answers = [
470+
new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.1'),
471+
];
472+
473+
// Oversized authority — will not fit
474+
$authority = [];
475+
for ($i = 0; $i < 30; $i++) {
476+
$authority[] = new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 3600, 'ns' . $i . '.example.com');
477+
}
478+
479+
// Tiny additional — would fit on its own with just the answers
480+
$additional = [
481+
new Record('glue.example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.2'),
482+
];
483+
484+
$response = Message::response(
485+
$query->header,
486+
Message::RCODE_NOERROR,
487+
questions: $query->questions,
488+
answers: $answers,
489+
authority: $authority,
490+
additional: $additional
491+
);
492+
493+
$truncated = $response->encode(512);
494+
$decoded = Message::decode($truncated);
495+
496+
$this->assertFalse($decoded->header->truncated, 'TC should not be set when only authority/additional dropped');
497+
$this->assertCount(1, $decoded->answers);
498+
$this->assertCount(0, $decoded->authority);
499+
$this->assertCount(0, $decoded->additional, 'Additional must be dropped whenever authority is dropped');
500+
$this->assertLessThanOrEqual(512, strlen($truncated));
501+
}
502+
503+
/**
504+
* When answers are partially truncated, authority and additional are
505+
* always cleared regardless of how much room remains. Prior tests used
506+
* empty authority/additional for this path — this one populates both.
507+
*/
508+
public function testAnswerTruncationDropsPopulatedAuthorityAndAdditional(): void
509+
{
510+
$question = new Question('example.com', Record::TYPE_A);
511+
$query = Message::query($question, id: 0xCD02);
512+
513+
$answers = [];
514+
for ($i = 0; $i < 100; $i++) {
515+
$answers[] = new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '10.0.' . ($i % 256) . '.' . ($i % 256));
516+
}
517+
518+
$authority = [];
519+
for ($i = 0; $i < 5; $i++) {
520+
$authority[] = new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 3600, 'ns' . $i . '.example.com');
521+
}
522+
523+
$additional = [];
524+
for ($i = 0; $i < 5; $i++) {
525+
$additional[] = new Record('ns' . $i . '.example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.2.' . $i);
526+
}
527+
528+
$response = Message::response(
529+
$query->header,
530+
Message::RCODE_NOERROR,
531+
questions: $query->questions,
532+
answers: $answers,
533+
authority: $authority,
534+
additional: $additional
535+
);
536+
537+
$truncated = $response->encode(512);
538+
$decoded = Message::decode($truncated);
539+
540+
$this->assertTrue($decoded->header->truncated, 'TC must be set when answers are partial');
541+
$this->assertGreaterThan(0, count($decoded->answers));
542+
$this->assertLessThan(100, count($decoded->answers));
543+
$this->assertCount(0, $decoded->authority, 'Authority cleared under answer truncation');
544+
$this->assertCount(0, $decoded->additional, 'Additional cleared under answer truncation');
545+
$this->assertLessThanOrEqual(512, strlen($truncated));
546+
}
547+
548+
/**
549+
* Re-encoding a message whose header already has TC=1 must preserve
550+
* the flag when nothing new is dropped. The encode() short-circuit
551+
* relies on the original header bytes being returned verbatim.
552+
*/
553+
public function testReEncodePreservesOriginalTruncatedFlag(): void
554+
{
555+
$question = new Question('example.com', Record::TYPE_A);
556+
$query = Message::query($question, id: 0xEF03);
557+
558+
$answers = [
559+
new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.1'),
560+
];
561+
562+
$response = Message::response(
563+
$query->header,
564+
Message::RCODE_NOERROR,
565+
questions: $query->questions,
566+
answers: $answers,
567+
authority: [],
568+
additional: [],
569+
truncated: true
570+
);
571+
572+
$encoded = $response->encode();
573+
$decoded = Message::decode($encoded);
574+
575+
$this->assertTrue($decoded->header->truncated, 'TC flag must survive re-encoding when nothing is dropped');
576+
$this->assertSame($encoded, $decoded->encode(), 'Second round-trip must be byte-identical');
577+
}
578+
579+
/**
580+
* maxSize equal to the natural encoded length must not trigger truncation.
581+
* Guards against an off-by-one where `>` would incorrectly become `>=`.
582+
*/
583+
public function testEncodeFitsExactlyAtMaxSizeBoundary(): void
584+
{
585+
$question = new Question('example.com', Record::TYPE_A);
586+
$query = Message::query($question, id: 0x1104);
587+
588+
$answers = [
589+
new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.1'),
590+
new Record('example.com', Record::TYPE_A, Record::CLASS_IN, 60, '192.168.1.2'),
591+
];
592+
593+
$response = Message::response(
594+
$query->header,
595+
Message::RCODE_NOERROR,
596+
questions: $query->questions,
597+
answers: $answers,
598+
authority: [],
599+
additional: []
600+
);
601+
602+
$natural = $response->encode();
603+
$exactSize = strlen($natural);
604+
605+
$atBoundary = $response->encode($exactSize);
606+
$this->assertSame($natural, $atBoundary, 'Encoding at exact size must match unconstrained output');
607+
608+
$belowBoundary = $response->encode($exactSize - 1);
609+
$this->assertLessThan(count($answers), count(Message::decode($belowBoundary)->answers));
610+
}
611+
612+
/**
613+
* NODATA-style response: zero answers but populated authority. When the
614+
* authority section can't fit, it's dropped without setting TC — there
615+
* are no answer records that failed to transmit.
616+
*/
617+
public function testNoAnswersWithOversizedAuthorityDropsWithoutTruncation(): void
618+
{
619+
$question = new Question('example.com', Record::TYPE_A);
620+
$query = Message::query($question, id: 0x2205);
621+
622+
$authority = [];
623+
for ($i = 0; $i < 30; $i++) {
624+
$authority[] = new Record('example.com', Record::TYPE_NS, Record::CLASS_IN, 3600, 'ns' . $i . '.example.com');
625+
}
626+
627+
$response = Message::response(
628+
$query->header,
629+
Message::RCODE_NOERROR,
630+
questions: $query->questions,
631+
answers: [],
632+
authority: $authority,
633+
additional: []
634+
);
635+
636+
$truncated = $response->encode(512);
637+
$decoded = Message::decode($truncated);
638+
639+
$this->assertFalse($decoded->header->truncated, 'TC must remain unset when there were no answers to truncate');
640+
$this->assertCount(0, $decoded->answers);
641+
$this->assertCount(0, $decoded->authority, 'Oversized authority is dropped');
642+
$this->assertLessThanOrEqual(512, strlen($truncated));
643+
}
458644
}

0 commit comments

Comments
 (0)