diff --git a/plist b/plist index c194d3225a..8fa2256092 100644 --- a/plist +++ b/plist @@ -631,6 +631,7 @@ /usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/OptionField.php /usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/PortField.php /usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/ProtocolField.php +/usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/RegexField.php /usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/TextField.php /usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/UniqueIdField.php /usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/UpdateOnlyTextField.php @@ -1036,6 +1037,7 @@ /usr/local/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/OptionFieldTest.php /usr/local/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/PortFieldTest.php /usr/local/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/ProtocolFieldTest.php +/usr/local/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/RegexFieldTest.php /usr/local/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/TextFieldTest.php /usr/local/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/VirtualIPFieldTest.php /usr/local/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/VirtualIPFieldTest/config.xml diff --git a/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/RegexField.php b/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/RegexField.php new file mode 100644 index 0000000000..83a943165a --- /dev/null +++ b/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/RegexField.php @@ -0,0 +1,106 @@ +internalRequireDelimiters = strtoupper(trim($value)) === 'Y'; + } + + /** + * {@inheritdoc} + */ + protected function defaultValidationMessage() + { + return gettext("Invalid regular expression pattern."); + } + + /** + * @return array list of validators for this field + */ + public function getValidators() + { + $validators = parent::getValidators(); + + $validators[] = new CallbackValidator([ + "callback" => function ($value) { + $item = (string)$value; + + // Skip validation if empty + if ($item === '') { + return []; + } + + // Try using the pattern as-is to check if it has delimiters + $hasDelimiters = @preg_match($item, '') !== false; + + if ($this->internalRequireDelimiters) { + // Delimiters are required + if (!$hasDelimiters) { + return [$this->getValidationMessage()]; + } + } else { + // No delimiters expected + if ($hasDelimiters) { + return [$this->getValidationMessage()]; + } + + // Wrap pattern with delimiter for validation + $delimiter = chr(1); + $testPattern = $delimiter . $item . $delimiter; + + if (@preg_match($testPattern, '') === false) { + return [$this->getValidationMessage()]; + } + } + + return []; + } + ]); + + return $validators; + } +} diff --git a/src/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/RegexFieldTest.php b/src/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/RegexFieldTest.php new file mode 100644 index 0000000000..9b7b5ce5fa --- /dev/null +++ b/src/opnsense/mvc/tests/app/models/OPNsense/Base/FieldTypes/RegexFieldTest.php @@ -0,0 +1,156 @@ +assertInstanceOf(RegexField::class, new RegexField()); + } + + public function testIsContainer() + { + $field = new RegexField(); + $this->assertFalse($field->isContainer()); + } + + public function testRequiredEmpty() + { + $this->expectException(\OPNsense\Base\ValidationException::class); + $field = new RegexField(); + $field->setRequired("Y"); + $field->setValue(""); + $field->eventPostLoading(); + $this->validateThrow($field); + } + + public function testRequiredNotEmpty() + { + $field = new RegexField(); + $field->setRequired("Y"); + $field->setValue("^test$"); + $field->eventPostLoading(); + $this->assertEmpty($this->validate($field)); + } + + public function testValidPatternsWithoutDelimiters() + { + $field = new RegexField(); + $field->setRequireDelimiters("N"); + $field->eventPostLoading(); + foreach (["^test$", "[a-z]+", "\\d{3}", "foo|bar", "(?i)case.*insensitive"] as $value) { + $field->setValue($value); + $this->assertEmpty($this->validate($field), "$value should be valid"); + } + } + + public function testValidPatternsWithDelimiters() + { + $field = new RegexField(); + $field->setRequireDelimiters("Y"); + $field->eventPostLoading(); + foreach (["/^test$/", "/[a-z]+/i", "#\\d{3}#", "/foo|bar/"] as $value) { + $field->setValue($value); + $this->assertEmpty($this->validate($field), "$value should be valid"); + } + } + + public function testInvalidPatternsWithoutDelimiters() + { + $field = new RegexField(); + $field->setRequireDelimiters("N"); + $field->eventPostLoading(); + foreach (["[unclosed", "bad)", "(?bad-group"] as $value) { + $field->setValue($value); + $this->assertNotEmpty($this->validate($field), "$value should be invalid"); + } + } + + public function testInvalidPatternsWithDelimiters() + { + $field = new RegexField(); + $field->setRequireDelimiters("Y"); + $field->eventPostLoading(); + foreach (["/[unclosed", "/bad)/", "no-delimiters", "/(?bad-group/"] as $value) { + $field->setValue($value); + $this->assertNotEmpty($this->validate($field), "$value should be invalid"); + } + } + + // Test patterns with delimiters and trailing modifiers (PHP PCRE2 style) + public function testAllowDelimitersWithModifiers() + { + $field = new RegexField(); + $field->setRequireDelimiters("Y"); + $field->eventPostLoading(); + foreach (["/^test$/im", "#[a-z]+#iu", "~\\d{3}~ms"] as $value) { + $field->setValue($value); + $this->assertEmpty($this->validate($field), "$value should be valid"); + } + } + + public function testRejectDelimitersWithModifiers() + { + $field = new RegexField(); + $field->setRequireDelimiters("N"); + $field->eventPostLoading(); + foreach (["/^test$/im", "#[a-z]+#iu", "~\\d{3}~ms"] as $value) { + $field->setValue($value); + $this->assertNotEmpty($this->validate($field), "$value should be invalid (has delimiters)"); + } + } + + public function testDefaultBehaviorNoDelimiters() + { + $field = new RegexField(); + $field->eventPostLoading(); + + // Default should be no delimiters required + $field->setValue("^test$"); + $this->assertEmpty($this->validate($field), "Default should accept patterns without delimiters"); + + $field->setValue("/^test$/"); + $this->assertNotEmpty($this->validate($field), "Default should reject PHP-style delimiters"); + } + + public function testEmptyValue() + { + $field = new RegexField(); + $field->eventPostLoading(); + $field->setValue(""); + $this->assertEmpty($this->validate($field), "Empty value should be valid when not required"); + } +}