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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user