Skip to content
5 changes: 3 additions & 2 deletions src/DNS/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,11 @@ private function parseRdata(string $packet, int &$offset, int $type, int $rdleng
while ($offset < $end) {
$len = ord($packet[$offset]);
$offset++;
$txts[] = substr($packet, $offset, $len);
$chunk = ($len > 0) ? substr($packet, $offset, $len) : '';
$txts[] = $chunk;
$offset += $len;
}
return implode(' ', $txts);
return implode('', $txts);
case 33: // SRV record
$priority = unpack('n', substr($packet, $offset, 2));
$weight = unpack('n', substr($packet, $offset + 2, 2));
Expand Down
151 changes: 103 additions & 48 deletions src/DNS/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,100 +330,155 @@ protected function resolve(array $question): array
return $this->resolver->resolve($question);
}

/**
* Encode an IPv4 address (A record) according to RFC 1035.
*
* @param string $ip
* @param int $ttl
* @return string
*/
protected function encodeIP(string $ip, int $ttl): string
{
$result = \pack('Nn', $ttl, 4);

$binaryIP = inet_pton($ip);
if ($binaryIP === false) {
if ($binaryIP === false || strlen($binaryIP) !== 4) {
throw new \Exception("Invalid IPv4 address format: {$ip}");
}

// Append the binary IPv4 address directly
$result .= $binaryIP;

$result = pack('Nn', $ttl, 4) . $binaryIP;
Comment thread
lohanidamodar marked this conversation as resolved.
Outdated
return $result;
}

/**
* Encode an IPv6 address (AAAA record) according to RFC 3596.
*
* @param string $ip
* @param int $ttl
* @return string
*/
protected function encodeIPv6(string $ip, int $ttl): string
{
$result = \pack('Nn', $ttl, 16);

$binaryIP = inet_pton($ip);
if ($binaryIP === false) {
if ($binaryIP === false || strlen($binaryIP) !== 16) {
throw new \Exception("Invalid IPv6 address format: {$ip}");
}

$result .= $binaryIP;

$result = pack('Nn', $ttl, 16) . $binaryIP;
return $result;
}

/**
* Encode a domain name (CNAME, NS, PTR) according to RFC 1035.
*
* @param string $domain
* @param int $ttl
* @return string
*/
protected function encodeDomain(string $domain, int $ttl): string
{
$labels = explode('.', rtrim($domain, '.'));
$result = '';
$totalLength = 0;

foreach (\explode('.', $domain) as $label) {
$labelLength = \strlen($label);
$result .= \chr($labelLength);
$result .= $label;
foreach ($labels as $label) {
$labelLength = strlen($label);
if ($labelLength === 0) {
throw new \Exception("Empty label in domain: '{$domain}'");
}
if ($labelLength > 63) {
throw new \Exception("Label too long in domain: {$label}");
}
$result .= chr($labelLength) . $label;
$totalLength += 1 + $labelLength;
}

$result .= \chr(0);
$result .= chr(0);
$totalLength += 1;

$result = \pack('Nn', $ttl, $totalLength) . $result;

$result = pack('Nn', $ttl, $totalLength) . $result;
return $result;
Comment thread
lohanidamodar marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Encode a TXT record according to RFC 1035.
*
* @param string $text
* @param int $ttl
* @return string
*/
protected function encodeText(string $text, int $ttl): string
{
$textLength = \strlen($text);
$result = \pack('Nn', $ttl, 1 + $textLength) . \chr($textLength) . $text;

$chunks = [];
$len = strlen($text);
for ($i = 0; $i < $len; $i += 255) {
$chunk = substr($text, $i, 255);
$chunks[] = chr(strlen($chunk)) . $chunk;
}
$txtData = implode('', $chunks);
$result = pack('Nn', $ttl, strlen($txtData)) . $txtData;
return $result;
}

/**
* Encode an MX record according to RFC 1035.
*
* @param string $domain
* @param int $ttl
* @param int $priority
* @return string
*/
protected function encodeMx(string $domain, int $ttl, int $priority): string
{
$result = \pack('n', $priority);
$labels = explode('.', rtrim($domain, '.'));
$result = pack('n', $priority);
$totalLength = 2;

foreach (\explode('.', $domain) as $label) {
$labelLength = \strlen($label);
$result .= \chr($labelLength);
$result .= $label;
foreach ($labels as $label) {
$labelLength = strlen($label);
if ($labelLength > 63) {
throw new \Exception("Label too long in MX domain: {$label}");
}
$result .= chr($labelLength) . $label;
$totalLength += 1 + $labelLength;
}

$result .= \chr(0);
$result .= chr(0);
$totalLength += 1;

$result = \pack('Nn', $ttl, $totalLength) . $result;

$result = pack('Nn', $ttl, $totalLength) . $result;
return $result;
Comment thread
lohanidamodar marked this conversation as resolved.
}
Comment thread
lohanidamodar marked this conversation as resolved.

/**
* Encode an SRV record according to RFC 2782.
*
* @param string $domain
* @param int $ttl
* @param int $priority
* @param int $weight
* @param int $port
* @return string
*/
protected function encodeSrv(string $domain, int $ttl, int $priority, int $weight, int $port): string
{
$result = \pack('nnn', $priority, $weight, $port);
// Validate SRV parameters
if ($priority < 0 || $priority > 65535) {
throw new \Exception("SRV priority out of range: {$priority}");
}
if ($weight < 0 || $weight > 65535) {
throw new \Exception("SRV weight out of range: {$weight}");
}
if ($port < 0 || $port > 65535) {
throw new \Exception("SRV port out of range: {$port}");
}
$labels = explode('.', rtrim($domain, '.'));
$result = pack('nnn', $priority, $weight, $port);
$totalLength = 6;

foreach (\explode('.', $domain) as $label) {
$labelLength = \strlen($label);
$result .= \chr($labelLength);
$result .= $label;
foreach ($labels as $label) {
$labelLength = strlen($label);
if ($labelLength === 0) {
throw new \Exception("Empty label in SRV domain: '{$domain}'");
}
if ($labelLength > 63) {
throw new \Exception("Label too long in SRV domain: {$label}");
}
$result .= chr($labelLength) . $label;
$totalLength += 1 + $labelLength;
}

$result .= \chr(0);
$result .= chr(0);
$totalLength += 1;

$result = \pack('Nn', $ttl, $totalLength) . $result;

$result = pack('Nn', $ttl, $totalLength) . $result;
return $result;
Comment thread
lohanidamodar marked this conversation as resolved.
}
Comment thread
lohanidamodar marked this conversation as resolved.

Expand Down
112 changes: 93 additions & 19 deletions src/DNS/Zone.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,20 @@ public function import(string $domain, string $content): array
$lastOwner = null;
$logicalLines = $this->collectLogicalLines($content);
foreach ($logicalLines as $rawLine) {
$line = trim($this->stripTrailingComment($rawLine));
// For TXT records, only strip comments at #, not at semicolons
$isTxt = false;
$peekTokens = $this->tokenize($rawLine);
foreach ($peekTokens as $tok) {
if (strtoupper($tok) === 'TXT') {
$isTxt = true;
break;
}
}
if ($isTxt) {
$line = trim($this->stripTrailingComment($rawLine, true));
} else {
$line = trim($this->stripTrailingComment($rawLine, false));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if ($line === '' || $this->isComment($line)) {
continue;
}
Expand Down Expand Up @@ -219,7 +232,14 @@ public function import(string $domain, string $content): array
}
$type = strtoupper($tokens[$pos++]);
$dataParts = array_slice($tokens, $pos);
$data = implode(' ', $dataParts);
if ($type === 'TXT') {
$data = '';
foreach ($dataParts as $part) {
$data .= $part;
}
} else {
$data = implode(' ', $dataParts);
}
if ($owner !== '@' && !str_ends_with($owner, '.')) {
$owner .= '.' . rtrim($this->defaultOrigin, '.');
}
Expand Down Expand Up @@ -265,11 +285,21 @@ public function export(string $domain, array $records): string
$data = "{$r->getPriority()} {$r->getRdata()}";
} elseif ($type === 'SRV' && $r->getPriority() !== null && $r->getWeight() !== null && $r->getPort() !== null) {
$data = "{$r->getPriority()} {$r->getWeight()} {$r->getPort()} {$r->getRdata()}";
} elseif ($type === 'TXT') {
// Encode TXT records with double quotes and escape embedded quotes and backslashes
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $data);
$data = '"' . $escaped . '"';
}

$lines[] = sprintf("%s %d %s %s %s", $owner, $ttl, $class, $type, $data);
}
return implode("\n", $lines) . "\n";
// Remove any empty lines at the end
while (!empty($lines) && trim(end($lines)) === '') {
array_pop($lines);
}
$output = implode("\n", $lines);
// Guarantee only a single newline at the end
return rtrim($output, "\n") . "\n";
}

/**
Expand All @@ -293,7 +323,7 @@ protected function collectLogicalLines(string $content): array
$parenDepth = 0;
foreach ($rawLines as $rawLine) {
// Remove trailing comments from the physical line.
$line = trim($this->stripTrailingComment($rawLine));
$line = trim($this->stripTrailingComment($rawLine, false));
if ($line === '') {
continue;
}
Expand Down Expand Up @@ -336,17 +366,35 @@ protected function collectLogicalLines(string $content): array
/**
* Remove trailing comments (anything after '#' or ';') from a line.
*/
protected function stripTrailingComment(string $line): string

/**
* Remove trailing comments (anything after '#' or ';') from a line.
* If $txtMode is true, only strip at #, not at semicolons.
*/
protected function stripTrailingComment(string $line, bool $txtMode): string
{
$hashPos = strpos($line, '#');
if ($hashPos !== false) {
$line = substr($line, 0, $hashPos);
}
$semiPos = strpos($line, ';');
if ($semiPos !== false) {
$line = substr($line, 0, $semiPos);
$inQuote = false;
$result = '';
for ($i = 0, $len = strlen($line); $i < $len; $i++) {
$c = $line[$i];
// Check for unescaped quote
if ($c === '"') {
$escaped = false;
$j = $i - 1;
while ($j >= 0 && $line[$j] === '\\') {
$escaped = !$escaped;
$j--;
}
if (!$escaped) {
$inQuote = !$inQuote;
}
}
if (!$inQuote && ($c === '#' || (!$txtMode && $c === ';'))) {
break;
}
$result .= $c;
}
return $line;
return $result;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

protected function isComment(string $line): bool
Expand All @@ -360,22 +408,48 @@ protected function isComment(string $line): bool
*
* @return string[]
*/
/**
* Tokenize a line by whitespace, handling quoted strings and escapes per RFC 1035.
*
* @return string[]
*/
protected function tokenize(string $line): array
{
// Manual tokenizer: handles quoted strings and escapes
$tokens = [];
$current = '';
$inSpace = true;
$inQuote = false;
$escape = false;
for ($i = 0, $len = strlen($line); $i < $len; $i++) {
$c = $line[$i];
if ($c === ' ' || $c === "\t") {
if (!$inSpace) {
if ($escape) {
$current .= $c;
$escape = false;
continue;
}
if ($c === '\\') {
$escape = true;
continue;
}
if ($inQuote) {
if ($c === '"') {
$inQuote = false;
$tokens[] = $current;
$current = '';
} else {
$current .= $c;
}
$inSpace = true;
} else {
$current .= $c;
$inSpace = false;
if ($c === '"') {
$inQuote = true;
} elseif ($c === ' ' || $c === "\t") {
if ($current !== '') {
$tokens[] = $current;
$current = '';
}
} else {
$current .= $c;
}
}
}
if ($current !== '') {
Expand Down
3 changes: 2 additions & 1 deletion tests/DNS/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ public function testTXTRecords(): void

$records = $this->client->query('dev2.appwrite.io', 'TXT');

$this->assertCount(2, $records);
$this->assertCount(3, $records);
$this->assertEquals('key with "$\'- symbols', $records[0]->getRdata());
$this->assertEquals('key with spaces', $records[1]->getRdata());
$this->assertEquals('v=DMARC1; p=none; rua=mailto:jon@snow.got; ruf=mailto:jon@snow.got; fo=1;', $records[2]->getRdata());

$records = $this->client->query('dev3.appwrite.io', 'TXT');
$this->assertCount(0, $records);
Expand Down
Loading