mvc: Add RegexField and RegexFieldTest that validate PCRE2 engine regular expressions (#9291)

* mvc: Add RegexField and RegexFieldTest that validate PCRE2 engine regular expressions

* Add tests for delimiters with modifiers
This commit is contained in:
Monviech 2025-10-17 09:08:37 +02:00 committed by GitHub
parent 51bdb9ac84
commit 03c8d0a36f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 264 additions and 0 deletions

2
plist
View file

@ -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

View file

@ -0,0 +1,106 @@
<?php
/*
* Copyright (C) 2025 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\Base\FieldTypes;
use OPNsense\Base\Validators\CallbackValidator;
class RegexField extends BaseField
{
/**
* @var bool marks if this is a data node or a container
*/
protected $internalIsContainer = false;
/**
* @var bool whether PHP-style delimiters are required
*/
private $internalRequireDelimiters = false;
/**
* @param string $value Y/N
*/
public function setRequireDelimiters($value): void
{
$this->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;
}
}

View file

@ -0,0 +1,156 @@
<?php
/*
* Copyright (C) 2025 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace tests\OPNsense\Base\FieldTypes;
// @CodingStandardsIgnoreStart
require_once __DIR__ . '/Field_Framework_TestCase.php';
// @CodingStandardsIgnoreEnd
use OPNsense\Base\FieldTypes\RegexField;
class RegexFieldTest extends Field_Framework_TestCase
{
public function testCanBeCreated()
{
$this->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");
}
}