From 6d4904a3d2d29b8604b0d6ecc0dc8f3f2074d27b Mon Sep 17 00:00:00 2001 From: Renato Hysa Date: Wed, 15 Apr 2026 16:42:26 +0200 Subject: [PATCH] Modular architecture --- src/CheckVersionVulnerability.php | 68 --------------- .../DecimalComparator.php} | 26 ++++-- src/Comparators/StandardComparator.php | 15 ++++ src/CompareVersions.php | 19 ----- src/Contracts/VersionComparator.php | 10 +++ src/Contracts/VersionNormalizer.php | 21 +++++ .../CheckDrupalVersionVulnerability.php | 81 ------------------ src/Drupal/DrupalVersionNormalizer.php | 39 --------- src/Normalizers/DrupalNormalizer.php | 40 +++++++++ src/Normalizers/GenericNormalizer.php | 26 ++++++ src/VersionStrategy.php | 12 +++ src/VulnerabilityCheck.php | 66 ++++++++++++++ tests/CheckVersionVulnerabilityTest.php | 85 +++++++++---------- tests/CompareVersionsTest.php | 17 ++-- .../CheckDrupalVersionVulnerabilityTest.php | 71 ++++++++-------- tests/Drupal/DrupalVersionNormalizerTest.php | 6 +- tests/NormalizeVersionPairTest.php | 40 +++++---- tests/VersionStrategyTest.php | 7 ++ 18 files changed, 323 insertions(+), 326 deletions(-) delete mode 100644 src/CheckVersionVulnerability.php rename src/{NormalizeVersionPair.php => Comparators/DecimalComparator.php} (55%) create mode 100644 src/Comparators/StandardComparator.php delete mode 100644 src/CompareVersions.php create mode 100644 src/Contracts/VersionComparator.php create mode 100644 src/Contracts/VersionNormalizer.php delete mode 100644 src/Drupal/CheckDrupalVersionVulnerability.php delete mode 100644 src/Drupal/DrupalVersionNormalizer.php create mode 100644 src/Normalizers/DrupalNormalizer.php create mode 100644 src/Normalizers/GenericNormalizer.php create mode 100644 src/VulnerabilityCheck.php diff --git a/src/CheckVersionVulnerability.php b/src/CheckVersionVulnerability.php deleted file mode 100644 index 8742b8b..0000000 --- a/src/CheckVersionVulnerability.php +++ /dev/null @@ -1,68 +0,0 @@ -normalizeVersion($currentVersion); - - if (preg_match('/^(<=?)\s*(\S+)/', $affectedIn, $matches)) { - return $this->compareVersions->execute($currentVersion, $matches[2], $matches[1], $strategy); - } - - if (str_contains($affectedIn, ',')) { - $versions = explode(',', $affectedIn); - - foreach ($versions as $version) { - if ($this->normalizeVersion($version) === $currentVersion) { - return true; - } - } - - return false; - } - - if (preg_match('/^\d+(?:\.\d+)*\s*-\s*\d+(?:\.\d+)*$/', $affectedIn)) { - $parts = explode('-', $affectedIn, 2); - - return $this->compareVersions->execute($currentVersion, trim($parts[0]), '>=', $strategy) - && $this->compareVersions->execute($currentVersion, trim($parts[1]), '<=', $strategy); - } - - return $currentVersion === $this->normalizeVersion($affectedIn); - } - - private function normalizeVersion(string $version): string - { - $version = strtolower(trim($version)); - - if (str_starts_with($version, 'v')) { - $version = substr($version, 1); - } - - return $version; - } -} diff --git a/src/NormalizeVersionPair.php b/src/Comparators/DecimalComparator.php similarity index 55% rename from src/NormalizeVersionPair.php rename to src/Comparators/DecimalComparator.php index 1a29b44..8a753e7 100644 --- a/src/NormalizeVersionPair.php +++ b/src/Comparators/DecimalComparator.php @@ -2,19 +2,29 @@ declare(strict_types=1); -namespace Patchstack\VersionCompare; +namespace Patchstack\VersionCompare\Comparators; -final class NormalizeVersionPair +use Patchstack\VersionCompare\Contracts\VersionComparator; + +/** + * Compares versions by treating each dot-separated segment as a decimal number. + * + * PHP's version_compare treats "3.5" < "3.41" (string comparison: "5" < "41"). + * This comparator right-pads segments so "3.5" becomes "3.50", making 3.50 > 3.41. + */ +final class DecimalComparator implements VersionComparator { + public function compare(string $version1, string $version2, string $operator): bool + { + [$version1, $version2] = $this->padSegments($version1, $version2); + + return version_compare($version1, $version2, $operator); + } + /** - * Normalize two version strings by right-padding numeric segments to equal length. - * - * This ensures decimal-style versioning compares correctly: - * e.g., "3.5" vs "3.41" becomes "3.50" vs "3.41". - * * @return array{string, string} */ - public function execute(string $version1, string $version2): array + private function padSegments(string $version1, string $version2): array { $segments1 = explode('.', $version1); $segments2 = explode('.', $version2); diff --git a/src/Comparators/StandardComparator.php b/src/Comparators/StandardComparator.php new file mode 100644 index 0000000..e74b2cf --- /dev/null +++ b/src/Comparators/StandardComparator.php @@ -0,0 +1,15 @@ +normalizeVersionPair->execute($version1, $version2); - } - - return version_compare($version1, $version2, $operator); - } -} diff --git a/src/Contracts/VersionComparator.php b/src/Contracts/VersionComparator.php new file mode 100644 index 0000000..a31d546 --- /dev/null +++ b/src/Contracts/VersionComparator.php @@ -0,0 +1,10 @@ +execute($currentVersion, $version, $strategy)) { - return true; - } - } - - return false; - } - - $currentVersion = $this->normalizeVersion($currentVersion); - - if (! $this->normalizer->majorVersionsMatch($affectedIn, $currentVersion)) { - return false; - } - - $affectedIn = $this->normalizer->normalize($affectedIn); - $currentVersion = $this->normalizer->normalize($currentVersion); - - if (preg_match('/^(<=?)\s*(\S+)/', $affectedIn, $matches)) { - return $this->compareVersions->execute($currentVersion, $matches[2], $matches[1], $strategy); - } - - if (preg_match('/^\d+(?:\.\d+)*\s*-\s*\d+(?:\.\d+)*$/', $affectedIn)) { - $parts = explode('-', $affectedIn, 2); - - return $this->compareVersions->execute($currentVersion, trim($parts[0]), '>=', $strategy) - && $this->compareVersions->execute($currentVersion, trim($parts[1]), '<=', $strategy); - } - - return $currentVersion === $this->normalizeVersion($affectedIn); - } - - private function normalizeVersion(string $version): string - { - $version = strtolower(trim($version)); - - if (str_starts_with($version, 'v')) { - $version = substr($version, 1); - } - - return $version; - } -} diff --git a/src/Drupal/DrupalVersionNormalizer.php b/src/Drupal/DrupalVersionNormalizer.php deleted file mode 100644 index cdeae4a..0000000 --- a/src/Drupal/DrupalVersionNormalizer.php +++ /dev/null @@ -1,39 +0,0 @@ - 0 && count($matches2) > 0 && $matches1[0][1] !== $matches2[0][1]) { - return false; - } - - return true; - } -} diff --git a/src/Normalizers/DrupalNormalizer.php b/src/Normalizers/DrupalNormalizer.php new file mode 100644 index 0000000..b57909c --- /dev/null +++ b/src/Normalizers/DrupalNormalizer.php @@ -0,0 +1,40 @@ + 0 && count($matches2[0]) > 0 && $matches1[1][0] !== $matches2[1][0]) { + return false; + } + + return true; + } +} diff --git a/src/Normalizers/GenericNormalizer.php b/src/Normalizers/GenericNormalizer.php new file mode 100644 index 0000000..aa883eb --- /dev/null +++ b/src/Normalizers/GenericNormalizer.php @@ -0,0 +1,26 @@ + new StandardComparator(), + self::DecimalNormalized => new DecimalComparator(), + }; + } } diff --git a/src/VulnerabilityCheck.php b/src/VulnerabilityCheck.php new file mode 100644 index 0000000..7f720a7 --- /dev/null +++ b/src/VulnerabilityCheck.php @@ -0,0 +1,66 @@ +isVulnerable($currentVersion, trim($version))) { + return true; + } + } + + return false; + } + + if (! $this->normalizer->areCompatible($affectedIn, $currentVersion)) { + return false; + } + + $currentVersion = $this->normalizer->normalize($currentVersion); + $affectedIn = $this->normalizer->normalize($affectedIn); + + if (preg_match('/^(<=?)\s*(\S+)/', $affectedIn, $matches)) { + return $this->comparator->compare($currentVersion, $matches[2], $matches[1]); + } + + if (preg_match('/^\d+(?:\.\d+)*\s*-\s*\d+(?:\.\d+)*$/', $affectedIn)) { + $parts = explode('-', $affectedIn, 2); + + return $this->comparator->compare($currentVersion, trim($parts[0]), '>=') + && $this->comparator->compare($currentVersion, trim($parts[1]), '<='); + } + + return $currentVersion === $affectedIn; + } +} diff --git a/tests/CheckVersionVulnerabilityTest.php b/tests/CheckVersionVulnerabilityTest.php index 86392fa..710bfc6 100644 --- a/tests/CheckVersionVulnerabilityTest.php +++ b/tests/CheckVersionVulnerabilityTest.php @@ -2,28 +2,27 @@ declare(strict_types=1); -use Patchstack\VersionCompare\CheckVersionVulnerability; -use Patchstack\VersionCompare\CompareVersions; -use Patchstack\VersionCompare\NormalizeVersionPair; -use Patchstack\VersionCompare\VersionStrategy; +use Patchstack\VersionCompare\Comparators\DecimalComparator; +use Patchstack\VersionCompare\Comparators\StandardComparator; +use Patchstack\VersionCompare\Normalizers\GenericNormalizer; +use Patchstack\VersionCompare\VulnerabilityCheck; beforeEach(function () { - $this->action = new CheckVersionVulnerability( - new CompareVersions(new NormalizeVersionPair) - ); + $this->check = new VulnerabilityCheck(new StandardComparator(), new GenericNormalizer()); + $this->decimalCheck = new VulnerabilityCheck(new DecimalComparator(), new GenericNormalizer()); }); it('returns false for empty affected_in or current version', function () { - expect($this->action->execute('3.5', ''))->toBeFalse() - ->and($this->action->execute('', '<= 3.41'))->toBeFalse(); + expect($this->check->isVulnerable('3.5', ''))->toBeFalse() + ->and($this->check->isVulnerable('', '<= 3.41'))->toBeFalse(); }); it('returns true for wildcard affected_in', function () { - expect($this->action->execute('1.0', '*'))->toBeTrue(); + expect($this->check->isVulnerable('1.0', '*'))->toBeTrue(); }); it('handles operator-based affected_in with standard strategy', function (string $currentVersion, string $affectedIn, bool $expected) { - expect($this->action->execute($currentVersion, $affectedIn))->toBe($expected); + expect($this->check->isVulnerable($currentVersion, $affectedIn))->toBe($expected); })->with([ 'less than or equal: vulnerable' => ['3.5', '<= 3.41', true], 'less than: vulnerable' => ['2.0', '< 3.0', true], @@ -31,8 +30,8 @@ 'less than or equal: exact match' => ['3.41', '<= 3.41', true], ]); -it('handles operator-based affected_in with decimal strategy', function (string $currentVersion, string $affectedIn, bool $expected) { - expect($this->action->execute($currentVersion, $affectedIn, VersionStrategy::DecimalNormalized))->toBe($expected); +it('handles operator-based affected_in with decimal comparator', function (string $currentVersion, string $affectedIn, bool $expected) { + expect($this->decimalCheck->isVulnerable($currentVersion, $affectedIn))->toBe($expected); })->with([ 'decimal: 3.5 not <= 3.41 (the fix)' => ['3.5', '<= 3.41', false], 'decimal: 3.40 <= 3.41' => ['3.40', '<= 3.41', true], @@ -40,32 +39,32 @@ ]); it('normalizes operator spacing', function () { - expect($this->action->execute('3.0', '<=3.41'))->toBeTrue() - ->and($this->action->execute('3.0', '<= 3.41'))->toBeTrue(); + expect($this->check->isVulnerable('3.0', '<=3.41'))->toBeTrue() + ->and($this->check->isVulnerable('3.0', '<= 3.41'))->toBeTrue(); }); it('handles comma-separated affected_in', function () { - expect($this->action->execute('1.5', '1.0, 1.5, 2.0'))->toBeTrue() - ->and($this->action->execute('1.6', '1.0, 1.5, 2.0'))->toBeFalse(); + expect($this->check->isVulnerable('1.5', '1.0, 1.5, 2.0'))->toBeTrue() + ->and($this->check->isVulnerable('1.6', '1.0, 1.5, 2.0'))->toBeFalse(); }); it('handles range-based affected_in', function () { - expect($this->action->execute('1.5', '1.0-2.0'))->toBeTrue() - ->and($this->action->execute('0.9', '1.0-2.0'))->toBeFalse() - ->and($this->action->execute('2.1', '1.0-2.0'))->toBeFalse(); + expect($this->check->isVulnerable('1.5', '1.0-2.0'))->toBeTrue() + ->and($this->check->isVulnerable('0.9', '1.0-2.0'))->toBeFalse() + ->and($this->check->isVulnerable('2.1', '1.0-2.0'))->toBeFalse(); }); it('handles exact version matching', function () { - expect($this->action->execute('1.5', '1.5'))->toBeTrue() - ->and($this->action->execute('1.6', '1.5'))->toBeFalse(); + expect($this->check->isVulnerable('1.5', '1.5'))->toBeTrue() + ->and($this->check->isVulnerable('1.6', '1.5'))->toBeFalse(); }); it('strips v prefix from current version', function () { - expect($this->action->execute('v1.5', '1.5'))->toBeTrue(); + expect($this->check->isVulnerable('v1.5', '1.5'))->toBeTrue(); }); it('does not corrupt versions containing v in body', function (string $currentVersion, string $affectedIn, bool $expected) { - expect($this->action->execute($currentVersion, $affectedIn))->toBe($expected); + expect($this->check->isVulnerable($currentVersion, $affectedIn))->toBe($expected); })->with([ 'revision exact match' => ['6.3-revision-0', '6.3-revision-0', true], 'dev exact match' => ['1.0-dev', '1.0-dev', true], @@ -73,7 +72,7 @@ ]); it('handles exact match for versions with dashes', function (string $currentVersion, string $affectedIn, bool $expected) { - expect($this->action->execute($currentVersion, $affectedIn))->toBe($expected); + expect($this->check->isVulnerable($currentVersion, $affectedIn))->toBe($expected); })->with([ 'beta suffix: match' => ['4.5.5-beta', '4.5.5-beta', true], 'beta suffix: no match' => ['4.5.6', '4.5.5-beta', false], @@ -84,33 +83,33 @@ ]); it('does not match versions via scientific notation coercion', function () { - expect($this->action->execute('10', '1e1'))->toBeFalse() - ->and($this->action->execute('10', '1e1, 2.0'))->toBeFalse() - ->and($this->action->execute('100', '1e2'))->toBeFalse(); + expect($this->check->isVulnerable('10', '1e1'))->toBeFalse() + ->and($this->check->isVulnerable('10', '1e1, 2.0'))->toBeFalse() + ->and($this->check->isVulnerable('100', '1e2'))->toBeFalse(); }); it('handles version zero as valid input', function () { - expect($this->action->execute('0', '<= 1.0'))->toBeTrue() - ->and($this->action->execute('0', '*'))->toBeTrue(); + expect($this->check->isVulnerable('0', '<= 1.0'))->toBeTrue() + ->and($this->check->isVulnerable('0', '*'))->toBeTrue(); }); it('normalizes v prefix in affectedIn for comma and exact paths', function () { - expect($this->action->execute('v1.5', 'v1.0, v1.5, v2.0'))->toBeTrue() - ->and($this->action->execute('V1.5', 'V1.5'))->toBeTrue(); + expect($this->check->isVulnerable('v1.5', 'v1.0, v1.5, v2.0'))->toBeTrue() + ->and($this->check->isVulnerable('V1.5', 'V1.5'))->toBeTrue(); }); it('rejects whitespace-only current version', function () { - expect($this->action->execute(' ', '<= 3.0'))->toBeFalse() - ->and($this->action->execute(' ', '*'))->toBeFalse(); + expect($this->check->isVulnerable(' ', '<= 3.0'))->toBeFalse() + ->and($this->check->isVulnerable(' ', '*'))->toBeFalse(); }); it('returns false for malformed operator expressions', function () { - expect($this->action->execute('3.5', '3.41<'))->toBeFalse() - ->and($this->action->execute('3.5', 'version < 3.41'))->toBeFalse(); + expect($this->check->isVulnerable('3.5', '3.41<'))->toBeFalse() + ->and($this->check->isVulnerable('3.5', 'version < 3.41'))->toBeFalse(); }); it('handles real-world version formats', function (string $currentVersion, string $affectedIn, bool $expected) { - expect($this->action->execute($currentVersion, $affectedIn))->toBe($expected); + expect($this->check->isVulnerable($currentVersion, $affectedIn))->toBe($expected); })->with([ 'date-based: vulnerable' => ['20251210', '<= 20251210', true], 'date-based: fixed' => ['20260110', '<= 20251210', false], @@ -126,12 +125,12 @@ 'revision-based: fixed' => ['6.3-revision-1', '<= 6.3-revision-0', false], ]); -it('applies decimal strategy to strict less-than operator', function () { - expect($this->action->execute('3.5', '< 3.41', VersionStrategy::DecimalNormalized))->toBeFalse() - ->and($this->action->execute('3.40', '< 3.41', VersionStrategy::DecimalNormalized))->toBeTrue(); +it('applies decimal comparator to strict less-than operator', function () { + expect($this->decimalCheck->isVulnerable('3.5', '< 3.41'))->toBeFalse() + ->and($this->decimalCheck->isVulnerable('3.40', '< 3.41'))->toBeTrue(); }); -it('applies decimal strategy to range format', function () { - expect($this->action->execute('3.5', '3.41-3.6', VersionStrategy::DecimalNormalized))->toBeTrue() - ->and($this->action->execute('3.39', '3.41-3.6', VersionStrategy::DecimalNormalized))->toBeFalse(); +it('applies decimal comparator to range format', function () { + expect($this->decimalCheck->isVulnerable('3.5', '3.41-3.6'))->toBeTrue() + ->and($this->decimalCheck->isVulnerable('3.39', '3.41-3.6'))->toBeFalse(); }); diff --git a/tests/CompareVersionsTest.php b/tests/CompareVersionsTest.php index 20cd7ee..25e6a49 100644 --- a/tests/CompareVersionsTest.php +++ b/tests/CompareVersionsTest.php @@ -2,16 +2,11 @@ declare(strict_types=1); -use Patchstack\VersionCompare\CompareVersions; -use Patchstack\VersionCompare\NormalizeVersionPair; -use Patchstack\VersionCompare\VersionStrategy; +use Patchstack\VersionCompare\Comparators\DecimalComparator; +use Patchstack\VersionCompare\Comparators\StandardComparator; -beforeEach(function () { - $this->compare = new CompareVersions(new NormalizeVersionPair); -}); - -it('compares versions using standard strategy', function (string $v1, string $v2, string $op, bool $expected) { - expect($this->compare->execute($v1, $v2, $op))->toBe($expected); +it('compares versions using standard comparator', function (string $v1, string $v2, string $op, bool $expected) { + expect((new StandardComparator())->compare($v1, $v2, $op))->toBe($expected); })->with([ 'less than: true' => ['1.0', '2.0', '<', true], 'less than: false' => ['2.0', '1.0', '<', false], @@ -23,8 +18,8 @@ 'standard: 3.5 < 3.41 (PHP native behaviour)' => ['3.5', '3.41', '<', true], ]); -it('compares versions using decimal normalized strategy', function (string $v1, string $v2, string $op, bool $expected) { - expect($this->compare->execute($v1, $v2, $op, VersionStrategy::DecimalNormalized))->toBe($expected); +it('compares versions using decimal comparator', function (string $v1, string $v2, string $op, bool $expected) { + expect((new DecimalComparator())->compare($v1, $v2, $op))->toBe($expected); })->with([ 'decimal: 3.5 > 3.41 (normalized)' => ['3.5', '3.41', '>', true], 'decimal: 3.5 < 3.41 is false' => ['3.5', '3.41', '<', false], diff --git a/tests/Drupal/CheckDrupalVersionVulnerabilityTest.php b/tests/Drupal/CheckDrupalVersionVulnerabilityTest.php index f64fc4f..4480a48 100644 --- a/tests/Drupal/CheckDrupalVersionVulnerabilityTest.php +++ b/tests/Drupal/CheckDrupalVersionVulnerabilityTest.php @@ -2,86 +2,85 @@ declare(strict_types=1); -use Patchstack\VersionCompare\CompareVersions; -use Patchstack\VersionCompare\Drupal\CheckDrupalVersionVulnerability; -use Patchstack\VersionCompare\Drupal\DrupalVersionNormalizer; -use Patchstack\VersionCompare\NormalizeVersionPair; +use Patchstack\VersionCompare\Comparators\DecimalComparator; +use Patchstack\VersionCompare\Comparators\StandardComparator; +use Patchstack\VersionCompare\Normalizers\DrupalNormalizer; +use Patchstack\VersionCompare\VulnerabilityCheck; beforeEach(function () { - $this->action = new CheckDrupalVersionVulnerability( - new CompareVersions(new NormalizeVersionPair), - new DrupalVersionNormalizer, - ); + $this->check = new VulnerabilityCheck(new StandardComparator(), new DrupalNormalizer()); }); it('returns false for empty inputs', function () { - expect($this->action->execute('3.5', ''))->toBeFalse() - ->and($this->action->execute('', '<= 3.41'))->toBeFalse(); + expect($this->check->isVulnerable('3.5', ''))->toBeFalse() + ->and($this->check->isVulnerable('', '<= 3.41'))->toBeFalse(); }); it('returns true for wildcard', function () { - expect($this->action->execute('1.0', '*'))->toBeTrue(); + expect($this->check->isVulnerable('1.0', '*'))->toBeTrue(); }); it('handles Drupal major version prefix matching', function () { - expect($this->action->execute('8.x-3.4', '<= 8.x-3.5'))->toBeTrue() - ->and($this->action->execute('9.x-3.4', '<= 8.x-3.5'))->toBeFalse(); + expect($this->check->isVulnerable('8.x-3.4', '<= 8.x-3.5'))->toBeTrue() + ->and($this->check->isVulnerable('9.x-3.4', '<= 8.x-3.5'))->toBeFalse(); }); it('handles operator-based affected_in after prefix stripping', function () { - expect($this->action->execute('8.x-3.0', '<= 8.x-3.5'))->toBeTrue() - ->and($this->action->execute('8.x-4.0', '<= 8.x-3.5'))->toBeFalse(); + expect($this->check->isVulnerable('8.x-3.0', '<= 8.x-3.5'))->toBeTrue() + ->and($this->check->isVulnerable('8.x-4.0', '<= 8.x-3.5'))->toBeFalse(); }); it('handles range-based affected_in with prefixes', function () { - expect($this->action->execute('8.x-3.3', '8.x-3.0-8.x-3.5'))->toBeTrue() - ->and($this->action->execute('8.x-2.9', '8.x-3.0-8.x-3.5'))->toBeFalse(); + expect($this->check->isVulnerable('8.x-3.3', '8.x-3.0-8.x-3.5'))->toBeTrue() + ->and($this->check->isVulnerable('8.x-2.9', '8.x-3.0-8.x-3.5'))->toBeFalse(); }); it('handles comma-separated affected_in', function () { - expect($this->action->execute('8.x-3.0', '8.x-3.0, 8.x-3.1'))->toBeTrue() - ->and($this->action->execute('8.x-3.2', '8.x-3.0, 8.x-3.1'))->toBeFalse(); + expect($this->check->isVulnerable('8.x-3.0', '8.x-3.0, 8.x-3.1'))->toBeTrue() + ->and($this->check->isVulnerable('8.x-3.2', '8.x-3.0, 8.x-3.1'))->toBeFalse(); }); it('handles versions without Drupal prefix', function () { - expect($this->action->execute('3.5', '<= 3.5'))->toBeTrue() - ->and($this->action->execute('3.6', '<= 3.5'))->toBeFalse(); + expect($this->check->isVulnerable('3.5', '<= 3.5'))->toBeTrue() + ->and($this->check->isVulnerable('3.6', '<= 3.5'))->toBeFalse(); }); it('handles exact version matching', function () { - expect($this->action->execute('8.x-3.5', '8.x-3.5'))->toBeTrue() - ->and($this->action->execute('8.x-3.6', '8.x-3.5'))->toBeFalse(); + expect($this->check->isVulnerable('8.x-3.5', '8.x-3.5'))->toBeTrue() + ->and($this->check->isVulnerable('8.x-3.6', '8.x-3.5'))->toBeFalse(); }); it('does not corrupt Drupal versions containing v in body', function () { - expect($this->action->execute('8.x-3.5-dev', '<= 8.x-3.5-dev'))->toBeTrue(); + expect($this->check->isVulnerable('8.x-3.5-dev', '<= 8.x-3.5-dev'))->toBeTrue(); }); it('handles exact match for Drupal versions with dashes after prefix stripping', function () { - expect($this->action->execute('8.x-3.5-beta', '8.x-3.5-beta'))->toBeTrue() - ->and($this->action->execute('8.x-3.6', '8.x-3.5-beta'))->toBeFalse(); + expect($this->check->isVulnerable('8.x-3.5-beta', '8.x-3.5-beta'))->toBeTrue() + ->and($this->check->isVulnerable('8.x-3.6', '8.x-3.5-beta'))->toBeFalse(); }); it('does not match Drupal versions via scientific notation coercion', function () { - expect($this->action->execute('10', '1e1'))->toBeFalse(); + expect($this->check->isVulnerable('10', '1e1'))->toBeFalse(); }); it('handles version zero as valid Drupal input', function () { - expect($this->action->execute('0', '<= 1.0'))->toBeTrue() - ->and($this->action->execute('0', '*'))->toBeTrue(); + expect($this->check->isVulnerable('0', '<= 1.0'))->toBeTrue() + ->and($this->check->isVulnerable('0', '*'))->toBeTrue(); }); it('rejects whitespace-only Drupal current version', function () { - expect($this->action->execute(' ', '<= 3.0'))->toBeFalse() - ->and($this->action->execute(' ', '*'))->toBeFalse(); + expect($this->check->isVulnerable(' ', '<= 3.0'))->toBeFalse() + ->and($this->check->isVulnerable(' ', '*'))->toBeFalse(); }); it('returns false for malformed Drupal operator expressions', function () { - expect($this->action->execute('3.5', '3.41<'))->toBeFalse() - ->and($this->action->execute('3.5', 'version < 3.41'))->toBeFalse(); + expect($this->check->isVulnerable('3.5', '3.41<'))->toBeFalse() + ->and($this->check->isVulnerable('3.5', 'version < 3.41'))->toBeFalse(); }); -it('handles Drupal with decimal normalized strategy', function () { - expect($this->action->execute('8.x-3.5', '<= 8.x-3.41', \Patchstack\VersionCompare\VersionStrategy::DecimalNormalized))->toBeFalse() - ->and($this->action->execute('8.x-3.40', '<= 8.x-3.41', \Patchstack\VersionCompare\VersionStrategy::DecimalNormalized))->toBeTrue(); +it('handles Drupal with decimal comparator', function () { + $decimal = new VulnerabilityCheck(new DecimalComparator(), new DrupalNormalizer()); + + expect($decimal->isVulnerable('8.x-3.5', '<= 8.x-3.41'))->toBeFalse() + ->and($decimal->isVulnerable('8.x-3.40', '<= 8.x-3.41'))->toBeTrue(); }); diff --git a/tests/Drupal/DrupalVersionNormalizerTest.php b/tests/Drupal/DrupalVersionNormalizerTest.php index c3c9993..4ad3e91 100644 --- a/tests/Drupal/DrupalVersionNormalizerTest.php +++ b/tests/Drupal/DrupalVersionNormalizerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Patchstack\VersionCompare\Drupal\DrupalVersionNormalizer; +use Patchstack\VersionCompare\Normalizers\DrupalNormalizer; beforeEach(function () { - $this->normalizer = new DrupalVersionNormalizer; + $this->normalizer = new DrupalNormalizer; }); it('strips major version prefixes', function (string $input, string $expected) { @@ -18,7 +18,7 @@ ]); it('checks major version matching', function (string $v1, string $v2, bool $expected) { - expect($this->normalizer->majorVersionsMatch($v1, $v2))->toBe($expected); + expect($this->normalizer->areCompatible($v1, $v2))->toBe($expected); })->with([ 'same major: 8.x' => ['8.x-3.0', '8.x-3.5', true], 'different major: 8.x vs 9.x' => ['8.x-3.0', '9.x-3.5', false], diff --git a/tests/NormalizeVersionPairTest.php b/tests/NormalizeVersionPairTest.php index 00c1e61..081a632 100644 --- a/tests/NormalizeVersionPairTest.php +++ b/tests/NormalizeVersionPairTest.php @@ -2,29 +2,33 @@ declare(strict_types=1); -use Patchstack\VersionCompare\NormalizeVersionPair; +use Patchstack\VersionCompare\Comparators\DecimalComparator; -beforeEach(function () { - $this->normalizer = new NormalizeVersionPair; -}); - -it('pads shorter numeric segments to equal length', function (string $v1, string $v2, string $expectedV1, string $expectedV2) { - [$result1, $result2] = $this->normalizer->execute($v1, $v2); +/* +|-------------------------------------------------------------------------- +| Decimal segment padding — tested through the DecimalComparator +|-------------------------------------------------------------------------- +| +| The DecimalComparator right-pads numeric segments so "3.5" becomes "3.50", +| ensuring decimal-style versions compare correctly. These tests verify the +| padding behavior via comparison results. +| +*/ - expect($result1)->toBe($expectedV1) - ->and($result2)->toBe($expectedV2); +it('pads shorter numeric segments to equal length', function (string $v1, string $v2, string $op, bool $expected) { + expect((new DecimalComparator())->compare($v1, $v2, $op))->toBe($expected); })->with([ - 'decimal: 3.5 vs 3.41' => ['3.5', '3.41', '3.50', '3.41'], - 'decimal: 3.41 vs 3.5' => ['3.41', '3.5', '3.41', '3.50'], - 'equal length: no change' => ['3.41', '3.42', '3.41', '3.42'], - 'semver: already equal' => ['1.2.3', '1.2.4', '1.2.3', '1.2.4'], - 'different segment counts' => ['3.5', '3.41.2', '3.50.0', '3.41.2'], - 'single segment' => ['5', '41', '50', '41'], + 'decimal: 3.5 vs 3.41' => ['3.5', '3.41', '>', true], + 'decimal: 3.41 vs 3.5' => ['3.41', '3.5', '<', true], + 'equal length: no change' => ['3.41', '3.42', '<', true], + 'semver: already equal' => ['1.2.3', '1.2.4', '<', true], + 'different segment counts' => ['3.5', '3.41.2', '>', true], + 'single segment' => ['5', '41', '>', true], ]); it('skips non-numeric segments', function () { - [$result1, $result2] = $this->normalizer->execute('5.0.37.decaf', '5.0.53.decaf'); + $comparator = new DecimalComparator(); - expect($result1)->toBe('5.0.37.decaf') - ->and($result2)->toBe('5.0.53.decaf'); + expect($comparator->compare('5.0.37.decaf', '5.0.53.decaf', '<'))->toBeTrue() + ->and($comparator->compare('5.0.53.decaf', '5.0.37.decaf', '>'))->toBeTrue(); }); diff --git a/tests/VersionStrategyTest.php b/tests/VersionStrategyTest.php index df2f39a..478e81f 100644 --- a/tests/VersionStrategyTest.php +++ b/tests/VersionStrategyTest.php @@ -2,9 +2,16 @@ declare(strict_types=1); +use Patchstack\VersionCompare\Comparators\DecimalComparator; +use Patchstack\VersionCompare\Comparators\StandardComparator; use Patchstack\VersionCompare\VersionStrategy; it('creates strategy from abnormal version flag', function () { expect(VersionStrategy::fromAbnormalFlag(true))->toBe(VersionStrategy::DecimalNormalized) ->and(VersionStrategy::fromAbnormalFlag(false))->toBe(VersionStrategy::Standard); }); + +it('creates the correct comparator', function () { + expect(VersionStrategy::Standard->comparator())->toBeInstanceOf(StandardComparator::class) + ->and(VersionStrategy::DecimalNormalized->comparator())->toBeInstanceOf(DecimalComparator::class); +});