From aab7a02bb55372e86e2283cfc9284fa739603f1e Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 26 Oct 2026 23:54:41 -0400 Subject: [PATCH] test(config): prove schemeless masking linearity deterministically TestSchemelessLinear compared wall-clock times, which cannot be made reliable on shared Windows runners (coarse timer, GC pauses, noisy neighbours). Assert linearity on counted work instead: a test-only step hook in the DSN and keyword scans, plus allocation counts and bytes, must each grow under 15x (bytes 25x) for 10x input over 13 adversarial shapes. Allocation checks are skipped under -race, which shadows allocations. The wall-clock check stays as a coarse safety net, best of 5, 40x plus a 5 ms floor, and is skipped on Windows and under -short. --- internal/credmask/linear_test.go | 153 +++++++++++++++++++++-------- internal/credmask/race_off_test.go | 5 + internal/credmask/race_on_test.go | 7 ++ internal/credmask/schemeless.go | 14 +++ 4 files changed, 138 insertions(+), 41 deletions(-) create mode 100644 internal/credmask/race_off_test.go create mode 100644 internal/credmask/race_on_test.go diff --git a/internal/credmask/linear_test.go b/internal/credmask/linear_test.go index 6eac22473..0cb0bc081 100644 --- a/internal/credmask/linear_test.go +++ b/internal/credmask/linear_test.go @@ -1,6 +1,7 @@ package credmask import ( + "runtime" "strings" "testing" "time" @@ -9,52 +10,122 @@ import ( // sink keeps the compiler from discarding the measured call. var sink string -// minBatch is how long one timed batch must run. Windows timers tick at about -// 0.5 to 15 ms, so a single 4 KB call reads as 0s there; a batch this long -// reads as a real number on every platform. -const minBatch = 3 * time.Millisecond - -// perCall returns the cost of one Schemeless call over unit repeated to about -// size bytes. It doubles the batch until the batch lasts minBatch, then takes -// the fastest of n batches, so a scheduler or GC stall on a loaded runner does -// not read as super-linear growth and a zero reading never reaches a ratio. -func perCall(unit string, size, n int) time.Duration { - v := strings.Repeat(unit, size/len(unit)) - run := func(reps int) time.Duration { - start := time.Now() - for i := 0; i < reps; i++ { - sink = Schemeless(v) - } - return time.Since(start) - } - reps := 1 - for run(reps) < minBatch && reps < 1<<20 { - reps *= 2 +// shapes are the adversarial inputs: each is repeated to the test size. They +// hit the DSN token scan (`@a(` runs with and without a colon or `://` ahead), +// the bare user:pass@host rule and the keyword rule, plus plain runs. +var shapes = []struct{ name, prefix, unit string }{ + {"@a(", "", "@a("}, + {":@a(", "", ":@a("}, + {"a:@", "", "a:@"}, + {"a:b@c(", "", "a:b@c("}, + {"x=y@a(", "", "x=y@a("}, + {"password=", "", "password="}, + {"password= x", "", "password= x"}, + {"aaaaaaaa", "", "aaaaaaaa"}, + {"::::", "", "::::"}, + {"@@@@", "", "@@@@"}, + {"one colon then @a(", "a:", "@a("}, + {"scheme then @a(", "a://h:", "@a("}, + {"quote then @a(", "'a:", "@a("}, +} + +func build(prefix, unit string, size int) string { + return prefix + strings.Repeat(unit, size/len(unit)) +} + +// work is what one Schemeless call costs, measured without a clock. +type work struct{ steps, mallocs, bytes uint64 } + +// measure runs Schemeless once over v and returns the bytes its scans read +// (stepHook), plus the allocation count and bytes it caused. The allocation +// figures are the smallest of three runs, so a stray allocation by another +// goroutine does not inflate them. +func measure(v string) work { + var w work + stepHook = func(n int) { w.steps += uint64(n) } + sink = Schemeless(v) + stepHook = nil + w.mallocs, w.bytes = ^uint64(0), ^uint64(0) + for i := 0; i < 3; i++ { + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + sink = Schemeless(v) + runtime.ReadMemStats(&after) + w.mallocs = min(w.mallocs, after.Mallocs-before.Mallocs) + w.bytes = min(w.bytes, after.TotalAlloc-before.TotalAlloc) } - best := time.Duration(1<<63 - 1) - for i := 0; i < n; i++ { - if d := run(reps) / time.Duration(reps); d < best { - best = d + return w +} + +// growthLimit is the most a 10x input may grow any cost: linear is 10x, a +// quadratic scan or per-candidate string rebuild is 100x. +const growthLimit = 15 + +// bytesGrowthLimit is looser for allocated bytes: slice doubling and size +// classes make 10x input read as up to about 18x memory at 4 KB to 40 KB, while +// bytes per input byte is flat from 256 KB up. A quadratic rebuild is 100x. +const bytesGrowthLimit = 25 + +// TestSchemelessLinear proves linear scaling without timing, so it holds on +// every platform: for each adversarial shape, 10x the input (4 KB to 40 KB) +// must read under 15x the bytes, make under 15x the allocations and allocate +// under 25x the memory. The small additive slack covers fixed costs (regexp +// state, builder growth) that dominate tiny inputs. Under -race only the step +// check runs (see raceEnabled). +func TestSchemelessLinear(t *testing.T) { + for _, s := range shapes { + small, large := measure(build(s.prefix, s.unit, 4_000)), measure(build(s.prefix, s.unit, 40_000)) + t.Logf("%-20s steps %8d -> %9d (x%.1f) mallocs %6d -> %7d (x%.1f) bytes %8d -> %9d (x%.1f)", s.name, + small.steps, large.steps, ratio(small.steps, large.steps), + small.mallocs, large.mallocs, ratio(small.mallocs, large.mallocs), + small.bytes, large.bytes, ratio(small.bytes, large.bytes)) + if large.steps > growthLimit*small.steps+64 { + t.Errorf("%q: 4 KB read %d bytes, 40 KB read %d: not linear", s.name, small.steps, large.steps) + } + if raceEnabled { + continue + } + if large.mallocs > growthLimit*small.mallocs+16 { + t.Errorf("%q: 4 KB made %d allocations, 40 KB made %d: not linear", s.name, small.mallocs, large.mallocs) + } + if large.bytes > bytesGrowthLimit*small.bytes+4096 { + t.Errorf("%q: 4 KB allocated %d bytes, 40 KB allocated %d: not linear", s.name, small.bytes, large.bytes) } } - return best } -// TestSchemelessLinear: adversarial shapes must scale linearly. 10x the input -// may take about 10x as long; a quadratic scan takes about 100x. The bound is -// 40x plus a 200 µs per-call floor for noise, so a 10x blow-up to 100x still -// fails. Costs are per call over batches of at least minBatch, never a single -// call, so a coarse timer cannot make the small case read as 0s. -func TestSchemelessLinear(t *testing.T) { - units := []string{"@a(", ":@a(", "a:@", "a:b@c(", "x=y@a(", "password=", "aaaaaaaa", "::::", "@@@@"} - for _, unit := range units { - prev := perCall(unit, 4_000, 5) - for _, size := range []int{40_000, 400_000} { - cur := perCall(unit, size, 5) - if cur > 40*prev+200*time.Microsecond { - t.Errorf("unit %q: %d B took %v/call, %d B took %v/call: not linear", unit, size/10, prev, size, cur) - } - prev = cur +func ratio(a, b uint64) float64 { + if a == 0 { + return 0 + } + return float64(b) / float64(a) +} + +// TestSchemelessLinearWallClock is a coarse safety net for a slowdown the +// step hook cannot see (a loop added without it). It is skipped on Windows, +// whose timer and shared runners are too coarse and noisy for a ratio. It takes +// the best of five batches per size and allows 40x for 10x the input plus a +// floor of a few milliseconds; a quadratic scan costs about 100x. +func TestSchemelessLinearWallClock(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("wall-clock ratios are unreliable on Windows; TestSchemelessLinear covers it") + } + if testing.Short() { + t.Skip("wall-clock safety net skipped in -short") + } + best := func(v string) time.Duration { + d := time.Duration(1<<63 - 1) + for i := 0; i < 5; i++ { + start := time.Now() + sink = Schemeless(v) + d = min(d, time.Since(start)) + } + return d + } + for _, s := range shapes { + small, large := best(build(s.prefix, s.unit, 40_000)), best(build(s.prefix, s.unit, 400_000)) + if large > 40*small+5*time.Millisecond { + t.Errorf("%q: 40 KB took %v, 400 KB took %v: not linear", s.name, small, large) } } } diff --git a/internal/credmask/race_off_test.go b/internal/credmask/race_off_test.go new file mode 100644 index 000000000..d0825207a --- /dev/null +++ b/internal/credmask/race_off_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package credmask + +const raceEnabled = false diff --git a/internal/credmask/race_on_test.go b/internal/credmask/race_on_test.go new file mode 100644 index 000000000..a4032ca8b --- /dev/null +++ b/internal/credmask/race_on_test.go @@ -0,0 +1,7 @@ +//go:build race + +package credmask + +// raceEnabled: the race detector shadows allocations, so allocation counts and +// bytes are not comparable across sizes; TestSchemelessLinear checks steps only. +const raceEnabled = true diff --git a/internal/credmask/schemeless.go b/internal/credmask/schemeless.go index d4bb13d72..1f7a7f911 100644 --- a/internal/credmask/schemeless.go +++ b/internal/credmask/schemeless.go @@ -30,6 +30,17 @@ var keywordPassword = regexp.MustCompile(`(?i)((?:^|[^A-Za-z0-9_])(?:ssl)?(?:pas // (already handled by the URL rules) stay readable. var urlKeywordPassword = regexp.MustCompile(`(?i)((?:^|[^A-Za-z0-9_])(?:ssl)?(?:password|passwd|pwd)[ \t]*=[ \t]*)('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|['"].*|[^\s'"&;#][^\s&;#]*)`) +// stepHook, when a test sets it, receives the number of bytes each scan in +// maskDSNUserinfo and maskKeywordValues reads, so a test can assert linear work +// on any platform without timing. It is nil in production. +var stepHook func(n int) + +func steps(n int) { + if stepHook != nil { + stepHook(n) + } +} + // Schemeless masks the credentials config.MaskURLCredentials' `://` rule cannot // see. func Schemeless(v string) string { @@ -65,6 +76,7 @@ func maskDSNUserinfo(v string) string { colonAt, schemeAt := -1, -1 for _, loc := range locs { at := loc[0] + steps(max(at-pos, 0)) for ; pos < at; pos++ { switch c := v[pos]; c { case ' ', '\t', '\r', '\n', '"', '\'': @@ -88,6 +100,7 @@ func maskDSNUserinfo(v string) string { } token := v[start:at] colon := colonAt - start + steps(colon) keep := token[:strings.LastIndexByte(token[:colon], '=')+1] b.WriteString(v[done:start]) b.WriteString(keep) @@ -110,6 +123,7 @@ var alreadyMasked = map[string]bool{"****": true, "[REDACTED]": true} // a NUL byte, which no real credential does. func maskKeywordValues(kw *regexp.Regexp, v string) string { return kw.ReplaceAllStringFunc(v, func(m string) string { + steps(len(m)) sub := kw.FindStringSubmatch(m) val := strings.Trim(sub[2], `'"`) if alreadyMasked[val] || strings.Contains(val, "\x00") {