diff --git a/.sampo/changesets/stern-princess-ukko.md b/.sampo/changesets/stern-princess-ukko.md new file mode 100644 index 0000000..a727cd9 --- /dev/null +++ b/.sampo/changesets/stern-princess-ukko.md @@ -0,0 +1,5 @@ +--- +hex/posthog: patch +--- + +Fall back to remote feature flag evaluation for regex syntax and Unicode semantics that differ from the flags service, and preserve safe invalid-pattern handling. diff --git a/lib/posthog/feature_flags/local_evaluator.ex b/lib/posthog/feature_flags/local_evaluator.ex index 9b8f8d3..ff0867a 100644 --- a/lib/posthog/feature_flags/local_evaluator.ex +++ b/lib/posthog/feature_flags/local_evaluator.ex @@ -430,7 +430,16 @@ defmodule PostHog.FeatureFlags.LocalEvaluator do end) end - defp match_typed_property(property, property_values, definitions, context, cache) + defp match_typed_property( + property, + property_values, + definitions, + context, + cache, + cohort? \\ false + ) + + defp match_typed_property(property, property_values, definitions, context, cache, cohort?) when is_map(property) do {result, cache} = case value(property, "type") do @@ -449,10 +458,16 @@ defmodule PostHog.FeatureFlags.LocalEvaluator do ), cache} end - {apply_negation(result, value(property, "negation") == true), cache} + if result == :invalid_regex do + # A cohort may be negated again at any outer group/reference. Keep malformed leaves + # inconclusive there so an invalid search can never become a successful comparison. + {if(cohort?, do: :inconclusive, else: :no_match), cache} + else + {apply_negation(result, value(property, "negation") == true), cache} + end end - defp match_typed_property(_property, _values, _definitions, _context, cache), + defp match_typed_property(_property, _values, _definitions, _context, cache, _cohort?), do: {:inconclusive, cache} defp apply_negation(:match, true), do: :no_match @@ -585,7 +600,9 @@ defmodule PostHog.FeatureFlags.LocalEvaluator do |> match_property_group(properties, definitions, context, cache) |> apply_negation(value(entry, "negation") == true) else - {result, _cache} = match_typed_property(entry, properties, definitions, context, cache) + {result, _cache} = + match_typed_property(entry, properties, definitions, context, cache, true) + result end end) @@ -661,13 +678,15 @@ defmodule PostHog.FeatureFlags.LocalEvaluator do end defp apply_operator(operator, property, filter, _now) when operator in ["regex", "not_regex"] do - case Regex.compile(to_string(filter)) do - {:ok, regex} -> - matched = Regex.match?(regex, to_string(property)) + pattern = to_string(filter) + input = to_string(property) + + case regex_match(pattern, input) do + matched when is_boolean(matched) -> boolean_result(if(operator == "regex", do: matched, else: not matched)) - {:error, _reason} -> - :inconclusive + result -> + result end end @@ -700,6 +719,188 @@ defmodule PostHog.FeatureFlags.LocalEvaluator do defp apply_operator(_operator, _property, _filter, _now), do: :inconclusive + defp regex_match("", _input), do: true + + defp regex_match(pattern, input) do + # PCRE's Unicode tables and set syntax differ from the service. Keep the shared ASCII + # subset local, without changing POSIX classes by blindly enabling Unicode mode. + if portable_regex?(pattern, :outside, :start) do + case Regex.compile(pattern, [:dollar_endonly]) do + {:ok, regex} -> + # Make $ strict at the end of the input, rather than before a final LF. + if ascii_regex_input?(input) do + # Regex.match? hides match-limit errors as misses. + case :re.run(input, Regex.re_pattern(regex), [:report_errors, {:capture, :none}]) do + :match -> true + :nomatch -> false + {:error, _reason} -> :inconclusive + end + else + :inconclusive + end + + {:error, {reason, _offset}} -> + invalid_regex_result(reason) + + _ -> + :inconclusive + end + else + :inconclusive + end + end + + # Only proven shared syntax errors are definitive. Other compiler failures, including + # engine limits, remain inconclusive. PCRE1 and PCRE2 spell some syntax errors differently. + defp invalid_regex_result(reason) do + if to_string(reason) in [ + "missing terminating ] for character class", + "missing closing parenthesis", + "missing )", + "unmatched closing parenthesis", + "unmatched parentheses", + "quantifier does not follow a repeatable item", + "nothing to repeat", + "range out of order in character class", + "numbers out of order in {} quantifier" + ], + do: :invalid_regex, + else: :inconclusive + end + + defp ascii_regex_input?(""), do: true + + defp ascii_regex_input?(<>) when byte < 128, + do: ascii_regex_input?(rest) + + defp ascii_regex_input?(<<_byte, _rest::binary>>), do: false + + defp portable_regex?("", _class, _previous), do: true + + defp portable_regex?(<>, _class, _previous) + when character > 127, + do: false + + defp portable_regex?(<<"\\x", a, b, rest::binary>>, class, _previous) + when a in ?0..?9 or a in ?a..?f or a in ?A..?F do + if b in ?0..?9 or b in ?a..?f or b in ?A..?F, + do: portable_regex?(rest, class_content(class), :literal), + else: false + end + + defp portable_regex?(<<"\\", escaped, rest::binary>>, class, _previous) do + cond do + # Even short ASCII word-boundary searches can exceed the service backtracking budget. + class == :outside and escaped in ~c"bB" -> + false + + escaped in ~c"dDsSwW" -> + portable_regex?(rest, class_content(class), :shorthand) + + # PCRE's \v includes LF/CR/FF, whereas the service matches only vertical tab. + escaped in ~c"fnrt" or (class != :outside and escaped == ?b) -> + portable_regex?(rest, class_content(class), :literal) + + class != :outside and escaped in ~c"\\.^$|?*+()[]{}-" -> + portable_regex?(rest, :inside, :literal) + + class == :outside and + (escaped in 32..47 or escaped in ~c":;=?@" or escaped in 91..96 or + escaped in 123..126) -> + portable_regex?(rest, class, :literal) + + true -> + false + end + end + + defp portable_regex?(<<"[", rest::binary>>, :outside, _previous), + do: portable_regex?(rest, :class_start, :start) + + defp portable_regex?(<<"^", rest::binary>>, :class_start, _previous), + do: portable_regex?(rest, :negated_start, :start) + + defp portable_regex?(<<"]", _rest::binary>>, class, _previous) + when class in [:class_start, :negated_start], + do: false + + defp portable_regex?(<<"]", rest::binary>>, :inside, _previous), + do: portable_regex?(rest, :outside, :literal) + + defp portable_regex?(<>, class, previous) when class != :outside do + cond do + character == ?[ -> false + character in ~c"&-~" and String.starts_with?(rest, <>) -> false + character == ?- and previous == :shorthand and not String.starts_with?(rest, "]") -> false + character == ?- and shorthand_start?(rest) -> false + true -> portable_regex?(rest, :inside, :literal) + end + end + + defp portable_regex?(<<"(?:", rest::binary>>, :outside, _previous), + do: portable_regex?(rest, :outside, :open) + + defp portable_regex?(<<"(?", _rest::binary>>, :outside, _previous), do: false + + defp portable_regex?(<<"{", _rest::binary>> = pattern, :outside, previous) do + case Regex.run(~r/^\{([0-9]+)(?:,([0-9]*))?\}/, pattern) do + [repetition | counts] + when previous not in [:start, :open, :alternation, :assertion, :empty, :repeat] -> + if Enum.all?(counts, &(&1 == "" or String.to_integer(&1) <= 65_535)) do + rest = + binary_part( + pattern, + byte_size(repetition), + byte_size(pattern) - byte_size(repetition) + ) + + not String.starts_with?(rest, "+") and portable_regex?(rest, :outside, :repeat) + else + false + end + + _ -> + false + end + end + + defp portable_regex?(<>, :outside, previous) do + cond do + character in ~c"*+?" and previous in [:start, :open, :alternation, :assertion, :empty] -> + false + + character in ~c"*+?" and String.starts_with?(rest, ["+", "{"]) -> + false + + character in ~c"*+?" -> + portable_regex?(rest, :outside, :repeat) + + character in ~c"^$" -> + portable_regex?(rest, :outside, :assertion) + + character == ?( -> + portable_regex?(rest, :outside, :open) + + character == ?) and previous in [:open, :empty] -> + portable_regex?(rest, :outside, :empty) + + character == ?| -> + portable_regex?(rest, :outside, :alternation) + + character == ?} -> + false + + true -> + portable_regex?(rest, :outside, :literal) + end + end + + defp class_content(:outside), do: :outside + defp class_content(_class), do: :inside + + defp shorthand_start?(<<"\\", character, _rest::binary>>), do: character in ~c"dDsSwW" + defp shorthand_start?(_rest), do: false + defp matching_value(flag, condition, bucketing) do variants = variants(flag) override = value(condition, "variant") diff --git a/test/posthog/feature_flags/local_evaluation_integration_test.exs b/test/posthog/feature_flags/local_evaluation_integration_test.exs index 9edd92d..1b3eb69 100644 --- a/test/posthog/feature_flags/local_evaluation_integration_test.exs +++ b/test/posthog/feature_flags/local_evaluation_integration_test.exs @@ -50,6 +50,184 @@ defmodule PostHog.FeatureFlags.LocalEvaluationIntegrationTest do start_supervised!({PostHog.Supervisor, config}) end + for operator <- ["regex", "not_regex"] do + @verb_operator operator + test "#{operator} PCRE verb reaches remote fallback and stays unresolved locally" do + condition = %{"key" => "prop", "operator" => @verb_operator, "value" => "(*FAIL)"} + + expect(PostHog.API.Mock, :request, fn :stub_client, :get, "/flags/definitions", _opts -> + {:ok, %{status: 200, body: envelope([flag("verb", [condition])]), headers: %{}}} + end) + + name = __MODULE__.RegexVerb + start_instance(name) + context = %{distinct_id: "user", person_properties: %{prop: "a"}} + + assert {:ok, local_only} = + FeatureFlags.evaluate_flags(name, Map.put(context, :only_evaluate_locally, true)) + + assert Evaluations.keys(local_only) == [] + + for properties <- [context.person_properties, %{}] do + expect(PostHog.API.Mock, :request, fn :stub_client, :post, "/flags", opts -> + assert opts[:json].person_properties == properties + {:ok, %{status: 200, body: %{"flags" => %{"verb" => %{"enabled" => false}}}}} + end) + + assert {:ok, remote} = + FeatureFlags.evaluate_flags(name, %{context | person_properties: properties}) + + refute remote.flags["verb"].enabled + refute remote.flags["verb"].locally_evaluated + end + end + + @execution_operator operator + test "#{operator} match-limit failure falls back without losing invalid local results" do + condition = %{ + "key" => "prop", + "operator" => @execution_operator, + "value" => "^(a+)+$|^a+\\n$" + } + + invalid = %{condition | "value" => "["} + + expect(PostHog.API.Mock, :request, fn :stub_client, :get, "/flags/definitions", _opts -> + {:ok, + %{ + status: 200, + body: envelope([flag("execution", [condition]), flag("invalid", [invalid])]), + headers: %{} + }} + end) + + name = __MODULE__.RegexExecutionError + start_instance(name) + + context = %{ + distinct_id: "user", + person_properties: %{prop: String.duplicate("a", 30) <> "\n"} + } + + assert {:ok, local_only} = + FeatureFlags.evaluate_flags(name, Map.put(context, :only_evaluate_locally, true)) + + assert Evaluations.keys(local_only) == ["invalid"] + refute local_only.flags["invalid"].enabled + assert local_only.flags["invalid"].locally_evaluated + + expected = @execution_operator == "regex" + + for properties <- [context.person_properties, %{}] do + expect(PostHog.API.Mock, :request, fn :stub_client, :post, "/flags", opts -> + assert opts[:json].person_properties == properties + {:ok, %{status: 200, body: %{"flags" => %{"execution" => %{"enabled" => expected}}}}} + end) + + assert {:ok, remote} = + FeatureFlags.evaluate_flags(name, %{context | person_properties: properties}) + + assert remote.flags["execution"].enabled == expected + refute remote.flags["execution"].locally_evaluated + end + + # A short matching alternative and an ordinary miss need no remote request. + for {input, matches?} <- [{"a\n", true}, {"b", false}] do + assert {:ok, local} = + FeatureFlags.evaluate_flags(name, %{context | person_properties: %{prop: input}}) + + expected = if @execution_operator == "regex", do: matches?, else: not matches? + assert local.flags["execution"].enabled == expected + assert local.flags["execution"].locally_evaluated + refute local.flags["invalid"].enabled + assert local.flags["invalid"].locally_evaluated + end + end + + for pattern <- ["\\v", "^\\v$", "[\\v]", "^[\\v]$"] do + @vertical_pattern pattern + @vertical_operator operator + test "#{operator} vertical escape #{inspect(pattern)} reaches remote fallback" do + condition = %{ + "key" => "prop", + "operator" => @vertical_operator, + "value" => @vertical_pattern + } + + expect(PostHog.API.Mock, :request, fn :stub_client, :get, "/flags/definitions", _opts -> + {:ok, %{status: 200, body: envelope([flag("vertical", [condition])]), headers: %{}}} + end) + + name = __MODULE__.VerticalEscape + start_instance(name) + context = %{distinct_id: "user", person_properties: %{prop: "\n"}} + + assert {:ok, local_only} = + FeatureFlags.evaluate_flags(name, Map.put(context, :only_evaluate_locally, true)) + + assert Evaluations.keys(local_only) == [] + + # Missing context independently verifies the HTTP interception for this flag. + for properties <- [%{prop: "\n"}, %{}] do + expected = @vertical_operator == "not_regex" + + expect(PostHog.API.Mock, :request, fn :stub_client, :post, "/flags", opts -> + assert opts[:json].person_properties == properties + {:ok, %{status: 200, body: %{"flags" => %{"vertical" => %{"enabled" => expected}}}}} + end) + + assert {:ok, remote} = + FeatureFlags.evaluate_flags(name, %{context | person_properties: properties}) + + assert remote.flags["vertical"].enabled == expected + refute remote.flags["vertical"].locally_evaluated + end + end + end + + @regex_operator operator + test "#{operator} compatibility gates remote fallback and local-only snapshots" do + condition = %{"key" => "prop", "operator" => @regex_operator, "value" => "^\\w+$"} + + expect(PostHog.API.Mock, :request, fn :stub_client, :get, "/flags/definitions", _opts -> + {:ok, %{status: 200, body: envelope([flag("regex", [condition])]), headers: %{}}} + end) + + name = __MODULE__.RegexCompatibility + start_instance(name) + context = %{distinct_id: "user", person_properties: %{prop: "é"}} + + assert {:ok, local_only} = + FeatureFlags.evaluate_flags(name, Map.put(context, :only_evaluate_locally, true)) + + assert Evaluations.keys(local_only) == [] + + # Missing context is an independent positive control for the same HTTP interception. + for properties <- [%{prop: "é"}, %{}] do + expect(PostHog.API.Mock, :request, fn :stub_client, :post, "/flags", opts -> + assert opts[:json].person_properties == properties + {:ok, %{status: 200, body: %{"flags" => %{"regex" => %{"enabled" => true}}}}} + end) + + assert {:ok, remote} = + FeatureFlags.evaluate_flags(name, %{context | person_properties: properties}) + + assert remote.flags["regex"].enabled + refute remote.flags["regex"].locally_evaluated + end + + # No /flags expectation: a shared ASCII search must not fall back. + assert {:ok, local} = + FeatureFlags.evaluate_flags(name, %{ + context + | person_properties: %{prop: "abc_123"} + }) + + assert local.flags["regex"].enabled == (@regex_operator == "regex") + assert local.flags["regex"].locally_evaluated + end + end + test "HTTP version-only refreshes replace matching atomically and preserve frozen results" do definitions = envelope([flag("versioned", [%{"key" => "prop", "value" => false}])]) diff --git a/test/posthog/feature_flags/local_evaluator_test.exs b/test/posthog/feature_flags/local_evaluator_test.exs index 500c02c..892484c 100644 --- a/test/posthog/feature_flags/local_evaluator_test.exs +++ b/test/posthog/feature_flags/local_evaluator_test.exs @@ -754,7 +754,7 @@ defmodule PostHog.FeatureFlags.LocalEvaluatorTest do for {property, actual} <- [ {%{"key" => "x", "operator" => "unknown", "value" => 1}, 1}, - {%{"key" => "x", "operator" => "regex", "value" => "["}, "hello123"}, + {%{"key" => "x", "operator" => "regex", "value" => "(?<=a+)b"}, "aab"}, {%{"key" => "x", "operator" => "is_date_before", "value" => "never"}, "2025-01-01"}, {%{"key" => "x", "operator" => "semver_eq", "value" => "01.2.3"}, "1.2.3"} ] do diff --git a/test/posthog/feature_flags/regex_compatibility_test.exs b/test/posthog/feature_flags/regex_compatibility_test.exs new file mode 100644 index 0000000..05af4b7 --- /dev/null +++ b/test/posthog/feature_flags/regex_compatibility_test.exs @@ -0,0 +1,334 @@ +defmodule PostHog.FeatureFlags.RegexCompatibilityTest do + use ExUnit.Case, async: true + + alias PostHog.FeatureFlags.LocalEvaluator + alias PostHog.FeatureFlags.Result + + defp evaluate(pattern, input, operator, options \\ []) do + property = %{"key" => "prop", "operator" => operator, "value" => pattern} + + property = Map.put(property, "negation", options[:negated?] == true) + + reference = %{ + "type" => "cohort", + "value" => "1", + "operator" => if(options[:not_in?], do: "not_in", else: "in"), + "negation" => options[:reference_negated?] == true + } + + group = %{"type" => "AND", "values" => [property]} + + group = + if options[:group_negated?], + do: %{"type" => "AND", "values" => [Map.put(group, "negation", true)]}, + else: group + + flag = %{ + "id" => 1, + "key" => "regex", + "active" => true, + "filters" => %{ + "groups" => [ + %{ + "properties" => if(options[:direct?], do: [property], else: [reference]), + "rollout_percentage" => 100 + } + ] + } + } + + definitions = %{ + flags: [flag], + flags_by_key: %{"regex" => flag}, + group_type_mapping: %{}, + cohorts: %{"1" => group}, + minimal_flag_called_events: false + } + + LocalEvaluator.evaluate(definitions, %{distinct_id: "user", person_properties: input}) + end + + # These are service-checked probes, not expectations derived from PCRE. + for operator <- ["regex", "not_regex"] do + @operator operator + for {name, pattern, input} <- [ + {"Unicode digits", "^\\d+$", "١"}, + {"astral digits", "^\\d+$", "𝟙"}, + {"Unicode word", "^\\w+$", "é"}, + {"Unicode whitespace", "^\\s$", "\u00a0"}, + {"Unicode property", "^\\p{L}+$", "é"}, + {"Unicode property ASCII", "^\\p{L}+$", "abc"}, + {"astral scalar", "^.$", "😀"}, + {"Unicode pattern", "^😀?$", ""}, + {"inline flags", "(?i)^null$", "NULL"}, + {"scoped flags", "(?i:^null$)", "NULL"}, + {"lookahead", "a(?=b)", "ab"}, + {"variable lookbehind", "(?<=a+)b", "aab"}, + {"backreference", "(a)\\1", "aa"}, + {"POSIX class", "^[[:alpha:]]+$", "abc"}, + {"nested intersection", "^[a-z&&[^aeiou]]+$", "bcdf"}, + {"flat intersection", "^[a&&b]$", "a"}, + {"difference", "^[a-z--b]$", "b"}, + {"symmetric difference", "^[a~~b]$", "~"}, + {"empty class", "[]", "a"}, + {"negated empty class", "[^]", "a"}, + {"octal", "\\141", "a"}, + {"identity escape", "\\q", "q"}, + {"hex scalar", "^\\x{41}$", "A"}, + {"absolute anchor", "\\Afoo\\z", "foo"}, + {"possessive repetition", "a++", "aa"}, + {"bounded possessive repetition", "a{2}+", "aa"}, + {"open repetition", "a{,2}", "aa"}, + {"stacked repetition", "a*{2}", "aa"}, + {"literal braces", "{foo}", "{foo}"}, + {"shorthand range start", "[\\s-9]", "-"}, + {"shorthand range end", "[a-\\d]", "-"}, + {"quantified start", "^?https?://", "https://a"}, + {"quantified end", "$?", "x"}, + {"leading repetition", "{2}", "x"}, + {"alternation repetition", "a|{2}", "x"}, + {"group repetition", "({2})", "x"}, + {"noncapturing repetition", "(?:{2})", "x"}, + {"empty group repetition", "()+", "x"}, + {"empty noncapturing repetition", "(?:)*", "x"}, + {"empty bounded repetition", "(?:){2}", "x"}, + {"oversized repetition", "a{0,4294967296}", "x"}, + {"word boundary budget", ".*\\bbot\\b.*", String.duplicate("a", 140)}, + {"nonword boundary budget", ".*\\Bbot\\B.*", String.duplicate("a", 140)}, + {"Rust word edges", "\\", "foo"}, + {"class punctuation", "[\\@]", "@"}, + {"class nonword boundary", "[\\B]", "B"} + ] do + @pattern pattern + @input input + test "#{operator} defers #{name}, including cohort negation" do + for negated? <- [false, true] do + result = evaluate(@pattern, %{"prop" => @input}, @operator, negated?: negated?) + assert result.results == %{} + assert result.unresolved == MapSet.new(["regex"]) + end + end + end + + for pattern <- [ + "(*FAIL)", + "(*ACCEPT)", + "(*COMMIT)", + "(*LIMIT_MATCH=1)a", + "*", + "?*", + "*a", + "+a", + "?a", + "a|*b", + "a|+b", + "a|?b", + "(*a)", + "(+a)", + "(?:*a)", + "(?:+a)", + "(?:?a)" + ] do + @pattern pattern + test "#{operator} defers nonportable quantifier #{inspect(pattern)} through negation" do + for options <- [ + [direct?: true], + [direct?: true, negated?: true], + [], + [negated?: true], + [reference_negated?: true], + [group_negated?: true], + [not_in?: true] + ] do + result = evaluate(@pattern, %{"prop" => "a"}, @operator, options) + assert result.results == %{} + assert result.unresolved == MapSet.new(["regex"]) + end + end + end + + for pattern <- ["\\v", "^\\v$", "[\\v]", "^[\\v]$"], + {name, input} <- [{"LF", "\n"}, {"CR", "\r"}, {"FF", "\f"}, {"VT", "\u000b"}] do + @pattern pattern + @input input + test "#{operator} defers vertical escape #{inspect(pattern)} on #{name}" do + for options <- [[direct?: true], [], [negated?: true], [not_in?: true]] do + result = evaluate(@pattern, %{"prop" => @input}, @operator, options) + assert result.results == %{} + assert result.unresolved == MapSet.new(["regex"]) + end + end + end + + test "#{operator} keeps match-limit errors inconclusive through negation" do + pattern = "^(a+)+$|^a+\\n$" + input = String.duplicate("a", 30) <> "\n" + {:ok, compiled} = Regex.compile(pattern, [:dollar_endonly]) + + assert {:error, :match_limit} = + :re.run(input, Regex.re_pattern(compiled), [:report_errors, {:capture, :none}]) + + for options <- [ + [direct?: true], + [direct?: true, negated?: true], + [], + [negated?: true], + [reference_negated?: true], + [group_negated?: true], + [not_in?: true] + ] do + result = evaluate(pattern, %{"prop" => input}, @operator, options) + assert result.results == %{} + assert result.unresolved == MapSet.new(["regex"]) + end + end + + test "#{operator} keeps literal and hexadecimal vertical tab local" do + for pattern <- [ + "\u000b", + "^\u000b$", + "[\u000b]", + "^[\u000b]$", + "\\x0b", + "^\\x0b$", + "[\\x0b]", + "^[\\x0b]$" + ], + input <- ["\n", "\r", "\f", "\u000b"] do + result = evaluate(pattern, %{"prop" => input}, @operator) + matches? = input == "\u000b" + expected = if @operator == "regex", do: matches?, else: not matches? + assert %Result{enabled: ^expected, locally_evaluated: true} = result.results["regex"] + assert MapSet.size(result.unresolved) == 0 + end + end + + test "#{operator} keeps empty and full-range ASCII input local" do + for {input, matches?} <- [ + {"", false}, + {<<0>>, false}, + {<<127>>, false}, + {:binary.list_to_bin(Enum.to_list(0..127)), true}, + {String.duplicate("a", 65_536), true} + ] do + result = evaluate("a", %{"prop" => input}, @operator, direct?: true) + expected = if @operator == "regex", do: matches?, else: not matches? + assert %Result{enabled: ^expected, locally_evaluated: true} = result.results["regex"] + assert MapSet.size(result.unresolved) == 0 + end + end + + test "#{operator} preserves fallback and special patterns for non-ASCII binary edges" do + ascii = String.duplicate("a", 65_536) + + for input <- [<<128>> <> ascii, ascii <> <<255>>, "é" <> ascii, ascii <> "é"] do + result = evaluate("a", %{"prop" => input}, @operator, direct?: true) + assert result.results == %{} + assert result.unresolved == MapSet.new(["regex"]) + + empty = evaluate("", %{"prop" => input}, @operator, direct?: true) + expected = @operator == "regex" + assert %Result{enabled: ^expected} = empty.results["regex"] + assert MapSet.size(empty.unresolved) == 0 + + invalid = evaluate("[", %{"prop" => input}, @operator, direct?: true) + assert %Result{enabled: false} = invalid.results["regex"] + assert MapSet.size(invalid.unresolved) == 0 + end + end + + test "#{operator} preserves fallback for property stringification failures" do + result = evaluate("a", %{"prop" => %{}}, @operator, direct?: true) + assert result.results == %{} + assert result.unresolved == MapSet.new(["regex"]) + end + + test "#{operator} keeps shared searches local" do + for {pattern, input, matches?} <- [ + {"pro", "a pro plan", true}, + {"^pro$", "a pro plan", false}, + {"^pro$", "PRO", false}, + {"^\\d+$", "123", true}, + {"^[\\w.+-]+@example\\.com$", "a.b+tag@example.com", true}, + {"^(?:ab|cd){2,3}$", "abcdab", true}, + {"^a*$", "", true}, + {"^a+$", "aa", true}, + {"^a?$", "", true}, + {"^(a)+$", "aa", true}, + {"^(?:a)*$", "aa", true}, + {"^a+?$", "aa", true}, + {"^\\\\p\\{L\\}$", "\\p{L}", true}, + {"^\\(\\?i\\)$", "(?i)", true}, + {"^\\x41+$", "AA", true}, + {"^v$", "v", true}, + {"^\\\\v$", "\\v", true}, + {"[\\b]", "\b", true}, + {"^a\\-b$", "a-b", true}, + {"\\@example\\.com$", "a@example.com", true}, + {"^a\\:b$", "a:b", true}, + {"^a\\_b$", "a_b", true}, + {"^a\\=b$", "a=b", true}, + {"^a\\ b$", "a b", true}, + {"^\\^?$", "", true}, + {"^\\${2}$", "$$", true}, + {"^\\({2}$", "((", true}, + {"^\\|{2}$", "||", true}, + {"^[\\^$?]+$", "$?^", true}, + {"^[\\w-]+$", "a-b", true}, + {"", "", true}, + {"", "é", true}, + {"", "\r", true}, + {"^foo$", "foo\n", false}, + {"^foo$", "a\nfoo\nb", false}, + {"^.$", "\r", true} + ] do + result = evaluate(pattern, %{"prop" => input}, @operator) + expected = if @operator == "regex", do: matches?, else: not matches? + + assert %Result{enabled: ^expected, locally_evaluated: true} = result.results["regex"], + inspect({pattern, input, result}) + + assert MapSet.size(result.unresolved) == 0 + end + end + + test "#{operator} does not negate proven invalid syntax" do + for pattern <- ["[", "(", "[abc", "(abc", "abc)", "[z-a]", "a{2,1}"], + input <- ["value", "é", "\r"], + negated? <- [false, true] do + result = + evaluate(pattern, %{"prop" => input}, @operator, direct?: true, negated?: negated?) + + assert %Result{enabled: false, locally_evaluated: true} = result.results["regex"] + assert MapSet.size(result.unresolved) == 0 + end + end + + test "#{operator} keeps invalid searches inconclusive through outer cohort negation" do + for pattern <- ["[", "(", "*", "?*"], + options <- [ + [], + [negated?: true], + [reference_negated?: true], + [not_in?: true], + [group_negated?: true], + [negated?: true, not_in?: true] + ] do + result = evaluate(pattern, %{"prop" => "value"}, @operator, options) + assert result.results == %{} + assert result.unresolved == MapSet.new(["regex"]) + end + end + + test "#{operator} still negates valid cohort comparisons" do + result = evaluate("^abc$", %{"prop" => "abc"}, @operator, negated?: true) + expected = @operator == "not_regex" + assert %Result{enabled: ^expected} = result.results["regex"] + end + + test "#{operator} keeps a missing property unresolved and explicit nil unchanged" do + assert evaluate("", %{}, @operator).unresolved == MapSet.new(["regex"]) + assert %Result{enabled: false} = evaluate("", %{"prop" => nil}, @operator).results["regex"] + end + end +end