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") {