From 2af7f598528b9c3ae90fef155aa85a78c6755850 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Wed, 18 Feb 2026 10:51:38 +0100 Subject: [PATCH 1/3] Implement chunked encoding for TXT records in DNS Message Record class and add corresponding unit tests for multi-chunk handling. --- src/DNS/Message/Record.php | 18 ++++-- tests/unit/DNS/Message/RecordTest.php | 84 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 2618e7b..5db0db7 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -448,14 +448,20 @@ private function encodeRdata(string $packet): string Domain::encode($this->rdata); case self::TYPE_TXT: - $len = strlen($this->rdata); - if ($len > self::MAX_TXT_CHUNK) { - throw new \InvalidArgumentException( - 'TXT record chunk exceeds ' . self::MAX_TXT_CHUNK . ' bytes' - ); + // Split rdata into chunks of up to 255 bytes each per RFC 1035 + $rdata = $this->rdata; + $encoded = ''; + $pos = 0; + $totalLen = strlen($rdata); + + while ($pos < $totalLen) { + $chunkLen = min(self::MAX_TXT_CHUNK, $totalLen - $pos); + $chunk = substr($rdata, $pos, $chunkLen); + $encoded .= chr($chunkLen) . $chunk; + $pos += $chunkLen; } - return chr($len) . $this->rdata; + return $encoded; case self::TYPE_CAA: return $this->encodeCaaRdata(); diff --git a/tests/unit/DNS/Message/RecordTest.php b/tests/unit/DNS/Message/RecordTest.php index 9d3e2cb..aafe621 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -357,4 +357,88 @@ public function testDecodeSoaRecordRoundTrip(): void $this->assertSame($original, $encoded); } + + public function testEncodeTxtRecordWithMultipleChunks(): void + { + // Create a TXT record with rdata that will be split into multiple chunks + // "hello" (5 bytes) + "world" (5 bytes) = 10 bytes total + $record = new Record( + name: 'example.com', + type: Record::TYPE_TXT, + class: Record::CLASS_IN, + ttl: 600, + rdata: 'helloworld' + ); + + $expected = "\x07example\x03com\x00" + . "\x00\x10" // TYPE_TXT + . "\x00\x01" // CLASS_IN + . "\x00\x00\x02\x58" // TTL: 600 + . "\x00\x0A" // RDLENGTH: 10 bytes (1+5+1+5) + . "\x05hello" + . "\x05world"; + + $this->assertSame($expected, $record->encode()); + } + + public function testEncodeTxtRecordWithLongString(): void + { + // Create a TXT record with rdata > 255 bytes to test chunking + $longString = str_repeat('a', 300); // 300 bytes + $record = new Record( + name: 'example.com', + type: Record::TYPE_TXT, + class: Record::CLASS_IN, + ttl: 600, + rdata: $longString + ); + + $encoded = $record->encode(); + $offset = 0; + $decoded = Record::decode($encoded, $offset); + + // Verify round-trip: decoded rdata should match original + $this->assertSame($longString, $decoded->rdata); + $this->assertSame(Record::TYPE_TXT, $decoded->type); + $this->assertSame(600, $decoded->ttl); + + // Verify the encoded data has multiple chunks + // Extract RDATA portion (skip header: name + type + class + ttl + rdlength) + $nameLen = strlen("\x07example\x03com\x00"); + $headerLen = $nameLen + 2 + 2 + 4 + 2; // name + type + class + ttl + rdlength + $rdataEncoded = substr($encoded, $headerLen); + + // Should have 2 chunks: 255 bytes + 45 bytes = 300 bytes total + // First chunk: chr(255) + 255 bytes = 256 bytes + // Second chunk: chr(45) + 45 bytes = 46 bytes + // Total RDATA: 256 + 46 = 302 bytes + $this->assertSame(302, strlen($rdataEncoded)); // Total RDATA length + $this->assertSame(255, ord($rdataEncoded[0])); // First chunk is 255 bytes + $this->assertSame(45, ord($rdataEncoded[256])); // Second chunk is 45 bytes + } + + public function testEncodeTxtRecordRoundTripWithMultipleChunks(): void + { + // Test round-trip encoding/decoding of multi-chunk TXT record + $original = "\x07example\x03com\x00" + . "\x00\x10" // TYPE_TXT + . "\x00\x01" // CLASS_IN + . "\x00\x00\x02\x58" // TTL: 600 + . "\x00\x0C" // RDLENGTH: 12 bytes (1+3+1+3+1+3) + . "\x03foo" + . "\x03bar" + . "\x03baz"; + + $offset = 0; + $record = Record::decode($original, $offset); + $encoded = $record->encode(); + + // Decode again to verify + $offset2 = 0; + $record2 = Record::decode($encoded, $offset2); + + $this->assertSame('foobarbaz', $record2->rdata); + $this->assertSame(Record::TYPE_TXT, $record2->type); + $this->assertSame(600, $record2->ttl); + } } From 0a38670550ede8d3057d9d1f0469e2cbc7b2daef Mon Sep 17 00:00:00 2001 From: eldadfux Date: Wed, 18 Feb 2026 10:57:07 +0100 Subject: [PATCH 2/3] Enhance unit test for TXT record encoding to validate chunking behavior for exactly 256 bytes of rdata, ensuring correct split into two chunks and verifying round-trip encoding/decoding. --- tests/unit/DNS/Message/RecordTest.php | 33 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/tests/unit/DNS/Message/RecordTest.php b/tests/unit/DNS/Message/RecordTest.php index aafe621..8d28923 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -360,25 +360,36 @@ public function testDecodeSoaRecordRoundTrip(): void public function testEncodeTxtRecordWithMultipleChunks(): void { - // Create a TXT record with rdata that will be split into multiple chunks - // "hello" (5 bytes) + "world" (5 bytes) = 10 bytes total + // Test that a TXT record with rdata exactly 256 bytes gets split into 2 chunks + // (255 bytes + 1 byte) + $exactly256Bytes = str_repeat('a', 256); $record = new Record( name: 'example.com', type: Record::TYPE_TXT, class: Record::CLASS_IN, ttl: 600, - rdata: 'helloworld' + rdata: $exactly256Bytes ); - $expected = "\x07example\x03com\x00" - . "\x00\x10" // TYPE_TXT - . "\x00\x01" // CLASS_IN - . "\x00\x00\x02\x58" // TTL: 600 - . "\x00\x0A" // RDLENGTH: 10 bytes (1+5+1+5) - . "\x05hello" - . "\x05world"; + $encoded = $record->encode(); + + // Extract RDATA portion to verify chunking + $nameLen = strlen("\x07example\x03com\x00"); + $headerLen = $nameLen + 2 + 2 + 4 + 2; // name + type + class + ttl + rdlength + $rdataEncoded = substr($encoded, $headerLen); - $this->assertSame($expected, $record->encode()); + // Should have 2 chunks: 255 bytes + 1 byte = 256 bytes total + // First chunk: chr(255) + 255 bytes = 256 bytes + // Second chunk: chr(1) + 1 byte = 2 bytes + // Total RDATA: 256 + 2 = 258 bytes + $this->assertSame(258, strlen($rdataEncoded)); // Total RDATA length + $this->assertSame(255, ord($rdataEncoded[0])); // First chunk is 255 bytes + $this->assertSame(1, ord($rdataEncoded[256])); // Second chunk is 1 byte + + // Verify round-trip + $offset = 0; + $decoded = Record::decode($encoded, $offset); + $this->assertSame($exactly256Bytes, $decoded->rdata); } public function testEncodeTxtRecordWithLongString(): void From 2fcf6d01af994a28c42ce286defbed203e65786a Mon Sep 17 00:00:00 2001 From: eldadfux Date: Wed, 18 Feb 2026 11:01:00 +0100 Subject: [PATCH 3/3] Handle empty rdata in TXT record encoding by emitting a zero-length character-string, ensuring compliance with RFC 1035. --- src/DNS/Message/Record.php | 8 ++++++- tests/unit/DNS/Message/RecordTest.php | 34 +++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 5db0db7..ca6721c 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -450,9 +450,15 @@ private function encodeRdata(string $packet): string case self::TYPE_TXT: // Split rdata into chunks of up to 255 bytes each per RFC 1035 $rdata = $this->rdata; + $totalLen = strlen($rdata); + + // Handle empty rdata: emit a single zero-length character-string + if ($totalLen === 0) { + return chr(0); + } + $encoded = ''; $pos = 0; - $totalLen = strlen($rdata); while ($pos < $totalLen) { $chunkLen = min(self::MAX_TXT_CHUNK, $totalLen - $pos); diff --git a/tests/unit/DNS/Message/RecordTest.php b/tests/unit/DNS/Message/RecordTest.php index 8d28923..029a581 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -372,7 +372,7 @@ class: Record::CLASS_IN, ); $encoded = $record->encode(); - + // Extract RDATA portion to verify chunking $nameLen = strlen("\x07example\x03com\x00"); $headerLen = $nameLen + 2 + 2 + 4 + 2; // name + type + class + ttl + rdlength @@ -385,7 +385,7 @@ class: Record::CLASS_IN, $this->assertSame(258, strlen($rdataEncoded)); // Total RDATA length $this->assertSame(255, ord($rdataEncoded[0])); // First chunk is 255 bytes $this->assertSame(1, ord($rdataEncoded[256])); // Second chunk is 1 byte - + // Verify round-trip $offset = 0; $decoded = Record::decode($encoded, $offset); @@ -452,4 +452,34 @@ public function testEncodeTxtRecordRoundTripWithMultipleChunks(): void $this->assertSame(Record::TYPE_TXT, $record2->type); $this->assertSame(600, $record2->ttl); } + + public function testEncodeTxtRecordWithEmptyRdata(): void + { + // Test that empty TXT rdata is encoded as a single zero-length character-string + $record = new Record( + name: 'example.com', + type: Record::TYPE_TXT, + class: Record::CLASS_IN, + ttl: 600, + rdata: '' + ); + + $encoded = $record->encode(); + + // Extract RDATA portion + $nameLen = strlen("\x07example\x03com\x00"); + $headerLen = $nameLen + 2 + 2 + 4 + 2; // name + type + class + ttl + rdlength + $rdataEncoded = substr($encoded, $headerLen); + + // Should be a single zero-length character-string: chr(0) + $this->assertSame(1, strlen($rdataEncoded)); // RDLENGTH should be 1 + $this->assertSame(0, ord($rdataEncoded[0])); // First (and only) chunk is 0 bytes + + // Verify round-trip: decode should work and return empty string + $offset = 0; + $decoded = Record::decode($encoded, $offset); + $this->assertSame('', $decoded->rdata); + $this->assertSame(Record::TYPE_TXT, $decoded->type); + $this->assertSame(600, $decoded->ttl); + } }