@@ -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