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