diff --git a/src/DNS/Validator/CAA.php b/src/DNS/Validator/CAA.php new file mode 100644 index 0000000..3fec5cd --- /dev/null +++ b/src/DNS/Validator/CAA.php @@ -0,0 +1,117 @@ + ""'; + + public string $reason = ''; + + /** + * Check if the provided value matches the CAA record format + * + * @param mixed $data + * @return bool + */ + public function isValid(mixed $data): bool + { + if (!is_string($data)) { + $this->reason = self::FAILURE_REASON_INVALID_FORMAT; + return false; + } + + $parts = explode(" ", $data, 3); + + if (count($parts) !== 3) { + $this->reason = self::FAILURE_REASON_INVALID_FORMAT; + return false; + } + + $flags = $parts[0]; + $tag = $parts[1]; + $value = $parts[2]; + + // Check flags is a number + if (!is_numeric($flags)) { + $this->reason = self::FAILURE_REASON_INVALID_FLAGS; + return false; + } + + $flags = (int) $flags; + + // Check flags is within the allowed range + if ($flags < self::CAA_FLAG_MIN || $flags > self::CAA_FLAG_MAX) { + $this->reason = self::FAILURE_REASON_INVALID_FLAGS; + return false; + } + + // Check tag is not empty + if (strlen($tag) === 0) { + $this->reason = self::FAILURE_REASON_INVALID_TAG; + return false; + } + + // Check value is not empty and starts with " and ends with " + if (strlen($value) === 0 || $value[0] !== '"' || $value[strlen($value) - 1] !== '"') { + $this->reason = self::FAILURE_REASON_INVALID_VALUE; + return false; + } + + $value = substr($value, 1, strlen($value) - 2); + + // Check value is not empty after removing the quotes + if (strlen($value) === 0) { + $this->reason = self::FAILURE_REASON_INVALID_VALUE; + return false; + } + + // All checks passed + return true; + } + + public function getDescription(): string + { + if (!empty($this->reason)) { + return $this->reason; + } + + return self::FAILURE_REASON_INVALID_FORMAT; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/DNS/Validator/Name.php b/src/DNS/Validator/Name.php new file mode 100644 index 0000000..fe883bf --- /dev/null +++ b/src/DNS/Validator/Name.php @@ -0,0 +1,129 @@ +recordType = $recordType; + } + + /** + * Check if the provided value matches the Name record format + * + * @param mixed $name + * @return bool + */ + public function isValid(mixed $name): bool + { + if (!\is_string($name)) { + $this->reason = self::FAILURE_REASON_GENERAL; + return false; + } + + // DNS names are made up of labels separated by dots. + // Each label: 1-63 chars, letters, digits, hyphens, can't start/end w/ hyphen. + // Full name: <=255 chars, labels separated by single dots, no empty labels unless root. + // If the record type allows underscores in the name, they are allowed in the name. + + if (\strlen($name) < 1 || \strlen($name) > Domain::MAX_DOMAIN_NAME_LEN) { + $this->reason = self::FAILURE_REASON_INVALID_NAME_LENGTH; + return false; + } + + // Special case for referencing the zone origin + if ($name === '@') { + return true; + } + + // If the name ends with '.', strip it (absolute FQDN); allow trailing '.'. + $trimmed = (\substr($name, -1) === '.') ? \substr($name, 0, -1) : $name; + $labels = \explode('.', $trimmed); + + $isUnderscoreAllowed = \in_array($this->recordType, self::RECORD_TYPES_WITH_UNDERSCORE_IN_NAME); + + foreach ($labels as $label) { + if ($label === '') { + $this->reason = $isUnderscoreAllowed ? self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE : self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE; + return false; + } + + if (\strlen($label) > self::LABEL_MAX_LENGTH) { + $this->reason = self::FAILURE_REASON_INVALID_LABEL_LENGTH; + return false; + } + + // RFC: Only a-z 0-9 -, can't start or end with '-' + // May contain '_' if the record type allows it. + $len = \strlen($label); + // Check label contains only allowed chars + for ($i = 0; $i < $len; ++$i) { + if (!$this->isValidCharacter($label[$i], $i === 0 || $i === $len - 1, $isUnderscoreAllowed)) { + $this->reason = $isUnderscoreAllowed ? self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE : self::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE; + return false; + } + } + } + + return true; + } + + private function isValidCharacter(string $char, bool $isFirstOrLast, bool $isUnderscoreAllowed): bool + { + if ($isFirstOrLast) { + return \ctype_alnum($char) || ($isUnderscoreAllowed && $char === '_'); + } + return \ctype_alnum($char) || $char === '-' || ($isUnderscoreAllowed && $char === '_'); + } + + /** + * @inheritDoc + */ + public function getDescription(): string + { + if (!empty($this->reason)) { + return $this->reason; + } + + return self::FAILURE_REASON_GENERAL; + } + + /** + * @inheritDoc + */ + public function getType(): string + { + return self::TYPE_STRING; + } + + /** + * @inheritDoc + */ + public function isArray(): bool + { + return false; + } +} diff --git a/tests/unit/DNS/Validator/CAATest.php b/tests/unit/DNS/Validator/CAATest.php new file mode 100644 index 0000000..12952fd --- /dev/null +++ b/tests/unit/DNS/Validator/CAATest.php @@ -0,0 +1,45 @@ +assertTrue($validator->isValid($value), "Expected valid: {$value}"); + } + } + + public function testInvalid(): void + { + $validator = new CAA(); + + $invalidValues = [ + ['value' => 'issue "letsencrypt.org"', 'description' => CAA::FAILURE_REASON_INVALID_FORMAT], + ['value' => '0 ""', 'description' => CAA::FAILURE_REASON_INVALID_FORMAT], + ['value' => '256 issue "letsencrypt.org"', 'description' => CAA::FAILURE_REASON_INVALID_FLAGS], + ['value' => '0 issue letsencrypt.org', 'description' => CAA::FAILURE_REASON_INVALID_VALUE], + ['value' => '0 issue ""', 'description' => CAA::FAILURE_REASON_INVALID_VALUE], + ]; + + foreach ($invalidValues as $invalidValue) { + $this->assertFalse($validator->isValid($invalidValue['value']), "Expected invalid: {$invalidValue['value']}"); + $this->assertSame($invalidValue['description'], $validator->getDescription()); + } + } +} diff --git a/tests/unit/DNS/Validator/NameTest.php b/tests/unit/DNS/Validator/NameTest.php new file mode 100644 index 0000000..b38bde8 --- /dev/null +++ b/tests/unit/DNS/Validator/NameTest.php @@ -0,0 +1,83 @@ +assertTrue($validator->isValid($value), "Expected valid: {$value}"); + } + + // Type that allows underscores in name + $validator = new Name(Record::TYPE_SRV); + $this->assertTrue($validator->isValid('example._tcp.com'), "Expected valid: example._tcp.com"); + } + + public function testInvalid(): void + { + $validator = new Name(Record::TYPE_CNAME); + + $invalidValues = [ + ['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL], + ['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH], + ['value' => '@.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'exa_mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITHOUT_UNDERSCORE], + ]; + + foreach ($invalidValues as $value) { + $this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}"); + $this->assertSame($value['description'], $validator->getDescription()); + } + + // Type that allows underscores in name + $validator = new Name(Record::TYPE_TXT); + + $invalidValues = [ + ['value' => 123, 'description' => Name::FAILURE_REASON_GENERAL], + ['value' => '', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 256) . '.com', 'description' => Name::FAILURE_REASON_INVALID_NAME_LENGTH], + ['value' => str_repeat('a', 64) . '.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_LENGTH], + ['value' => '@.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ['value' => '-example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ['value' => 'example-.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ['value' => 'example..com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ['value' => '.example.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ['value' => 'example.com..', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ['value' => 'exa mple.com', 'description' => Name::FAILURE_REASON_INVALID_LABEL_CHARACTERS_WITH_UNDERSCORE], + ]; + + foreach ($invalidValues as $value) { + $this->assertFalse($validator->isValid($value['value']), "Expected invalid: {$value['value']}"); + $this->assertSame($value['description'], $validator->getDescription()); + } + } +}