Split tracker patterns on colon and dot separators

The pattern-analysis tokenizer split names only on underscore first,
then on dash. A key like "name_done:<uuid>" left the colon glued to
the leading hex group, so the trailing UUID was never recognised as a
single token and instead shredded on dash into short hex anchors. The
derived template kept those anchors fixed, producing a distinct glob
per UUID and preventing any merge.

Treat colon and dot as primary separators alongside underscore so the
embedded UUID is isolated, matched by isUUIDShape, and collapsed to a
wildcard. Extend templateHasFixedAnchor to ignore the new separators
so a separator-only template stays rejected by the anti-overmerge
guard.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-08 18:38:45 +02:00
parent 89ee202845
commit 591a051e0a
2 changed files with 74 additions and 6 deletions

View File

@@ -29,7 +29,17 @@ import (
"go.probo.inc/probo/pkg/gid"
)
const patternMergeThreshold = 3
const (
patternMergeThreshold = 3
// primarySeparators are the structural delimiters that always
// start a new token in splitTokens. `-` is intentionally excluded
// because it appears inside UUIDs, which splitTokens preserves as a
// single token; a value like "done:ecdd43d7-0193-4d24-b6ed-..."
// must split on ":" so the trailing UUID is isolated and collapsed
// to a wildcard rather than shredded into fixed hex anchors.
primarySeparators = "_:."
)
// durationUnits mirrors the snap table from cookie-utils.ts. The same
// tracker observed across different clients can have jitter in its
@@ -565,16 +575,16 @@ func isUUIDShape(s string) bool {
}
func splitTokens(name string) ([]string, []byte) {
underscoreParts := strings.Split(name, "_")
primaryParts, primarySeps := splitOnAny(name, primarySeparators)
var (
tokens []string
seps []byte
)
for i, part := range underscoreParts {
for i, part := range primaryParts {
if i > 0 {
seps = append(seps, '_')
seps = append(seps, primarySeps[i-1])
}
if isUUIDShape(part) || !strings.Contains(part, "-") {
@@ -597,6 +607,29 @@ func splitTokens(name string) ([]string, []byte) {
return tokens, seps
}
// splitOnAny splits s on every byte found in separators, returning the
// parts and the separator byte that preceded each part after the first.
// len(seps) == len(parts)-1.
func splitOnAny(s, separators string) ([]string, []byte) {
var (
parts []string
seps []byte
start int
)
for i := 0; i < len(s); i++ {
if strings.IndexByte(separators, s[i]) >= 0 {
parts = append(parts, s[start:i])
seps = append(seps, s[i])
start = i + 1
}
}
parts = append(parts, s[start:])
return parts, seps
}
func joinTokens(tokens []string, seps []byte) string {
var b strings.Builder
@@ -616,10 +649,13 @@ func joinTokens(tokens []string, seps []byte) string {
// "__*", "-*", "--*", "__*__" would merge unrelated third parties
// (e.g. __support__, __darkreader__wasEnabledForHost,
// __EXT_APP_REFRESH_BLACK_SUB_DOMAINS__) under a single glob, so
// candidates without any fixed alphanumeric anchor are rejected.
// candidates without any fixed alphanumeric anchor are rejected. The
// separators recognised here mirror primarySeparators (plus the
// UUID-internal "-") so a separator-only template such as ":.*" is
// rejected too.
func templateHasFixedAnchor(tmpl string) bool {
for _, ch := range tmpl {
if ch != '*' && ch != '_' && ch != '-' {
if ch != '*' && ch != '-' && !strings.ContainsRune(primarySeparators, ch) {
return true
}
}

View File

@@ -243,6 +243,12 @@ func TestHeuristicTemplate(t *testing.T) {
input: "__a1b2c3d4_e5f6g7h8",
changed: false,
},
{
name: "colon-delimited trailing UUID collapses to wildcard",
input: "letaido.onboarding.invite_done:0a1b2c3d-4e5f-6789-abcd-ef0123456789",
template: "letaido.onboarding.invite_done:*",
changed: true,
},
}
for _, tt := range tests {
@@ -538,6 +544,12 @@ func TestSplitTokens(t *testing.T) {
tokens: []string{"session", "550e8400-e29b-41d4-a716-446655440000", "data"},
seps: []byte{'_', '_'},
},
{
name: "colon and dot separators isolate trailing UUID",
input: "letaido.onboarding.invite_done:0a1b2c3d-4e5f-6789-abcd-ef0123456789",
tokens: []string{"letaido", "onboarding", "invite", "done", "0a1b2c3d-4e5f-6789-abcd-ef0123456789"},
seps: []byte{'.', '.', '_', ':'},
},
}
for _, tt := range tests {
@@ -952,6 +964,26 @@ func TestFindMergeGroups(t *testing.T) {
},
)
t.Run(
"colon-delimited UUID keys merge under heuristic glob",
func(t *testing.T) {
t.Parallel()
patterns := coredata.TrackerPatterns{
makePattern("letaido.onboarding.invite_done:0a1b2c3d-4e5f-6789-abcd-ef0123456789", &oneYear),
makePattern("letaido.onboarding.invite_done:11111111-2222-3333-4444-555555555555", &oneYear),
makePattern("letaido.onboarding.invite_done:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", &oneYear),
}
groups := findMergeGroups(patterns, 3)
require.Len(t, groups, 1)
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "letaido.onboarding.invite_done:*", durationBucket: durationBucket(&oneYear)}]
require.True(t, ok)
assert.Len(t, group, 3)
},
)
t.Run(
"unrelated double-underscore keys do not merge under anchor-free glob",
func(t *testing.T) {