From 1366cbcbc237ad916768ca3afaaa412ce6db0a9a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 19 May 2026 19:29:18 +0530 Subject: [PATCH 1/3] Fix SOA RNAME email encoding --- src/DNS/Message/Domain.php | 49 ++++++++++++++++++++++++++- src/DNS/Message/Record.php | 44 ++++++++++++++++++++++++ tests/unit/DNS/Message/DomainTest.php | 7 ++++ tests/unit/DNS/Message/RecordTest.php | 30 ++++++++++++++++ tests/unit/DNS/Zone/FileTest.php | 14 ++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/DNS/Message/Domain.php b/src/DNS/Message/Domain.php index 1e89fae..4342ed4 100644 --- a/src/DNS/Message/Domain.php +++ b/src/DNS/Message/Domain.php @@ -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) { @@ -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 + */ + 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. * diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 4188ed2..7df44ba 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -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) { @@ -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; + } + private function encodeCaaRdata(): string { $input = trim($this->rdata); diff --git a/tests/unit/DNS/Message/DomainTest.php b/tests/unit/DNS/Message/DomainTest.php index a3acbf0..db72310 100644 --- a/tests/unit/DNS/Message/DomainTest.php +++ b/tests/unit/DNS/Message/DomainTest.php @@ -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('')); 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); From 5ab793d75b337d32fc65aaa863cdd4b6a4625a56 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 19 May 2026 19:44:07 +0530 Subject: [PATCH 2/3] Address Greptile review on SOA RNAME encoding - Make Domain::encode preprocessing escape-aware (no more rtrim corrupting escaped dots, no more str_ends_with('..') false-positives). - Reject dangling trailing backslashes and unknown \X escape sequences in splitLabels so silent behavior changes surface loudly. - Validate SOA RNAME up-front: exactly one @, non-empty local part and domain, no trailing backslash in the local part. - Document encode/decode asymmetry on Domain::decode. - Add regression tests covering escaped-dot edge cases, dangling and unknown escapes, SOA wire round-trip, multi-@, empty local part / domain, pre-escaped RNAME, single-label RNAME, and over-long local part. --- src/DNS/Message/Domain.php | 46 +++++++-- src/DNS/Message/Record.php | 18 ++++ tests/unit/DNS/Message/DomainTest.php | 39 ++++++++ tests/unit/DNS/Message/RecordTest.php | 135 ++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 8 deletions(-) diff --git a/src/DNS/Message/Domain.php b/src/DNS/Message/Domain.php index 4342ed4..a2f5b46 100644 --- a/src/DNS/Message/Domain.php +++ b/src/DNS/Message/Domain.php @@ -13,6 +13,11 @@ /** * Encode a domain name according to RFC 1035. * + * Recognises two escape sequences inside labels: `\.` for a literal dot + * and `\\` for a literal backslash. Any other `\X` sequence is rejected. + * A single unescaped trailing dot is treated as the FQDN terminator and + * does not produce an empty trailing label. + * * @param string $name * @return string */ @@ -22,16 +27,22 @@ public static function encode(string $name): string return "\x00"; } - if (str_ends_with($name, '..')) { - throw new \InvalidArgumentException('Domain labels must not be empty'); + $labels = self::splitLabels($name); + + // FQDN terminator: a trailing unescaped dot ("example.com.") leaves an + // empty trailing label; drop it. Consecutive trailing dots + // ("example..") leave an empty label *before* the terminator, which is + // still caught by the empty-label check below. + if (count($labels) > 1 && end($labels) === '') { + array_pop($labels); } - $trimmed = rtrim($name, '.'); - if ($trimmed === '') { + // Root domain shorthand: "." (and similar all-dot inputs that collapse + // to a single empty label after FQDN trim) encode as the zero byte. + if (count($labels) === 1 && $labels[0] === '') { return "\x00"; } - $labels = self::splitLabels($trimmed); $labelCount = count($labels); if ($labelCount > self::MAX_LABELS) { @@ -74,8 +85,13 @@ public static function encode(string $name): string /** * 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". + * Two escape sequences are recognised inside labels: `\.` for a literal + * dot and `\\` for a literal backslash. These match the subset of RFC 1035 + * escapes needed by SOA RNAME mailbox local parts such as + * "first\.last.example.com". The RFC 1035 `\DDD` decimal escape is *not* + * supported. Any other `\X` sequence is rejected so the behaviour change + * is loud rather than silent for callers that previously passed raw + * backslashes through Domain::encode(). * * @return list */ @@ -90,6 +106,11 @@ private static function splitLabels(string $name): array $char = $name[$i]; if ($escaped) { + if ($char !== '.' && $char !== '\\') { + throw new \InvalidArgumentException( + 'Invalid escape sequence in domain name: \\' . $char + ); + } $label .= $char; $escaped = false; continue; @@ -110,7 +131,9 @@ private static function splitLabels(string $name): array } if ($escaped) { - $label .= '\\'; + throw new \InvalidArgumentException( + 'Domain name has a dangling trailing backslash' + ); } $labels[] = $label; @@ -125,6 +148,13 @@ private static function splitLabels(string $name): array * reference earlier occurrences in the packet. This implementation tracks * visited pointer positions to prevent infinite loops from malicious packets. * + * Asymmetry with encode(): label bytes are joined with literal `.` + * without re-escaping. A wire-format SOA RNAME with a dotted local part + * (e.g. labels ["first.last", "example", "com"]) decodes to + * "first.last.example.com", which then re-encodes as four labels rather + * than three. Callers that need to round-trip dotted local parts must + * track the original label boundaries themselves. + * * @param string $data Full DNS packet * @param int $offset Current read offset (updated to first byte after the name) * @return string Decoded domain name in dotted form diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 7df44ba..26c3ea7 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -542,8 +542,20 @@ private static function normalizeSoaRname(string $rname): string return $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' + ); + } + return self::escapeSoaRnameLocalPart($localPart) . '.' . $domain; } @@ -576,6 +588,12 @@ private static function escapeSoaRnameLocalPart(string $localPart): string $escaped .= $char; } + if ($isEscaped) { + throw new \InvalidArgumentException( + 'SOA RNAME local part cannot end with a dangling backslash' + ); + } + return $escaped; } diff --git a/tests/unit/DNS/Message/DomainTest.php b/tests/unit/DNS/Message/DomainTest.php index db72310..51971d4 100644 --- a/tests/unit/DNS/Message/DomainTest.php +++ b/tests/unit/DNS/Message/DomainTest.php @@ -31,6 +31,45 @@ public function testEncodeTreatsEscapedDotAsLiteralLabelCharacter(): void $this->assertSame("\x0Afirst.last\x07example\x03com\x00", $encoded); } + public function testEncodePreservesEscapedDotImmediatelyBeforeFqdnTerminator(): void + { + // "foo\.." = label "foo." (escaped dot) followed by FQDN root terminator. + $encoded = Domain::encode('foo\..'); + + $this->assertSame("\x04foo.\x00", $encoded); + } + + public function testEncodeTreatsEscapedDotAsLiteralEvenWithoutTrailingTerminator(): void + { + // "foo\." = single label "foo." with no FQDN terminator. + $encoded = Domain::encode('foo\.'); + + $this->assertSame("\x04foo.\x00", $encoded); + } + + public function testEncodeTreatsEscapedBackslashAsLiteralBackslash(): void + { + $encoded = Domain::encode('foo\\\\.com'); + + $this->assertSame("\x04foo\\\x03com\x00", $encoded); + } + + public function testEncodeRejectsDanglingTrailingBackslash(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('dangling trailing backslash'); + + Domain::encode('foo\\'); + } + + public function testEncodeRejectsUnknownEscapeSequence(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid escape sequence'); + + Domain::encode('foo\xbar'); + } + public function testEncodeAllowsRootViaEmptyString(): void { $this->assertSame("\x00", Domain::encode('')); diff --git a/tests/unit/DNS/Message/RecordTest.php b/tests/unit/DNS/Message/RecordTest.php index f5ed386..f74bbe1 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -325,6 +325,141 @@ class: Record::CLASS_IN, $this->assertStringContainsString("\x0Afirst.last\x07example\x03com\x00", $encoded); } + public function testEncodeSoaRecordPreservesPreEscapedRnameWithoutAt(): 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 testEncodeSoaRecordWireFormatRoundTripsEmailRname(): 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(); + + $offset = 0; + $decoded = Record::decode($encoded, $offset); + + // After wire decode the dotted RNAME is no longer distinguishable from + // an "ordinary" three-label name, so the local part is parsed as a + // separate label. This pins the current encode/decode asymmetry + // documented on Domain::decode(). + $this->assertSame('example.com', $decoded->name); + $this->assertSame(Record::TYPE_SOA, $decoded->type); + $this->assertStringContainsString('hostmaster.example.com', $decoded->rdata); + } + + public function testEncodeSoaRecordRejectsRnameWithMultipleAt(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'ns1.example.com foo@bar@example.com 2024102701 7200 3600 1209600 86400' + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one @ separator'); + + $record->encode(); + } + + public function testEncodeSoaRecordRejectsRnameWithEmptyLocalPart(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'ns1.example.com @example.com 2024102701 7200 3600 1209600 86400' + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('non-empty local part and domain'); + + $record->encode(); + } + + public function testEncodeSoaRecordRejectsRnameWithEmptyDomain(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'ns1.example.com hostmaster@ 2024102701 7200 3600 1209600 86400' + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('non-empty local part and domain'); + + $record->encode(); + } + + public function testEncodeSoaRecordRejectsRnameWithTrailingBackslashInLocalPart(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'ns1.example.com user\\@example.com 2024102701 7200 3600 1209600 86400' + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('dangling backslash'); + + $record->encode(); + } + + public function testEncodeSoaRecordAcceptsSingleLabelRname(): void + { + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'ns1.example.com hostmaster@localhost 2024102701 7200 3600 1209600 86400' + ); + + $encoded = $record->encode(); + + $this->assertStringContainsString("\x0Ahostmaster\x09localhost\x00", $encoded); + } + + public function testEncodeSoaRecordRejectsRnameWithLocalPartOverLabelLimit(): void + { + $localPart = str_repeat('a', 64); + $record = new Record( + name: 'example.com', + type: Record::TYPE_SOA, + class: Record::CLASS_IN, + ttl: 3600, + rdata: "ns1.example.com {$localPart}@example.com 2024102701 7200 3600 1209600 86400" + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Label too long'); + + $record->encode(); + } + public function testDecodeTxtRecordWithMultipleChunks(): void { // TXT with two chunks: "hello" (5 bytes) + "world" (5 bytes) From 06e519527a442d06d9d11af52ff03f49010286f8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 19 May 2026 19:50:15 +0530 Subject: [PATCH 3/3] Narrow SOA RNAME email encoding fix --- src/DNS/Message/Domain.php | 87 +---------------- src/DNS/Message/Record.php | 47 ++------- tests/unit/DNS/Message/DomainTest.php | 46 --------- tests/unit/DNS/Message/RecordTest.php | 135 -------------------------- 4 files changed, 15 insertions(+), 300 deletions(-) diff --git a/src/DNS/Message/Domain.php b/src/DNS/Message/Domain.php index a2f5b46..1e89fae 100644 --- a/src/DNS/Message/Domain.php +++ b/src/DNS/Message/Domain.php @@ -13,11 +13,6 @@ /** * Encode a domain name according to RFC 1035. * - * Recognises two escape sequences inside labels: `\.` for a literal dot - * and `\\` for a literal backslash. Any other `\X` sequence is rejected. - * A single unescaped trailing dot is treated as the FQDN terminator and - * does not produce an empty trailing label. - * * @param string $name * @return string */ @@ -27,22 +22,16 @@ public static function encode(string $name): string return "\x00"; } - $labels = self::splitLabels($name); - - // FQDN terminator: a trailing unescaped dot ("example.com.") leaves an - // empty trailing label; drop it. Consecutive trailing dots - // ("example..") leave an empty label *before* the terminator, which is - // still caught by the empty-label check below. - if (count($labels) > 1 && end($labels) === '') { - array_pop($labels); + if (str_ends_with($name, '..')) { + throw new \InvalidArgumentException('Domain labels must not be empty'); } - // Root domain shorthand: "." (and similar all-dot inputs that collapse - // to a single empty label after FQDN trim) encode as the zero byte. - if (count($labels) === 1 && $labels[0] === '') { + $trimmed = rtrim($name, '.'); + if ($trimmed === '') { return "\x00"; } + $labels = explode('.', $trimmed); $labelCount = count($labels); if ($labelCount > self::MAX_LABELS) { @@ -82,65 +71,6 @@ public static function encode(string $name): string return $encoded . "\x00"; } - /** - * Split a presentation-format domain name into labels. - * - * Two escape sequences are recognised inside labels: `\.` for a literal - * dot and `\\` for a literal backslash. These match the subset of RFC 1035 - * escapes needed by SOA RNAME mailbox local parts such as - * "first\.last.example.com". The RFC 1035 `\DDD` decimal escape is *not* - * supported. Any other `\X` sequence is rejected so the behaviour change - * is loud rather than silent for callers that previously passed raw - * backslashes through Domain::encode(). - * - * @return list - */ - 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) { - if ($char !== '.' && $char !== '\\') { - throw new \InvalidArgumentException( - 'Invalid escape sequence in domain name: \\' . $char - ); - } - $label .= $char; - $escaped = false; - continue; - } - - if ($char === '\\') { - $escaped = true; - continue; - } - - if ($char === '.') { - $labels[] = $label; - $label = ''; - continue; - } - - $label .= $char; - } - - if ($escaped) { - throw new \InvalidArgumentException( - 'Domain name has a dangling trailing backslash' - ); - } - - $labels[] = $label; - - return $labels; - } - /** * Decode a domain name from DNS wire format, handling compression pointers. * @@ -148,13 +78,6 @@ private static function splitLabels(string $name): array * reference earlier occurrences in the packet. This implementation tracks * visited pointer positions to prevent infinite loops from malicious packets. * - * Asymmetry with encode(): label bytes are joined with literal `.` - * without re-escaping. A wire-format SOA RNAME with a dotted local part - * (e.g. labels ["first.last", "example", "com"]) decodes to - * "first.last.example.com", which then re-encodes as four labels rather - * than three. Callers that need to round-trip dotted local parts must - * track the original label boundaries themselves. - * * @param string $data Full DNS packet * @param int $offset Current read offset (updated to first byte after the name) * @return string Decoded domain name in dotted form diff --git a/src/DNS/Message/Record.php b/src/DNS/Message/Record.php index 26c3ea7..fb7eb95 100644 --- a/src/DNS/Message/Record.php +++ b/src/DNS/Message/Record.php @@ -514,7 +514,6 @@ 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) { @@ -532,14 +531,14 @@ 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 normalizeSoaRname(string $rname): string + private static function encodeSoaRname(string $rname): string { if (!str_contains($rname, '@')) { - return $rname; + return Domain::encode($rname); } if (substr_count($rname, '@') > 1) { @@ -556,45 +555,19 @@ private static function normalizeSoaRname(string $rname): string ); } - 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; + $localLength = strlen($localPart); + if ($localLength > Domain::MAX_LABEL_LEN) { + throw new \InvalidArgumentException("Label too long: $localPart"); } - if ($isEscaped) { + $encoded = chr($localLength) . $localPart . Domain::encode($domain); + if (strlen($encoded) > Domain::MAX_DOMAIN_NAME_LEN) { throw new \InvalidArgumentException( - 'SOA RNAME local part cannot end with a dangling backslash' + "Encoded domain exceeds maximum length of " . Domain::MAX_DOMAIN_NAME_LEN . ' bytes' ); } - return $escaped; + return $encoded; } private function encodeCaaRdata(): string diff --git a/tests/unit/DNS/Message/DomainTest.php b/tests/unit/DNS/Message/DomainTest.php index 51971d4..a3acbf0 100644 --- a/tests/unit/DNS/Message/DomainTest.php +++ b/tests/unit/DNS/Message/DomainTest.php @@ -24,52 +24,6 @@ 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 testEncodePreservesEscapedDotImmediatelyBeforeFqdnTerminator(): void - { - // "foo\.." = label "foo." (escaped dot) followed by FQDN root terminator. - $encoded = Domain::encode('foo\..'); - - $this->assertSame("\x04foo.\x00", $encoded); - } - - public function testEncodeTreatsEscapedDotAsLiteralEvenWithoutTrailingTerminator(): void - { - // "foo\." = single label "foo." with no FQDN terminator. - $encoded = Domain::encode('foo\.'); - - $this->assertSame("\x04foo.\x00", $encoded); - } - - public function testEncodeTreatsEscapedBackslashAsLiteralBackslash(): void - { - $encoded = Domain::encode('foo\\\\.com'); - - $this->assertSame("\x04foo\\\x03com\x00", $encoded); - } - - public function testEncodeRejectsDanglingTrailingBackslash(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('dangling trailing backslash'); - - Domain::encode('foo\\'); - } - - public function testEncodeRejectsUnknownEscapeSequence(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid escape sequence'); - - Domain::encode('foo\xbar'); - } - public function testEncodeAllowsRootViaEmptyString(): void { $this->assertSame("\x00", Domain::encode('')); diff --git a/tests/unit/DNS/Message/RecordTest.php b/tests/unit/DNS/Message/RecordTest.php index f74bbe1..f5ed386 100644 --- a/tests/unit/DNS/Message/RecordTest.php +++ b/tests/unit/DNS/Message/RecordTest.php @@ -325,141 +325,6 @@ class: Record::CLASS_IN, $this->assertStringContainsString("\x0Afirst.last\x07example\x03com\x00", $encoded); } - public function testEncodeSoaRecordPreservesPreEscapedRnameWithoutAt(): 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 testEncodeSoaRecordWireFormatRoundTripsEmailRname(): 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(); - - $offset = 0; - $decoded = Record::decode($encoded, $offset); - - // After wire decode the dotted RNAME is no longer distinguishable from - // an "ordinary" three-label name, so the local part is parsed as a - // separate label. This pins the current encode/decode asymmetry - // documented on Domain::decode(). - $this->assertSame('example.com', $decoded->name); - $this->assertSame(Record::TYPE_SOA, $decoded->type); - $this->assertStringContainsString('hostmaster.example.com', $decoded->rdata); - } - - public function testEncodeSoaRecordRejectsRnameWithMultipleAt(): void - { - $record = new Record( - name: 'example.com', - type: Record::TYPE_SOA, - class: Record::CLASS_IN, - ttl: 3600, - rdata: 'ns1.example.com foo@bar@example.com 2024102701 7200 3600 1209600 86400' - ); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('exactly one @ separator'); - - $record->encode(); - } - - public function testEncodeSoaRecordRejectsRnameWithEmptyLocalPart(): void - { - $record = new Record( - name: 'example.com', - type: Record::TYPE_SOA, - class: Record::CLASS_IN, - ttl: 3600, - rdata: 'ns1.example.com @example.com 2024102701 7200 3600 1209600 86400' - ); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('non-empty local part and domain'); - - $record->encode(); - } - - public function testEncodeSoaRecordRejectsRnameWithEmptyDomain(): void - { - $record = new Record( - name: 'example.com', - type: Record::TYPE_SOA, - class: Record::CLASS_IN, - ttl: 3600, - rdata: 'ns1.example.com hostmaster@ 2024102701 7200 3600 1209600 86400' - ); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('non-empty local part and domain'); - - $record->encode(); - } - - public function testEncodeSoaRecordRejectsRnameWithTrailingBackslashInLocalPart(): void - { - $record = new Record( - name: 'example.com', - type: Record::TYPE_SOA, - class: Record::CLASS_IN, - ttl: 3600, - rdata: 'ns1.example.com user\\@example.com 2024102701 7200 3600 1209600 86400' - ); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('dangling backslash'); - - $record->encode(); - } - - public function testEncodeSoaRecordAcceptsSingleLabelRname(): void - { - $record = new Record( - name: 'example.com', - type: Record::TYPE_SOA, - class: Record::CLASS_IN, - ttl: 3600, - rdata: 'ns1.example.com hostmaster@localhost 2024102701 7200 3600 1209600 86400' - ); - - $encoded = $record->encode(); - - $this->assertStringContainsString("\x0Ahostmaster\x09localhost\x00", $encoded); - } - - public function testEncodeSoaRecordRejectsRnameWithLocalPartOverLabelLimit(): void - { - $localPart = str_repeat('a', 64); - $record = new Record( - name: 'example.com', - type: Record::TYPE_SOA, - class: Record::CLASS_IN, - ttl: 3600, - rdata: "ns1.example.com {$localPart}@example.com 2024102701 7200 3600 1209600 86400" - ); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Label too long'); - - $record->encode(); - } - public function testDecodeTxtRecordWithMultipleChunks(): void { // TXT with two chunks: "hello" (5 bytes) + "world" (5 bytes)