diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 4188ed2..fb7eb95 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -531,10 +531,45 @@ private function encodeSoaRdata(): string [$serialNum, $refreshNum, $retryNum, $expireNum, $minimumNum] = $numbers; return Domain::encode($mname) - . Domain::encode($rname) + . self::encodeSoaRname($rname) . pack('NNNNN', $serialNum, $refreshNum, $retryNum, $expireNum, $minimumNum); } + private static function encodeSoaRname(string $rname): string + { + if (!str_contains($rname, '@')) { + return Domain::encode($rname); + } + + if (substr_count($rname, '@') > 1) { + throw new \InvalidArgumentException( + 'SOA RNAME email must contain exactly one @ separator' + ); + } + + [$localPart, $domain] = explode('@', $rname, 2); + + if ($localPart === '' || $domain === '') { + throw new \InvalidArgumentException( + 'SOA RNAME email must have non-empty local part and domain' + ); + } + + $localLength = strlen($localPart); + if ($localLength > Domain::MAX_LABEL_LEN) { + throw new \InvalidArgumentException("Label too long: $localPart"); + } + + $encoded = chr($localLength) . $localPart . Domain::encode($domain); + if (strlen($encoded) > Domain::MAX_DOMAIN_NAME_LEN) { + throw new \InvalidArgumentException( + "Encoded domain exceeds maximum length of " . Domain::MAX_DOMAIN_NAME_LEN . ' bytes' + ); + } + + return $encoded; + } + private function encodeCaaRdata(): string { $input = trim($this->rdata); diff --git a/tests/unit/DNS/Message/RecordTest.php b/tests/unit/DNS/Message/RecordTest.php index 44cc053..f5ed386 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -295,6 +295,36 @@ class: Record::CLASS_IN, $this->assertSame($expected, $record->encode()); } + public function testEncodeSoaRecordAcceptsEmailRname(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'ns1.example.com hostmaster@example.com 2024102701 7200 3600 1209600 86400' + ); + + $encoded = $record->encode(); + + $this->assertStringContainsString("\x0Ahostmaster\x07example\x03com\x00", $encoded); + } + + public function testEncodeSoaRecordEscapesDotsInEmailRnameLocalPart(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'ns1.example.com first.last@example.com 2024102701 7200 3600 1209600 86400' + ); + + $encoded = $record->encode(); + + $this->assertStringContainsString("\x0Afirst.last\x07example\x03com\x00", $encoded); + } + public function testDecodeTxtRecordWithMultipleChunks(): void { // TXT with two chunks: "hello" (5 bytes) + "world" (5 bytes) diff --git a/tests/unit/DNS/Zone/FileTest.php b/tests/unit/DNS/Zone/FileTest.php index c51783e..c2c7832 100644 --- a/tests/unit/DNS/Zone/FileTest.php +++ b/tests/unit/DNS/Zone/FileTest.php @@ -202,6 +202,20 @@ public function testImportUsesDefaultOriginWhenDirectiveMissing(): void $this->assertSame('www.example.com', $zone->records[0]->name); } + public function testImportAllowsEmailAddressSoaRnameToEncode(): void + { + $contents = <<<'ZONE' +@ IN SOA ns1.example.com. first.last@example.com. 2025011801 7200 3600 1209600 1800 +www 600 IN A 192.0.2.10 +ZONE; + + $zone = File::import($contents, 'example.com'); + + $encoded = $zone->soa->encode(); + + $this->assertStringContainsString("\x0Afirst.last\x07example\x03com\x00", $encoded); + } + public function testImportFailsWhenSoaDataMissing(): void { $this->expectException(ImportException::class);