Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/DNS/Message/Record.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/DNS/Message/RecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/DNS/Zone/FileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading