diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 2618e7b..ca6721c 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -448,14 +448,26 @@ 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; + $totalLen = strlen($rdata); + + // Handle empty rdata: emit a single zero-length character-string + if ($totalLen === 0) { + return chr(0); + } + + $encoded = ''; + $pos = 0; + + 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..029a581 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -357,4 +357,129 @@ public function testDecodeSoaRecordRoundTrip(): void $this->assertSame($original, $encoded); } + + public function testEncodeTxtRecordWithMultipleChunks(): void + { + // 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: $exactly256Bytes + ); + + $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); + + // 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 + { + // 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); + } + + 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); + } }