From d136d1198cb2a58c5722755ce63ff743836cfa72 Mon Sep 17 00:00:00 2001 From: quotentiroler Date: Thu, 15 Oct 2026 19:17:11 +0200 Subject: [PATCH] fix(flags): match regex filters like the flags service The regex branch treated the filter value as a PHP delimited pattern when it happened to compile as one, and otherwise wrapped it in "/" without escaping. Patterns with an unescaped slash (for example ^https://example\.com/) failed to compile and always returned false, a pattern like [abc] used the brackets as delimiters and lost its character class, and a value like /abc/i got PHP flags instead of being matched literally. The flags service and the other SDKs compile the value as-is. The property value was passed to preg_match without stringification: null short-circuited to false for both operators, booleans became "1", and an array property raised a TypeError that escapes getFeatureFlag because Client only catches Exception. Now the pattern and the property are stringified with valueToString, the pattern is wrapped in slashes with any unescaped slash escaped, and it runs in Unicode mode like the flags service. Invalid patterns still return false for both operators, and a preg_match execution failure raises InconclusiveMatchException so not_regex can no longer match on an error. These follow the regex requirement in PostHog/sdk-specs local-feature-flag-evaluator and match rust/feature-flags property_matching.rs. testMatchPropertyWithNones expected regex "null" against a null property to be false; the flags service stringifies null to "null", so it is now true. --- .../regex-filters-match-flags-service.md | 5 + lib/FeatureFlag.php | 41 ++----- test/FeatureFlagLocalEvaluationTest.php | 116 +++++++++++++++++- 3 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 .changeset/regex-filters-match-flags-service.md diff --git a/.changeset/regex-filters-match-flags-service.md b/.changeset/regex-filters-match-flags-service.md new file mode 100644 index 0000000..51e836f --- /dev/null +++ b/.changeset/regex-filters-match-flags-service.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Match `regex` and `not_regex` flag filters locally the way the flags service does. The filter value is now always used as the pattern itself, so patterns containing `/` (such as `^https://example\.com/`) or starting with a bracket (such as `[abc]`) no longer evaluate to `false`, and slash-wrapped values like `/abc/i` are matched literally. Property values are stringified like the other string operators (`null`, booleans, floats, and arrays included), so an array property no longer throws a `TypeError` out of `getFeatureFlag`. Patterns run in Unicode mode, and a regex execution failure is now inconclusive instead of silently making `not_regex` match. diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 6d13da3..18d1ea7 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -75,20 +75,17 @@ public static function matchProperty($property, $propertyValues, $propertyMatchi } if (in_array($operator, ["regex", "not_regex"])) { - $regexValue = FeatureFlag::prepareValueForRegex($value); - if (FeatureFlag::isRegularExpression($regexValue)) { - if ($overrideValue === null) { - return false; - } - $returnValue = preg_match($regexValue, $overrideValue) ? true : false; - if ($operator == "regex") { - return $returnValue; - } else { - return !$returnValue; - } - } else { + $regexValue = FeatureFlag::prepareValueForRegex(FeatureFlag::valueToString($value)); + if (!FeatureFlag::isRegularExpression($regexValue)) { return false; } + + $matched = preg_match($regexValue, FeatureFlag::valueToString($overrideValue)); + if ($matched === false) { + throw new InconclusiveMatchException("Regex execution failed: " . preg_last_error_msg()); + } + + return ($matched === 1) === ($operator == "regex"); } if (in_array($operator, ["gt", "gte", "lt", "lte"])) { @@ -1073,24 +1070,12 @@ private static function isRegularExpression($string) return $isRegularExpression; } - private static function prepareValueForRegex($value) + private static function prepareValueForRegex(string $pattern): string { - $regex = $value; - - // If delimiter already exists, do nothing - if (FeatureFlag::isRegularExpression($regex)) { - return $regex; - } - - if (substr($regex, 0, 1) != "/") { - $regex = "/" . $regex; - } - - if (substr($regex, -1) != "/") { - $regex = $regex . "/"; - } + // Escape each delimiter slash that is not already escaped by an odd run of backslashes. + $escaped = preg_replace('~(? "key", + "value" => $pattern, + "operator" => $operator, + ]; + + self::assertSame($expected, FeatureFlag::matchProperty($prop, [ + "key" => $propertyValue, + ])); + } + + public static function regexLikeFlagsServiceProvider(): array + { + return [ + 'unescaped slash' => ["regex", "^https://example\\.com/", "https://example.com/pricing", true], + 'unescaped slash negated' => ["not_regex", "^https://example\\.com/", "https://example.com/pricing", false], + 'slash mid pattern' => ["regex", "a/b", "a/b", true], + 'escaped slash' => ["regex", "a\\/b", "a/b", true], + 'escaped backslash before slash' => ["regex", "a\\\\/b", "a\\/b", true], + 'bracket class' => ["regex", "[abc]", "b", true], + 'bracket class negated' => ["not_regex", "[abc]", "b", false], + 'parenthesized alternation' => ["regex", "(foo|bar)", "xbarx", true], + 'slash-wrapped pattern is literal' => ["regex", "/abc/i", "ABC", false], + 'slash-wrapped pattern matches literally' => ["regex", "/abc/i", "x/abc/iy", true], + 'empty pattern' => ["regex", "", "admin@example.com", true], + 'empty pattern negated' => ["not_regex", "", "admin@example.com", false], + 'invalid pattern' => ["regex", "[", "admin@example.com", false], + 'invalid pattern negated' => ["not_regex", "[", "admin@example.com", false], + 'case sensitive' => ["regex", "EXAMPLE", "admin@example.com", false], + 'inline case flag' => ["regex", "(?i)EXAMPLE", "admin@example.com", true], + 'unicode word' => ["regex", "^\\w+$", "héllo", true], + 'unicode dot' => ["regex", "^.$", "é", true], + 'numeric pattern' => ["regex", 12, 123, true], + 'boolean property' => ["regex", "^true$", true, true], + 'float property' => ["regex", "^1\\.0$", 1.0, true], + 'array property' => ["regex", "\\[1,2\\]", [1, 2], true], + 'null property' => ["regex", "^null$", null, true], + 'null property negated' => ["not_regex", "x", null, true], + ]; + } + + public function testMatchPropertyRegexExecutionFailureIsInconclusive(): void + { + foreach (["regex", "not_regex"] as $operator) { + try { + FeatureFlag::matchProperty([ + "key" => "key", + "value" => "x", + "operator" => $operator, + ], [ + "key" => "\xff", + ]); + self::fail("Expected {$operator} to be inconclusive"); + } catch (InconclusiveMatchException $exception) { + self::assertInstanceOf(InconclusiveMatchException::class, $exception); + } + } + } + public function testMatchPropertyMathOperators(): void { $prop = [ @@ -1364,7 +1430,7 @@ public function testMatchPropertyWithNones(): void "value" => "null", "operator" => "regex" ]; - self::assertFalse(FeatureFlag::matchProperty($prop_c, [ + self::assertTrue(FeatureFlag::matchProperty($prop_c, [ "key" => null, ])); self::assertTrue(FeatureFlag::matchProperty($prop_c, [ @@ -1596,6 +1662,54 @@ public function testFlagDistinctIdPropertyIsAvailableForLocalEvaluationOnly() $this->checkEmptyErrorLogs(); } + public function testRegexFlagEvaluatesLocallyLikeFlagsService() + { + $localEvaluationResponse = MockedResponses::LOCAL_EVALUATION_SIMPLE_REQUEST; + $localEvaluationResponse['flags'][0]['key'] = 'regex-flag'; + $localEvaluationResponse['flags'][0]['filters']['groups'][0]['properties'] = [ + [ + 'key' => 'landing_page', + 'operator' => 'regex', + 'value' => '^https://example\\.com/pricing', + 'type' => 'person', + ], + ]; + + $this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: $localEvaluationResponse); + $this->client = new Client( + self::FAKE_API_KEY, + [ + "debug" => true, + ], + $this->http_client, + "test" + ); + PostHog::init(null, null, $this->client); + $this->http_client->calls = array(); + + $this->assertTrue(PostHog::getFeatureFlag( + 'regex-flag', + 'some-distinct-id', + [], + ['landing_page' => 'https://example.com/pricing?plan=team'], + [], + true, + false + )); + $this->assertFalse(PostHog::getFeatureFlag( + 'regex-flag', + 'some-distinct-id', + [], + ['landing_page' => ['https://example.com/blog']], + [], + true, + false + )); + $this->assertEquals(array(), $this->http_client->calls); + + $this->checkEmptyErrorLogs(); + } + public function testFlagPersonBooleanProperties() { $this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_BOOLEAN_REQUEST);