Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 112 additions & 41 deletions internal/credmask/linear_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package credmask

import (
"runtime"
"strings"
"testing"
"time"
Expand All @@ -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)
}
}
}
5 changes: 5 additions & 0 deletions internal/credmask/race_off_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !race

package credmask

const raceEnabled = false
7 changes: 7 additions & 0 deletions internal/credmask/race_on_test.go
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions internal/credmask/schemeless.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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', '"', '\'':
Expand All @@ -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)
Expand All @@ -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") {
Expand Down
Loading