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);