From 076c63204cd49eea233fe734d9c105cd34030fcc Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 20 May 2026 13:36:14 -0700 Subject: [PATCH] Reject semver values with leading zeros in local evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per semver 2.0.0 §2, numeric identifiers must not include leading zeros. Values like "1.07.3" are not valid semver and should not match targeting conditions. Both override values and flag values are validated; invalid inputs surface an InconclusiveMatchError so the condition does not match. --- lib/FeatureFlag.php | 55 ++++++++---------- test/FeatureFlagLocalEvaluationTest.php | 74 ++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 40 deletions(-) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index aba5f6a..dc2c2ab 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -336,6 +336,23 @@ public static function relativeDateParseForFeatureFlagMatching($value) } } + /** + * Parse a semver numeric identifier (e.g., the "1" in "1.2.3"). + * + * @throws InconclusiveMatchException If the part is empty, non-numeric, or has a leading zero. + */ + private static function parseSemverNumeric(string $part, string $component, $value): int + { + // Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. + if ($part === "" || !ctype_digit($part)) { + throw new InconclusiveMatchException("Cannot parse semver: invalid {$component} version in {$value}"); + } + if (strlen($part) > 1 && $part[0] === "0") { + throw new InconclusiveMatchException("Cannot parse semver: {$component} version has leading zero in {$value}"); + } + return intval($part); + } + /** * Parse a semver string into a tuple of [major, minor, patch]. * @@ -346,7 +363,8 @@ public static function relativeDateParseForFeatureFlagMatching($value) * 4. Split on `.` and parse first 3 components as integers * 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0)) * 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3)) - * 7. Throw InconclusiveMatchException for invalid input (empty string, non-numeric parts, leading dot) + * 7. Reject numeric identifiers with leading zeros per semver 2.0.0 §2 + * 8. Throw InconclusiveMatchException for invalid input (empty string, non-numeric parts, leading dot) * * @param mixed $value The semver string to parse * @return array{int, int, int} The parsed tuple [major, minor, patch] @@ -382,34 +400,16 @@ public static function parseSemver($value): array // Split on dots $parts = explode(".", $text); - // Parse major - if (!isset($parts[0]) || $parts[0] === "" || !ctype_digit(ltrim($parts[0], "0") ?: "0")) { - // Allow pure zeros or numeric strings - if (isset($parts[0]) && preg_match('/^[0-9]+$/', $parts[0])) { - $major = intval($parts[0]); - } else { - throw new InconclusiveMatchException("Cannot parse semver: invalid major version in {$value}"); - } - } else { - $major = intval($parts[0]); - } + $major = FeatureFlag::parseSemverNumeric($parts[0] ?? "", "major", $value); - // Parse minor (default to 0 if not present or empty) $minor = 0; if (isset($parts[1]) && $parts[1] !== "") { - if (!preg_match('/^[0-9]+$/', $parts[1])) { - throw new InconclusiveMatchException("Cannot parse semver: invalid minor version in {$value}"); - } - $minor = intval($parts[1]); + $minor = FeatureFlag::parseSemverNumeric($parts[1], "minor", $value); } - // Parse patch (default to 0 if not present or empty) $patch = 0; if (isset($parts[2]) && $parts[2] !== "") { - if (!preg_match('/^[0-9]+$/', $parts[2])) { - throw new InconclusiveMatchException("Cannot parse semver: invalid patch version in {$value}"); - } - $patch = intval($parts[2]); + $patch = FeatureFlag::parseSemverNumeric($parts[2], "patch", $value); } return [$major, $minor, $patch]; @@ -505,11 +505,7 @@ private static function wildcardBounds($value): array throw new InconclusiveMatchException("Cannot parse semver wildcard: no version components found in {$value}"); } - // Parse major - if (!preg_match('/^[0-9]+$/', $parts[0])) { - throw new InconclusiveMatchException("Cannot parse semver wildcard: invalid major version in {$value}"); - } - $major = intval($parts[0]); + $major = FeatureFlag::parseSemverNumeric($parts[0], "major", $value); if (count($parts) === 1) { // X.* pattern @@ -517,10 +513,7 @@ private static function wildcardBounds($value): array $upper = [$major + 1, 0, 0]; } else { // X.Y.* pattern - if (!preg_match('/^[0-9]+$/', $parts[1])) { - throw new InconclusiveMatchException("Cannot parse semver wildcard: invalid minor version in {$value}"); - } - $minor = intval($parts[1]); + $minor = FeatureFlag::parseSemverNumeric($parts[1], "minor", $value); $lower = [$major, $minor, 0]; $upper = [$major, $minor + 1, 0]; } diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index bdd9f12..676ede7 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -4074,11 +4074,22 @@ public function testParseSemverExtraParts(): void self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("1.2.3.4.5")); } - public function testParseSemverLeadingZeros(): void + public function testParseSemverRejectsLeadingZeros(): void { - // Leading zeros are parsed correctly - self::assertEquals([1, 2, 3], FeatureFlag::parseSemver("01.02.03")); - self::assertEquals([0, 0, 1], FeatureFlag::parseSemver("00.00.01")); + // Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. + foreach (["01.2.3", "1.02.3", "1.2.03", "01.02.03", "1.07.3", "001.2.3", "00.00.01"] as $bad) { + try { + FeatureFlag::parseSemver($bad); + self::fail("Expected InconclusiveMatchException for value: {$bad}"); + } catch (InconclusiveMatchException $e) { + // expected + } + } + + // Literal "0" components remain valid. + self::assertEquals([0, 1, 0], FeatureFlag::parseSemver("0.1.0")); + self::assertEquals([1, 0, 0], FeatureFlag::parseSemver("1.0.0")); + self::assertEquals([0, 0, 0], FeatureFlag::parseSemver("0.0.0")); } public function testParseSemverInvalidEmpty(): void @@ -4372,14 +4383,59 @@ public function testMatchPropertySemverPreReleaseSuffixesStripped(): void self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "1.2.3-alpha+build"])); } - public function testMatchPropertySemverLeadingZeros(): void + public function testMatchPropertySemverRejectsLeadingZeros(): void { - // Leading zeros are parsed correctly + // Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros. $prop = ["key" => "version", "value" => "1.2.3", "operator" => "semver_eq"]; - self::assertTrue(FeatureFlag::matchProperty($prop, ["version" => "01.02.03"])); - $prop2 = ["key" => "version", "value" => "01.02.03", "operator" => "semver_eq"]; - self::assertTrue(FeatureFlag::matchProperty($prop2, ["version" => "1.2.3"])); + foreach (["01.2.3", "1.02.3", "1.2.03", "01.02.03", "1.07.3", "001.2.3"] as $bad) { + try { + FeatureFlag::matchProperty($prop, ["version" => $bad]); + self::fail("Expected InconclusiveMatchException for override value: {$bad}"); + } catch (InconclusiveMatchException $e) { + // expected + } + } + + // Literal "0" components still match. + $propZeroMinor = ["key" => "version", "value" => "0.1.0", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($propZeroMinor, ["version" => "0.1.0"])); + + $propZeroPatch = ["key" => "version", "value" => "1.0.0", "operator" => "semver_eq"]; + self::assertTrue(FeatureFlag::matchProperty($propZeroPatch, ["version" => "1.0.0"])); + + // Flag values with leading zeros are also rejected across range operators. + $propGt = ["key" => "version", "value" => "01.2.3", "operator" => "semver_gt"]; + try { + FeatureFlag::matchProperty($propGt, ["version" => "2.0.0"]); + self::fail("Expected InconclusiveMatchException for semver_gt flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } + + $propCaret = ["key" => "version", "value" => "1.07.0", "operator" => "semver_caret"]; + try { + FeatureFlag::matchProperty($propCaret, ["version" => "1.2.0"]); + self::fail("Expected InconclusiveMatchException for semver_caret flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } + + $propTilde = ["key" => "version", "value" => "1.07.0", "operator" => "semver_tilde"]; + try { + FeatureFlag::matchProperty($propTilde, ["version" => "1.2.0"]); + self::fail("Expected InconclusiveMatchException for semver_tilde flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } + + $propWild = ["key" => "version", "value" => "01.*", "operator" => "semver_wildcard"]; + try { + FeatureFlag::matchProperty($propWild, ["version" => "1.2.0"]); + self::fail("Expected InconclusiveMatchException for semver_wildcard flag value"); + } catch (InconclusiveMatchException $e) { + // expected + } } public function testMatchPropertySemverPartialVersions(): void