Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
49 changes: 48 additions & 1 deletion src/DNS/Message/Domain.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public static function encode(string $name): string
return "\x00";
}

$labels = explode('.', $trimmed);
$labels = self::splitLabels($trimmed);
$labelCount = count($labels);

if ($labelCount > self::MAX_LABELS) {
Expand Down Expand Up @@ -71,6 +71,53 @@ public static function encode(string $name): string
return $encoded . "\x00";
}

/**
* Split a presentation-format domain name into labels.
*
* Escaped dots represent literal dots inside a label, as used by SOA RNAME
* mailbox local parts such as "first\.last.example.com".
*
* @return list<string>
*/
private static function splitLabels(string $name): array
{
$labels = [];
$label = '';
$length = strlen($name);
$escaped = false;

for ($i = 0; $i < $length; $i++) {
$char = $name[$i];

if ($escaped) {
$label .= $char;
$escaped = false;
continue;
}

if ($char === '\\') {
$escaped = true;
continue;
}

if ($char === '.') {
$labels[] = $label;
$label = '';
continue;
}

$label .= $char;
}

if ($escaped) {
$label .= '\\';
}

$labels[] = $label;

return $labels;
}

/**
* Decode a domain name from DNS wire format, handling compression pointers.
*
Expand Down
44 changes: 44 additions & 0 deletions src/DNS/Message/Record.php
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ private function encodeSoaRdata(): string
}

[$mname, $rname, $serial, $refresh, $retry, $expire, $minimum] = $parts;
$rname = self::normalizeSoaRname($rname);

$numbers = [];
foreach ([$serial, $refresh, $retry, $expire, $minimum] as $value) {
Expand All @@ -535,6 +536,49 @@ private function encodeSoaRdata(): string
. pack('NNNNN', $serialNum, $refreshNum, $retryNum, $expireNum, $minimumNum);
}

private static function normalizeSoaRname(string $rname): string
{
if (!str_contains($rname, '@')) {
return $rname;
}

[$localPart, $domain] = explode('@', $rname, 2);

return self::escapeSoaRnameLocalPart($localPart) . '.' . $domain;
}

private static function escapeSoaRnameLocalPart(string $localPart): string
{
$escaped = '';
$length = strlen($localPart);
$isEscaped = false;

for ($i = 0; $i < $length; $i++) {
$char = $localPart[$i];

if ($isEscaped) {
$escaped .= $char;
$isEscaped = false;
continue;
}

if ($char === '\\') {
$escaped .= $char;
$isEscaped = true;
continue;
}

if ($char === '.') {
$escaped .= '\\.';
continue;
}

$escaped .= $char;
}

return $escaped;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

private function encodeCaaRdata(): string
{
$input = trim($this->rdata);
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/DNS/Message/DomainTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ public function testEncodeTreatsSingleTrailingDotAsAbsolute(): void
);
}

public function testEncodeTreatsEscapedDotAsLiteralLabelCharacter(): void
{
$encoded = Domain::encode('first\.last.example.com');

$this->assertSame("\x0Afirst.last\x07example\x03com\x00", $encoded);
}

public function testEncodeAllowsRootViaEmptyString(): void
{
$this->assertSame("\x00", Domain::encode(''));
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