Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/case-folding-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"PostHog": patch
"PostHog.AspNetCore": patch
---

Match local feature flag string operators using the same ASCII and Unicode lowercase rules as the flags service.
131 changes: 131 additions & 0 deletions bin/generate-unicode-lowercase-data
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Generate the Unicode property ranges needed by the Final_Sigma rule."""

from __future__ import annotations

import hashlib
import urllib.request
from pathlib import Path

UNICODE_VERSION = "17.0.0"
FILES = {
"DerivedCoreProperties.txt": "24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08",
"SpecialCasing.txt": "efc25faf19de21b92c1194c111c932e03d2a5eaf18194e33f1156e96de4c9588",
}
OUTPUT = Path(__file__).resolve().parents[1] / "src/PostHog/Json/UnicodeSpecialCasingData.cs"
EXPECTED_RANGE_COUNTS = {"Cased": 175, "Case_Ignorable": 518}


def download(filename: str) -> str:
url = f"https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/{filename}"
contents = urllib.request.urlopen(url).read()
digest = hashlib.sha256(contents).hexdigest()
if digest != FILES[filename]:
raise RuntimeError(f"Unexpected SHA-256 for {url}: {digest}")
return contents.decode("utf-8")


def property_ranges(contents: str, property_name: str) -> list[tuple[int, int]]:
ranges: list[tuple[int, int]] = []
for line in contents.splitlines():
data = line.split("#", 1)[0].strip()
if not data:
continue
fields = [field.strip() for field in data.split(";")]
if len(fields) < 2 or fields[1] != property_name:
continue
bounds = fields[0].split("..")
start = int(bounds[0], 16)
end = int(bounds[-1], 16)
ranges.append((start, end))
return ranges


def validate_ranges(property_name: str, ranges: list[tuple[int, int]]) -> None:
if len(ranges) != EXPECTED_RANGE_COUNTS[property_name]:
raise RuntimeError(f"Unexpected {property_name} range count: {len(ranges)}")
for index, (start, end) in enumerate(ranges):
if start > end or (index > 0 and start <= ranges[index - 1][1]):
raise RuntimeError(f"Invalid {property_name} range at index {index}: {(start, end)}")


def format_ranges(ranges: list[tuple[int, int]]) -> str:
values = [value for bounds in ranges for value in bounds]
lines = []
for index in range(0, len(values), 8):
lines.append(" " + ", ".join(f"0x{value:04X}" for value in values[index:index + 8]) + ",")
return "\n".join(lines)


def main() -> None:
derived = download("DerivedCoreProperties.txt")
special = download("SpecialCasing.txt")
if "0130; 0069 0307; 0130; 0130;" not in special:
raise RuntimeError("Unicode dotted-I lowercase mapping was not found")
if "03A3; 03C2; 03A3; 03A3; Final_Sigma;" not in special:
raise RuntimeError("Unicode Final_Sigma lowercase mapping was not found")

cased = property_ranges(derived, "Cased")
case_ignorable = property_ranges(derived, "Case_Ignorable")
validate_ranges("Cased", cased)
validate_ranges("Case_Ignorable", case_ignorable)
source = f'''// <auto-generated />
// Unicode {UNICODE_VERSION} data generated by bin/generate-unicode-lowercase-data.
// 漏 2025 Unicode, Inc.
// For terms of use and license, see https://www.unicode.org/terms_of_use.html
// Unicode Data Files and Software License: https://www.unicode.org/license.txt
// Sources:
// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/DerivedCoreProperties.txt
// SHA-256: {FILES["DerivedCoreProperties.txt"]}
// https://www.unicode.org/Public/{UNICODE_VERSION}/ucd/SpecialCasing.txt
// SHA-256: {FILES["SpecialCasing.txt"]}

namespace PostHog.Json;

internal static class UnicodeSpecialCasingData
{{
static readonly int[] CasedRanges =
[
{format_ranges(cased)}
];

static readonly int[] CaseIgnorableRanges =
[
{format_ranges(case_ignorable)}
];

internal static bool IsCased(int codePoint) => Contains(CasedRanges, codePoint);

internal static bool IsCaseIgnorable(int codePoint) => Contains(CaseIgnorableRanges, codePoint);

static bool Contains(int[] ranges, int codePoint)
{{
var lower = 0;
var upper = ranges.Length / 2 - 1;
while (lower <= upper)
{{
var middle = lower + (upper - lower) / 2;
var start = ranges[middle * 2];
var end = ranges[middle * 2 + 1];
if (codePoint < start)
{{
upper = middle - 1;
}}
else if (codePoint > end)
{{
lower = middle + 1;
}}
else
{{
return true;
}}
}}
return false;
}}
}}
'''
OUTPUT.write_text(source)


if __name__ == "__main__":
main()
31 changes: 21 additions & 10 deletions src/PostHog/Features/LocalEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -658,10 +658,21 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary<
throw new InconclusiveMatchException("The filter property value is null");
}

if (overrideValue is null && propertyFilter.Operator != ComparisonOperator.IsNot)
{
// If the value is null, just fail the feature flag comparison. This doesn't throw an
// InconclusiveMatchException because the property value was provided.
if (overrideValue is null
&& propertyFilter.Operator is not (
ComparisonOperator.Exact
or ComparisonOperator.IsNot
or ComparisonOperator.ContainsIgnoreCase
or ComparisonOperator.DoesNotContainIgnoreCase
or ComparisonOperator.StartsWith
or ComparisonOperator.NotStartsWith
or ComparisonOperator.EndsWith
or ComparisonOperator.NotEndsWith
or ComparisonOperator.Regex
or ComparisonOperator.NotRegex))
{
// The backend stringifies null for string operators and exact matching. Other value operators keep the
// existing null non-match behavior.
return false;
}

Expand All @@ -673,12 +684,12 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary<
ComparisonOperator.GreaterThanOrEquals => value <= overrideValue,
ComparisonOperator.LessThan => value > overrideValue,
ComparisonOperator.LessThanOrEquals => value >= overrideValue,
ComparisonOperator.ContainsIgnoreCase => value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase),
ComparisonOperator.DoesNotContainIgnoreCase => !value.IsContainedBy(overrideValue, StringComparison.OrdinalIgnoreCase),
ComparisonOperator.StartsWith => value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
ComparisonOperator.NotStartsWith => !value.IsPrefixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
ComparisonOperator.EndsWith => value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
ComparisonOperator.NotEndsWith => !value.IsSuffixOf(overrideValue, StringComparison.OrdinalIgnoreCase),
ComparisonOperator.ContainsIgnoreCase => value.IsContainedByAsciiIgnoreCase(overrideValue),
ComparisonOperator.DoesNotContainIgnoreCase => !value.IsContainedByAsciiIgnoreCase(overrideValue),
ComparisonOperator.StartsWith => value.IsPrefixOfAsciiIgnoreCase(overrideValue),
ComparisonOperator.NotStartsWith => !value.IsPrefixOfAsciiIgnoreCase(overrideValue),
ComparisonOperator.EndsWith => value.IsSuffixOfAsciiIgnoreCase(overrideValue),
ComparisonOperator.NotEndsWith => !value.IsSuffixOfAsciiIgnoreCase(overrideValue),
ComparisonOperator.Regex => value.IsRegexMatch(overrideValue),
ComparisonOperator.NotRegex => !value.IsRegexMatch(overrideValue),
ComparisonOperator.IsDateBefore => value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()),
Expand Down
Loading
Loading