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"
|
"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
|
// durationUnits mirrors the snap table from cookie-utils.ts. The same
|
||||||
// tracker observed across different clients can have jitter in its
|
// 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) {
|
func splitTokens(name string) ([]string, []byte) {
|
||||||
underscoreParts := strings.Split(name, "_")
|
primaryParts, primarySeps := splitOnAny(name, primarySeparators)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
tokens []string
|
tokens []string
|
||||||
seps []byte
|
seps []byte
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, part := range underscoreParts {
|
for i, part := range primaryParts {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
seps = append(seps, '_')
|
seps = append(seps, primarySeps[i-1])
|
||||||
}
|
}
|
||||||
|
|
||||||
if isUUIDShape(part) || !strings.Contains(part, "-") {
|
if isUUIDShape(part) || !strings.Contains(part, "-") {
|
||||||
@@ -597,6 +607,29 @@ func splitTokens(name string) ([]string, []byte) {
|
|||||||
return tokens, seps
|
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 {
|
func joinTokens(tokens []string, seps []byte) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
@@ -616,10 +649,13 @@ func joinTokens(tokens []string, seps []byte) string {
|
|||||||
// "__*", "-*", "--*", "__*__" would merge unrelated third parties
|
// "__*", "-*", "--*", "__*__" would merge unrelated third parties
|
||||||
// (e.g. __support__, __darkreader__wasEnabledForHost,
|
// (e.g. __support__, __darkreader__wasEnabledForHost,
|
||||||
// __EXT_APP_REFRESH_BLACK_SUB_DOMAINS__) under a single glob, so
|
// __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 {
|
func templateHasFixedAnchor(tmpl string) bool {
|
||||||
for _, ch := range tmpl {
|
for _, ch := range tmpl {
|
||||||
if ch != '*' && ch != '_' && ch != '-' {
|
if ch != '*' && ch != '-' && !strings.ContainsRune(primarySeparators, ch) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,12 @@ func TestHeuristicTemplate(t *testing.T) {
|
|||||||
input: "__a1b2c3d4_e5f6g7h8",
|
input: "__a1b2c3d4_e5f6g7h8",
|
||||||
changed: false,
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -538,6 +544,12 @@ func TestSplitTokens(t *testing.T) {
|
|||||||
tokens: []string{"session", "550e8400-e29b-41d4-a716-446655440000", "data"},
|
tokens: []string{"session", "550e8400-e29b-41d4-a716-446655440000", "data"},
|
||||||
seps: []byte{'_', '_'},
|
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 {
|
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(
|
t.Run(
|
||||||
"unrelated double-underscore keys do not merge under anchor-free glob",
|
"unrelated double-underscore keys do not merge under anchor-free glob",
|
||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user