validator pattern

This commit is contained in:
Hemachandar
2026-03-24 13:03:48 +05:30
parent 3f555e3061
commit 283efcb3c8
3 changed files with 183 additions and 54 deletions
@@ -7,6 +7,7 @@ use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Vcs\Comment;
use Appwrite\Vcs\Validator\BuildTrigger;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Database;
@@ -534,22 +535,15 @@ trait Deployment
private function isResourceBuildable(Document $resource, string $providerBranch, array $providerAffectedFiles, string $logBase): bool
{
$allowedBranches = $resource->getAttribute('providerBranches', []);
if (!$this->matchesPatterns($providerBranch, $allowedBranches)) {
$branchTrigger = new BuildTrigger($resource->getAttribute('providerBranches', []));
if (!$branchTrigger->isValid($providerBranch)) {
Span::add("{$logBase}.build.skipped.reason", 'branch');
return false;
}
$allowedPaths = $resource->getAttribute('providerPaths', []);
if (!empty($allowedPaths) && !empty($providerAffectedFiles)) {
$pathMatched = false;
foreach ($providerAffectedFiles as $file) {
if ($this->matchesPatterns($file, $allowedPaths)) {
$pathMatched = true;
break;
}
}
if (!$pathMatched) {
$pathTrigger = new BuildTrigger($resource->getAttribute('providerPaths', []));
foreach ($providerAffectedFiles as $file) {
if (!$pathTrigger->isValid($file)) {
Span::add("{$logBase}.build.skipped.reason", 'path');
return false;
}
@@ -557,46 +551,4 @@ trait Deployment
return true;
}
private function matchesPatterns(string $subject, array $patterns): bool
{
if (empty($patterns)) {
return true;
}
$include = array_filter($patterns, fn ($p) => !str_starts_with($p, '!'));
$exclude = array_filter($patterns, fn ($p) => str_starts_with($p, '!'));
foreach ($include as $pattern) {
if ($this->matchGlob($subject, $pattern)) {
return true;
}
}
foreach ($exclude as $pattern) {
if ($this->matchGlob($subject, substr($pattern, 1))) {
return false;
}
}
return empty($include);
}
private function matchGlob(string $subject, string $pattern): bool
{
$regex = preg_replace_callback(
'/\*\*|\*|\?|[^*?]+/',
static function (array $m): string {
return match ($m[0]) {
'**' => '.*',
'*' => '[^/]*',
'?' => '[^/]',
default => preg_quote($m[0], '/'),
};
},
$pattern
);
return (bool) preg_match('/^' . $regex . '$/', $subject);
}
}
@@ -0,0 +1,91 @@
<?php
namespace Appwrite\Vcs\Validator;
use Utopia\Validator;
class BuildTrigger extends Validator
{
public function __construct(private readonly array $patterns) {}
public function isValid($value): bool
{
if (!is_string($value)) {
return false;
}
if (empty($this->patterns)) {
return true;
}
$include = array_filter($this->patterns, fn ($p) => !str_starts_with($p, '!'));
$exclude = array_filter($this->patterns, fn ($p) => str_starts_with($p, '!'));
foreach ($include as $pattern) {
if ($this->matchGlob($value, $pattern)) {
return true;
}
}
foreach ($exclude as $pattern) {
if ($this->matchGlob($value, substr($pattern, 1))) {
return false;
}
}
return empty($include);
}
public function getDescription(): string
{
return 'Value must match at least one inclusion pattern and must not match any exclusion pattern.';
}
public function isArray(): bool
{
return false;
}
public function getType(): string
{
return self::TYPE_STRING;
}
private function matchGlob(string $subject, string $pattern): bool
{
$regex = '';
$len = strlen($pattern);
$i = 0;
while ($i < $len) {
$char = $pattern[$i];
if ($char === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
$prevSlash = $i === 0 || $pattern[$i - 1] === '/';
$nextSlash = isset($pattern[$i + 2]) && $pattern[$i + 2] === '/';
if ($prevSlash && $nextSlash) {
// a/**/b → zero or more intermediate dirs (matches a/b, a/x/b, a/x/y/b)
// **/foo → zero or more leading dirs (matches foo, a/foo, a/b/foo)
$regex .= '(?:.+/)?';
$i += 3; // consume ** and the trailing /
} else {
// foo/** → everything inside (matches foo/a, foo/a/b)
$regex .= '.*';
$i += 2;
}
} elseif ($char === '*') {
$regex .= '[^/]*'; // anything except a path separator
$i++;
} elseif ($char === '?') {
$regex .= '[^/]'; // any single character except a path separator
$i++;
} else {
$regex .= preg_quote($char, '~');
$i++;
}
}
return (bool) preg_match('~^' . $regex . '$~', $subject);
}
}
@@ -0,0 +1,86 @@
<?php
namespace Tests\Unit\Vcs\Validator;
use Appwrite\Vcs\Validator\BuildTrigger;
use PHPUnit\Framework\TestCase;
class BuildTriggerTest extends TestCase
{
public function testEmptyPatterns(): void
{
$validator = new BuildTrigger([]);
$this->assertTrue($validator->isValid('anything'));
$this->assertTrue($validator->isValid('main'));
}
public function testExactMatch(): void
{
$validator = new BuildTrigger(['main']);
$this->assertTrue($validator->isValid('main'));
$this->assertFalse($validator->isValid('develop'));
}
public function testSingleWildcard(): void
{
$validator = new BuildTrigger(['feature/*']);
$this->assertTrue($validator->isValid('feature/foo'));
$this->assertFalse($validator->isValid('feature/foo/bar')); // * does not cross /
$this->assertFalse($validator->isValid('main'));
}
public function testDashInPattern(): void
{
$validator = new BuildTrigger(['feature/test-*']);
$this->assertTrue($validator->isValid('feature/test-1'));
$this->assertTrue($validator->isValid('feature/test-abc'));
$this->assertFalse($validator->isValid('feature/other'));
}
public function testDoubleWildcardEnd(): void
{
$validator = new BuildTrigger(['src/**']);
$this->assertTrue($validator->isValid('src/foo.js'));
$this->assertTrue($validator->isValid('src/a/b/c.js'));
}
public function testDoubleWildcardMiddle(): void
{
$validator = new BuildTrigger(['a/**/b']);
$this->assertTrue($validator->isValid('a/b')); // zero intermediate dirs
$this->assertTrue($validator->isValid('a/x/b')); // one
$this->assertTrue($validator->isValid('a/x/y/b')); // two
$this->assertFalse($validator->isValid('a/b/c'));
}
public function testDoubleWildcardStart(): void
{
$validator = new BuildTrigger(['**/foo']);
$this->assertTrue($validator->isValid('foo'));
$this->assertTrue($validator->isValid('a/foo'));
$this->assertTrue($validator->isValid('a/b/foo'));
$this->assertFalse($validator->isValid('foobar'));
}
public function testOrSemantics(): void
{
$validator = new BuildTrigger(['main', 'develop']);
$this->assertTrue($validator->isValid('main'));
$this->assertTrue($validator->isValid('develop'));
$this->assertFalse($validator->isValid('feature/x'));
}
public function testInclusionTakesPrecedenceOverExclusion(): void
{
$validator = new BuildTrigger(['!feature/*', 'feature/abc']);
$this->assertTrue($validator->isValid('feature/abc')); // inclusion wins
$this->assertFalse($validator->isValid('feature/xyz')); // excluded
}
public function testOnlyExclusions(): void
{
$validator = new BuildTrigger(['!main']);
$this->assertFalse($validator->isValid('main'));
$this->assertTrue($validator->isValid('develop')); // not excluded, passes by default
}
}