From f3617661f15b470743713a9e06be5519d3ceaade Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:25:41 +0000 Subject: [PATCH] feat: import detect zone name --- src/DNS/Zone/File.php | 51 +++++++++++----- tests/unit/DNS/Zone/FileTest.php | 101 +++++++++++++++++-------------- 2 files changed, 93 insertions(+), 59 deletions(-) diff --git a/src/DNS/Zone/File.php b/src/DNS/Zone/File.php index 3ebc959..f5f7237 100644 --- a/src/DNS/Zone/File.php +++ b/src/DNS/Zone/File.php @@ -27,25 +27,31 @@ /** * Import a zone from RFC 1035 master file format string. * - * @param string $name Zone name * @param string $content Zone file content - * @param string|null $defaultOrigin Default origin if not specified in file + * @param string|null $defaultOrigin Default origin if $ORIGIN is not specified * @param int $defaultTTL Default TTL if not specified (default: 3600) * * @throws InvalidArgumentException */ - public static function import(string $name, string $content, ?string $defaultOrigin = null, int $defaultTTL = 3600): Zone + public static function import(string $content, ?string $defaultOrigin = null, int $defaultTTL = 3600): Zone { - $zoneName = self::canonicalizeName($name); - if ($zoneName === null) { - throw new InvalidArgumentException('Zone name must not be empty'); - } - $normalizedLines = self::preprocess($content); // array $records = []; $soa = null; - $origin = $defaultOrigin !== null ? self::canonicalizeName($defaultOrigin) : $zoneName; + $origin = null; + $zoneName = null; + $zoneNameFromDefault = false; + + if ($defaultOrigin !== null) { + $origin = self::canonicalizeName($defaultOrigin); + if ($origin === null) { + throw new InvalidArgumentException('Default origin must not be empty'); + } + $zoneName = $origin; + $zoneNameFromDefault = true; + } + $lastOwner = null; $lastTTL = $defaultTTL; $lastClass = Record::CLASS_IN; @@ -56,7 +62,17 @@ public static function import(string $name, string $content, ?string $defaultOri } // Directives - if (self::handleDirectives($line, $origin, $lastTTL)) { + $directive = self::handleDirectives($line, $origin, $lastTTL); + if ($directive !== null) { + if ($directive === 'origin') { + if ($origin === null) { + throw new InvalidArgumentException('$ORIGIN directive must not be empty'); + } + if ($zoneName === null || $zoneNameFromDefault) { + $zoneName = $origin; + $zoneNameFromDefault = false; + } + } continue; } @@ -101,6 +117,10 @@ class: $rr['class'], throw new InvalidArgumentException('No SOA record found in zone file'); } + if ($zoneName === null) { + throw new InvalidArgumentException('Unable to determine zone name: provide an $ORIGIN directive or defaultOrigin.'); + } + return new Zone($zoneName, $records, $soa); } @@ -240,28 +260,29 @@ private static function preprocess(string $content): array } /** - * Handle $ORIGIN / $TTL directives. Returns true if the line was a directive. + * Handle $ORIGIN / $TTL directives. * * @param-out string|null $origin * @param-out int $lastTTL + * @return 'origin'|'ttl'|null */ - private static function handleDirectives(string $line, ?string &$origin, int &$lastTTL): bool + private static function handleDirectives(string $line, ?string &$origin, int &$lastTTL): ?string { if (preg_match('/^\s*\$ORIGIN\s+(\S+)\s*$/i', $line, $m) === 1) { $origin = self::canonicalizeName($m[1]); - return true; + return 'origin'; } if (preg_match('/^\s*\$TTL\s+(\d+)\s*$/i', $line, $m) === 1) { $lastTTL = (int) $m[1]; - return true; + return 'ttl'; } if (preg_match('/^\s*\$INCLUDE\b/i', $line) === 1) { throw new InvalidArgumentException('$INCLUDE directive is not supported'); } - return false; + return null; } /** diff --git a/tests/unit/DNS/Zone/FileTest.php b/tests/unit/DNS/Zone/FileTest.php index e272cd7..7a01e9b 100644 --- a/tests/unit/DNS/Zone/FileTest.php +++ b/tests/unit/DNS/Zone/FileTest.php @@ -16,7 +16,7 @@ public function testExampleComZoneFile(): void { $content = (string) file_get_contents(__DIR__ . '/../../../resources/zone-valid-example.com.txt'); - $zone = File::import('example.com', $content); + $zone = File::import($content); $this->assertSame('example.com', $zone->name); $this->assertNotEmpty($zone->records); @@ -26,7 +26,7 @@ public function testRedHatZoneFile(): void { $content = (string) file_get_contents(__DIR__ . '/../../../resources/zone-valid-redhat.txt'); - $zone = File::import('example.com', $content); + $zone = File::import($content); $this->assertSame('example.com', $zone->name); $this->assertNotEmpty($zone->records); @@ -36,7 +36,7 @@ public function testOracle1ZoneFile(): void { $content = (string) file_get_contents(__DIR__ . '/../../../resources/zone-valid-oracle1.txt'); - $zone = File::import('example.com', $content); + $zone = File::import($content, 'example.com'); $this->assertSame('example.com', $zone->name); $this->assertNotEmpty($zone->records); @@ -46,7 +46,7 @@ public function testOracle2ZoneFile(): void { $content = (string) file_get_contents(__DIR__ . '/../../../resources/zone-valid-oracle2.txt'); - $zone = File::import('example.com', $content); + $zone = File::import($content); $this->assertSame('example.com', $zone->name); $this->assertNotEmpty($zone->records); @@ -56,7 +56,7 @@ public function testLocalhostZoneFile(): void { $content = (string) file_get_contents(__DIR__ . '/../../../resources/zone-valid-localhost.txt'); - $zone = File::import('localhost', $content); + $zone = File::import($content); $this->assertSame('localhost', $zone->name); $this->assertNotEmpty($zone->records); @@ -75,7 +75,7 @@ public function testImportValidZoneWithDirectives(): void _sip._tcp 600 IN SRV 5 10 5060 sip ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertSame(1800, $zone->soa->ttl); $this->assertCount(3, $zone->records); @@ -106,7 +106,7 @@ public function testImportFailsWithUnsupportedDirective(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('$INCLUDE directive is not supported'); - File::import('example.com', <<assertCount(1, $zone->records); $this->assertSame('example.com', $zone->records[0]->name); @@ -184,17 +184,30 @@ public function testImportAllowsZeroTtl(): void @ 0 IN A 127.0.0.1 ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertSame(0, $zone->records[0]->ttl); } + public function testImportUsesDefaultOriginWhenDirectiveMissing(): void + { + $contents = <<<'ZONE' +@ IN SOA ns1.example.com. admin.example.com. 2025011801 7200 3600 1209600 1800 +www 600 IN A 192.0.2.10 +ZONE; + + $zone = File::import($contents, 'example.com'); + + $this->assertSame('example.com', $zone->name); + $this->assertSame('www.example.com', $zone->records[0]->name); + } + public function testImportFailsWhenSoaDataMissing(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('SOA requires MNAME, RNAME, SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM'); - File::import('example.com', '@ IN SOA ns1.example.com. admin.example.com.'); + File::import('@ IN SOA ns1.example.com. admin.example.com.', 'example.com'); } public function testImportWithRelativeNamesExpandsToZone(): void @@ -208,7 +221,7 @@ public function testImportWithRelativeNamesExpandsToZone(): void alias IN CNAME www ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertSame('example.com', $zone->soa->name); @@ -230,7 +243,7 @@ public function testImportHandlesClassBeforeTtl(): void @ IN 3600 A 192.0.2.10 ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $zone->records[0]; $this->assertSame(Record::CLASS_IN, $record->class); @@ -246,7 +259,7 @@ public function testImportDefaultsClassToIn(): void @ 600 A 192.0.2.11 ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $zone->records[0]; $this->assertSame(Record::CLASS_IN, $record->class); @@ -264,7 +277,7 @@ public function testImportCollapsesParenthesizedTxtRecords(): void ) ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $txt = $this->findRecord($zone->records, Record::TYPE_TXT); $this->assertNotNull($txt); $this->assertSame('foobar', $txt->rdata); @@ -279,7 +292,7 @@ public function testImportDecodesDecimalEscapesInTxt(): void escaped 600 IN TXT "foo\\010bar" ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $txt = $this->findRecord($zone->records, Record::TYPE_TXT); $this->assertNotNull($txt); $this->assertSame("foo" . chr(10) . "bar", $txt->rdata); @@ -295,7 +308,7 @@ public function testImportIgnoresUnknownDirective(): void www IN A 192.168.1.10 ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertCount(1, $zone->records); $this->assertSame('www.example.com', $zone->records[0]->name); @@ -310,7 +323,7 @@ public function testImportTxtWithSpecialChars(): void @ 3600 IN TXT "v=DMARC1; p=none; rua=mailto:jon@snow.got; ruf=mailto:jon@snow.got; fo=1;" ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $this->findRecord($zone->records, Record::TYPE_TXT); $this->assertNotNull($record); $this->assertSame('v=DMARC1; p=none; rua=mailto:jon@snow.got; ruf=mailto:jon@snow.got; fo=1;', $record->rdata); @@ -331,7 +344,7 @@ public function testExportTxtWithSpecialChars(): void $exported = File::export($zone, includeComments: false); $this->assertSame($expected, $exported); - $roundTrip = File::import('example.com', $exported); + $roundTrip = File::import($exported); $roundTripTxt = $this->findRecord($roundTrip->records, Record::TYPE_TXT); $this->assertNotNull($roundTripTxt); @@ -348,11 +361,11 @@ public function testImportExportRoundTrip(): void mail 600 IN MX 10 mail ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertCount(2, $zone->records); $exported = File::export($zone, includeComments: false); - $roundTrip = File::import('example.com', $exported); + $roundTrip = File::import($exported); $this->assertSame($zone->name, $roundTrip->name); $this->assertSame($zone->soa->rdata, $roundTrip->soa->rdata); @@ -377,7 +390,7 @@ public function testExportBasicZone(): void $output = File::export($zone, includeComments: false); $this->assertSame($expected, $output); - $roundTrip = File::import('example.com', $output); + $roundTrip = File::import($output); $this->assertCount(3, $roundTrip->records); $this->assertSame('mail.example.com', $roundTrip->records[2]->name); } @@ -391,7 +404,7 @@ public function testImportSupportsPtrRecords(): void 1 3600 IN PTR host.example.com. ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $ptr = $this->findRecord($zone->records, Record::TYPE_PTR); $this->assertNotNull($ptr); $this->assertSame('1.example.com', $ptr->name); @@ -414,7 +427,7 @@ public function testImportSupportsMultilineSoa(): void www 1800 IN A 192.0.2.10 ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertSame('ns1.example.com admin.example.com 2025011801 7200 3600 1209600 1800', $zone->soa->rdata); $this->assertSame('www.example.com', $zone->records[0]->name); @@ -433,7 +446,7 @@ public function testImportHandlesMultipleOrigins(): void api IN CNAME www ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertSame('www.example.com', $zone->records[0]->name); $this->assertSame('sub.example.com', $zone->records[1]->name); @@ -451,7 +464,7 @@ public function testImportAllowsOwnerOmissionWithPreviousOwner(): void IN AAAA 2001:db8::1 ZONE; - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertSame('www.example.com', $zone->records[0]->name); $this->assertSame('www.example.com', $zone->records[1]->name); @@ -469,7 +482,7 @@ public function testImportTxtWithEscapedSemicolon(): void self::DEFAULT_SOA ); - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $this->findRecord($zone->records, Record::TYPE_TXT); $this->assertNotNull($record); $this->assertSame('foo;bar', $record->rdata); @@ -486,7 +499,7 @@ public function testImportTxtWithSemicolonInQuotes(): void self::DEFAULT_SOA ); - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $this->findRecord($zone->records, Record::TYPE_TXT); $this->assertNotNull($record); $this->assertSame('not a comment; still text', $record->rdata); @@ -503,11 +516,11 @@ public function testImportExportRoundTripForAaaa(): void self::DEFAULT_SOA ); - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $this->assertSame(Record::TYPE_AAAA, $zone->records[0]->type); $exported = File::export($zone, includeComments: false); - $roundTrip = File::import('example.com', $exported); + $roundTrip = File::import($exported); $this->assertSame($zone->records[0]->rdata, $roundTrip->records[0]->rdata); } @@ -523,13 +536,13 @@ public function testImportExportRoundTripForCaa(): void self::DEFAULT_SOA ); - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $this->findRecord($zone->records, Record::TYPE_CAA); $this->assertNotNull($record); $this->assertSame('0 issue "letsencrypt.org"', $record->rdata); $exported = File::export($zone, includeComments: false); - $roundTrip = File::import('example.com', $exported); + $roundTrip = File::import($exported); $roundTripCaa = $this->findRecord($roundTrip->records, Record::TYPE_CAA); $this->assertNotNull($roundTripCaa); @@ -550,7 +563,7 @@ public function testImportCaaMissingQuotedValueFails(): void self::DEFAULT_SOA ); - File::import('example.com', $contents); + File::import($contents); } public function testImportPtrWithReverseOrigin(): void @@ -561,7 +574,7 @@ public function testImportPtrWithReverseOrigin(): void 1 3600 IN PTR host.example.com. ZONE; - $zone = File::import('2.0.192.in-addr.arpa.', $contents); + $zone = File::import($contents); $ptr = $this->findRecord($zone->records, Record::TYPE_PTR); $this->assertNotNull($ptr); $this->assertSame('1.2.0.192.in-addr.arpa', $ptr->name); @@ -578,7 +591,7 @@ public function testImportFailsWithDuplicateSoa(): void @ IN SOA ns2.example.com. admin.example.com. 2025011801 7200 3600 1209600 1800 ZONE; - File::import('example.com', $contents); + File::import($contents); } public function testImportRejectsTtlWithSuffix(): void @@ -595,7 +608,7 @@ public function testImportRejectsTtlWithSuffix(): void self::DEFAULT_SOA ); - File::import('example.com', $contents); + File::import($contents); } public function testImportSupportsAlternativeClasses(): void @@ -609,7 +622,7 @@ public function testImportSupportsAlternativeClasses(): void self::DEFAULT_SOA ); - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $this->findRecord($zone->records, Record::TYPE_A); $this->assertNotNull($record); $this->assertSame(Record::CLASS_CS, $record->class); @@ -626,7 +639,7 @@ public function testImportTxtWithEmbeddedQuoteAndBackslash(): void self::DEFAULT_SOA ); - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $this->findRecord($zone->records, Record::TYPE_TXT); $this->assertNotNull($record); $this->assertSame('a "quote" and a \\ backslash', $record->rdata); @@ -643,7 +656,7 @@ public function testImportTxtThreeDigitEscapeConsumesOnlyThreeDigits(): void self::DEFAULT_SOA ); - $zone = File::import('example.com', $contents); + $zone = File::import($contents); $record = $this->findRecord($zone->records, Record::TYPE_TXT); $this->assertNotNull($record); $this->assertSame("foo" . chr(10) . "0bar", $record->rdata); @@ -654,7 +667,7 @@ public function testImportFailsWhenSoaHasTooFewFields(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('SOA requires MNAME, RNAME, SERIAL, REFRESH, RETRY, EXPIRE, MINIMUM'); - File::import('example.com', '@ IN SOA ns1.example.com. admin.example.com. 2025011801 7200 3600'); + File::import('@ IN SOA ns1.example.com. admin.example.com. 2025011801 7200 3600', 'example.com'); } public function testImportFailsWithoutSoa(): void @@ -662,7 +675,7 @@ public function testImportFailsWithoutSoa(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('No SOA record found in zone file'); - File::import('example.com', "www IN A 192.168.1.10\n"); + File::import("www IN A 192.168.1.10\n", 'example.com'); } public function testImportFailsWhenOwnerOmittedWithoutContext(): void @@ -670,7 +683,7 @@ public function testImportFailsWhenOwnerOmittedWithoutContext(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Owner omitted but no previous owner available'); - File::import('example.com', <<