From 3ee63a95f66784442c3c5fc59cc4f765c13e6bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 8 Sep 2026 17:24:10 +0200 Subject: [PATCH] test(dns): pin zero through the wire format and the zone file `priority`, `weight` and `port` are nullable because most record types do not carry them, and every place that reads them uses `?? 0` rather than `?: 0` -- correctly, since a zero the caller set is not a zero the caller omitted. MX priority 0 is the highest priority a mail exchange can be given, and RFC 2782 gives all three SRV fields a meaning at zero. Nothing held that. The MX and SRV cases in `RecordTest` use 10 and 5/10/5060, and the zone fixtures use 10, 20 and 50, so `?? 0` could become `?: ''` and only the zone export broke -- silently, into a line `File::parseRdata` then refuses, which fails the import of the whole zone rather than the one record. Covers encode, decode and the zone export/import round trip for MX priority 0 and SRV 0 0 0. Verified red: rewriting the MX branch of `File::formatRdata` to `$pri = $priority ?: ''` exports `mail 300 IN MX mail` and fails the round trip test. Found while tracing a report that an MX record with priority 0 could not be added to Appwrite Cloud. The defect was in the console, and this library already handled zero correctly at every layer -- but nothing proved it. Co-Authored-By: Claude Opus 5 (1M context) --- .../dns/tests/unit/DNS/Message/RecordTest.php | 94 +++++++++++++++++++ packages/dns/tests/unit/DNS/Zone/FileTest.php | 47 ++++++++++ 2 files changed, 141 insertions(+) diff --git a/packages/dns/tests/unit/DNS/Message/RecordTest.php b/packages/dns/tests/unit/DNS/Message/RecordTest.php index 8309ed6bc..a847c3c27 100644 --- a/packages/dns/tests/unit/DNS/Message/RecordTest.php +++ b/packages/dns/tests/unit/DNS/Message/RecordTest.php @@ -150,6 +150,100 @@ public function testDecodeSrvRecordParsesFields(): void $this->assertSame(\strlen($data), $offset); } + public function testEncodeMxRecordWithZeroPriorityMatchesBytes(): void + { + // Zero is the highest priority a mail exchange can be given, and it is a value the sender + // chose rather than one it omitted. It has to reach the wire as two zero octets. + $record = new Record( + name: 'mail.example.com', + type: Record::TYPE_MX, + class: Record::CLASS_IN, + ttl: 3600, + rdata: 'mail.exchange.example.com', + priority: 0, + ); + + // Raw RR: mail.example.com. 3600 IN MX 0 mail.exchange.example.com. + $expected = "\x04mail\x07example\x03com\x00" + . "\x00\x0F" + . "\x00\x01" + . "\x00\x00\x0E\x10" + . "\x00\x1D" + . "\x00\x00" + . "\x04mail\x08exchange\x07example\x03com\x00"; + + $this->assertSame($expected, $record->encode()); + } + + public function testDecodeMxRecordWithZeroPriorityParsesFields(): void + { + // Raw RR: mail.example.com. 3600 IN MX 0 mail.exchange.example.com. + $data = "\x04mail\x07example\x03com\x00" + . "\x00\x0F" + . "\x00\x01" + . "\x00\x00\x0E\x10" + . "\x00\x1D" + . "\x00\x00" + . "\x04mail\x08exchange\x07example\x03com\x00"; + + $offset = 0; + $record = Record::decode($data, $offset); + + // A zero read off the wire stays a zero, rather than becoming the null that means "this + // record type carries no priority". + $this->assertSame(0, $record->priority); + $this->assertSame('mail.exchange.example.com', $record->rdata); + $this->assertSame(\strlen($data), $offset); + } + + public function testEncodeSrvRecordWithZeroNumericsMatchesBytes(): void + { + // RFC 2782 gives all three fields a meaning at zero: highest priority, no share of the + // weighted draw, and the port that marks the service unavailable on this target. + $record = new Record( + name: '_sip._tcp.example.com', + type: Record::TYPE_SRV, + class: Record::CLASS_IN, + ttl: 7200, + rdata: 'sip.example.com', + priority: 0, + weight: 0, + port: 0, + ); + + // Raw RR: _sip._tcp.example.com. 7200 IN SRV 0 0 0 sip.example.com. + $expected = "\x04_sip\x04_tcp\x07example\x03com\x00" + . "\x00\x21" + . "\x00\x01" + . "\x00\x00\x1C\x20" + . "\x00\x17" + . "\x00\x00\x00\x00\x00\x00" + . "\x03sip\x07example\x03com\x00"; + + $this->assertSame($expected, $record->encode()); + } + + public function testDecodeSrvRecordWithZeroNumericsParsesFields(): void + { + // Raw RR: _sip._tcp.example.com. 7200 IN SRV 0 0 0 sip.example.com. + $data = "\x04_sip\x04_tcp\x07example\x03com\x00" + . "\x00\x21" + . "\x00\x01" + . "\x00\x00\x1C\x20" + . "\x00\x17" + . "\x00\x00\x00\x00\x00\x00" + . "\x03sip\x07example\x03com\x00"; + + $offset = 0; + $record = Record::decode($data, $offset); + + $this->assertSame(0, $record->priority); + $this->assertSame(0, $record->weight); + $this->assertSame(0, $record->port); + $this->assertSame('sip.example.com', $record->rdata); + $this->assertSame(\strlen($data), $offset); + } + public function testEncodeTxtRecordMatchesBytes(): void { $record = new Record( diff --git a/packages/dns/tests/unit/DNS/Zone/FileTest.php b/packages/dns/tests/unit/DNS/Zone/FileTest.php index ad08f8fd8..3001cbfcf 100644 --- a/packages/dns/tests/unit/DNS/Zone/FileTest.php +++ b/packages/dns/tests/unit/DNS/Zone/FileTest.php @@ -411,6 +411,53 @@ public function testExportBasicZone(): void $this->assertSame('mail.example.com', $roundTrip->records[2]->name); } + public function testExportAndImportKeepZeroMxPriority(): void + { + // The zone file is the only thing a resolver reads. A priority of 0 written as an empty + // field does not just lose the priority: the line stops parsing, and an import failure + // takes every other record in the zone with it. + $zone = new Zone( + 'example.com', + [ + new Record('mail.example.com', Record::TYPE_MX, Record::CLASS_IN, 300, 'mail.example.com', priority: 0), + ], + new Record('example.com', Record::TYPE_SOA, Record::CLASS_IN, 1800, 'ns1.example.com. admin.example.com. 2025011801 7200 3600 1209600 1800'), + ); + + $exported = File::export($zone, includeComments: false); + $this->assertStringContainsString("mail\t300\tIN\tMX\t0 mail\n", $exported); + + $roundTrip = File::import($exported); + $record = $this->findRecord($roundTrip->records, Record::TYPE_MX); + + $this->assertInstanceOf(Record::class, $record); + $this->assertSame(0, $record->priority); + $this->assertSame('mail.example.com', $record->rdata); + } + + public function testExportAndImportKeepZeroSrvNumerics(): void + { + $zone = new Zone( + 'example.com', + [ + new Record('_sip._tcp.example.com', Record::TYPE_SRV, Record::CLASS_IN, 300, 'sip.example.com', priority: 0, weight: 0, port: 0), + ], + new Record('example.com', Record::TYPE_SOA, Record::CLASS_IN, 1800, 'ns1.example.com. admin.example.com. 2025011801 7200 3600 1209600 1800'), + ); + + $exported = File::export($zone, includeComments: false); + $this->assertStringContainsString("_sip._tcp\t300\tIN\tSRV\t0 0 0 sip\n", $exported); + + $roundTrip = File::import($exported); + $record = $this->findRecord($roundTrip->records, Record::TYPE_SRV); + + $this->assertInstanceOf(Record::class, $record); + $this->assertSame(0, $record->priority); + $this->assertSame(0, $record->weight); + $this->assertSame(0, $record->port); + $this->assertSame('sip.example.com', $record->rdata); + } + public function testImportSupportsPtrRecords(): void { $soa = self::DEFAULT_SOA;