diff --git a/internal/config/secretkeys_test.go b/internal/config/secretkeys_test.go index 79b9926c2..d889d7804 100644 --- a/internal/config/secretkeys_test.go +++ b/internal/config/secretkeys_test.go @@ -174,9 +174,17 @@ func TestMaskURLCredentialsNoPanic(t *testing.T) { } } +// schemelessTimeOf is the fastest of five runs, so one scheduler or GC stall on +// a loaded runner does not read as super-linear growth. func schemelessTimeOf(unit string, size int) time.Duration { v := strings.Repeat(unit, size/len(unit)) - start := time.Now() - _ = MaskURLCredentials(v) - return time.Since(start) + best := time.Duration(1<<63 - 1) + for i := 0; i < 5; i++ { + start := time.Now() + _ = MaskURLCredentials(v) + if d := time.Since(start); d < best { + best = d + } + } + return best } diff --git a/internal/credmask/linear_test.go b/internal/credmask/linear_test.go new file mode 100644 index 000000000..c0c78a1b7 --- /dev/null +++ b/internal/credmask/linear_test.go @@ -0,0 +1,41 @@ +package credmask + +import ( + "strings" + "testing" + "time" +) + +// bestOf returns the fastest of n runs of Schemeless over unit repeated to +// about size bytes; the minimum filters scheduler and GC noise on a loaded CI +// runner. +func bestOf(unit string, size, n int) time.Duration { + v := strings.Repeat(unit, size/len(unit)) + best := time.Duration(1<<63 - 1) + for i := 0; i < n; i++ { + start := time.Now() + _ = Schemeless(v) + if d := time.Since(start); d < best { + best = d + } + } + return best +} + +// TestSchemelessLinear: adversarial shapes must scale linearly. 10x the input +// may take about 10x as long; a quadratic scan takes about 100x, so the bound +// is 50x plus a 2 ms floor for timer noise. The 40 KB to 400 KB step is where +// a quadratic rescan stands clear of that noise. +func TestSchemelessLinear(t *testing.T) { + units := []string{"@a(", ":@a(", "a:@", "a:b@c(", "x=y@a(", "password=", "aaaaaaaa", "::::", "@@@@"} + for _, unit := range units { + prev := bestOf(unit, 4_000, 7) + for _, size := range []int{40_000, 400_000} { + cur := bestOf(unit, size, 7) + if cur > 50*prev+2*time.Millisecond { + t.Errorf("unit %q: %d B took %v, %d B took %v: not linear", unit, size/10, prev, size, cur) + } + prev = cur + } + } +} diff --git a/internal/credmask/schemeless.go b/internal/credmask/schemeless.go index 29f70f61c..d4bb13d72 100644 --- a/internal/credmask/schemeless.go +++ b/internal/credmask/schemeless.go @@ -58,30 +58,42 @@ func maskDSNUserinfo(v string) string { } var b strings.Builder done := 0 - // One forward pass tracks where the token holding each match starts, so a - // long run without whitespace is not rescanned per match. + // One forward pass tracks where the token holding each match starts, its + // first `:` and its first `://`, so a long run without whitespace is not + // rescanned per match. pos, tokenStart := 0, 0 + colonAt, schemeAt := -1, -1 for _, loc := range locs { at := loc[0] for ; pos < at; pos++ { - if strings.IndexByte(" \t\r\n\"'", v[pos]) >= 0 { + switch c := v[pos]; c { + case ' ', '\t', '\r', '\n', '"', '\'': tokenStart = pos + 1 + colonAt, schemeAt = -1, -1 + case ':': + if colonAt < 0 { + colonAt = pos + } + if schemeAt < 0 && strings.HasPrefix(v[pos:], "://") { + schemeAt = pos + } } } if at < done { continue } start := max(tokenStart, done) - token := v[start:at] - colon := strings.IndexByte(token, ':') - if colon < 0 || strings.Contains(token, "://") { + if colonAt < 0 || (schemeAt >= 0 && schemeAt+3 <= at) { continue } + token := v[start:at] + colon := colonAt - start keep := token[:strings.LastIndexByte(token[:colon], '=')+1] b.WriteString(v[done:start]) b.WriteString(keep) b.WriteString("****") done = at + colonAt, schemeAt = -1, -1 } b.WriteString(v[done:]) return b.String()