check / check (push) Waiting to run
Every *.rules file in SWWAF_RULES_DIR is read at start and on each change, and each request is checked against the rules after the rate limits: log notes a match, block refuses with 403, ban refuses and bans the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next request or clear sign of attack. path, query and uri are matched as the request line sent them. bans.json gains each ban's cause, and ban notes count earlier bans by cause. The image ships 00-default.rules. Judgement call: a header sent twice is matched with its values joined by ", ". Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack. Not in this unit: offences for rule matches, with the error burst. Model: opus-5-5
596 lines
17 KiB
Go
596 lines
17 KiB
Go
package rules_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
)
|
|
|
|
const (
|
|
// What the process log says once Watch watches the directory, after
|
|
// each reading of the rule files, and for one that has an error.
|
|
watching = "watching the rule files for edits"
|
|
read = "read the rule files"
|
|
hasError = "a rule file has an error, and the rules stay as they were"
|
|
// maxLogLines is how many lines of the process log wait for a test to
|
|
// read them.
|
|
maxLogLines = 64
|
|
// browser is the user agent of an ordinary visitor.
|
|
browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
|
|
// testFile is the rule file of a test that needs only one, and
|
|
// firstFile the first of a test's rule files.
|
|
testFile = "test.rules"
|
|
firstFile = "00-a.rules"
|
|
// userAgent is the header that carries the user agent.
|
|
userAgent = "User-Agent"
|
|
)
|
|
|
|
func TestEachTargetMatchesWhatItNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
rule string // its target, action and regex
|
|
uri string // the request's path and query
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true},
|
|
{"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false},
|
|
{"path without the query", `path log ^/a$`, "/a?b=c", nil, true},
|
|
{"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true},
|
|
{"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true},
|
|
{"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true},
|
|
{
|
|
"uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`,
|
|
"/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true,
|
|
},
|
|
{"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false},
|
|
{"method", `method log ^PUT$`, "/", nil, true},
|
|
{"host", `host log ^app\.example$`, "/", nil, true},
|
|
{
|
|
"user_agent", `user_agent log ^sqlmap/`, "/",
|
|
http.Header{userAgent: {"sqlmap/1.8"}}, true,
|
|
},
|
|
{
|
|
"user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/",
|
|
http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true,
|
|
},
|
|
{"user_agent missing", `user_agent log ^$`, "/", nil, true},
|
|
{
|
|
"referer", `referer log ^https://spam\.example/`, "/",
|
|
http.Header{"Referer": {"https://spam.example/buy"}}, true,
|
|
},
|
|
{
|
|
"a header sent twice", `header:x-api-version log ^2, 3$`, "/",
|
|
http.Header{"X-Api-Version": {"2", "3"}}, true,
|
|
},
|
|
{"a header missing", `header:X-Api-Version log ^$`, "/", nil, true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"})
|
|
|
|
// Every request is a PUT, which the method rule looks for.
|
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodPut,
|
|
"http://app.example"+tc.uri, nil)
|
|
maps.Copy(r.Header, tc.header)
|
|
|
|
got := len(files.Match(r)) == 1
|
|
if got != tc.want {
|
|
t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPathMatchedAsTheClientSentIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each path holds a character Go's URL type would escape again, \ or
|
|
// a non-ASCII byte, and each rule is written for the path as sent.
|
|
for _, tc := range []struct {
|
|
rule string // its target, action and regex
|
|
sent string // the path and query the client sent
|
|
}{
|
|
{`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`},
|
|
{`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`},
|
|
{`path log ^/café$`, "/café?x=1"},
|
|
{`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`},
|
|
} {
|
|
files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"})
|
|
|
|
// The target in origin form, as traefik sends it, and in absolute
|
|
// form, as a client sends it to a proxy.
|
|
for _, target := range []string{tc.sent, "http://app.example" + tc.sent} {
|
|
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
wantMatched(t, files, r, "as-sent")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := load(t, ruleFiles{testFile: `
|
|
every-path path log ^/
|
|
no-path path log ^$
|
|
first-refusal path block ^/probe
|
|
later-ban path ban ^/probe
|
|
after path log ^/
|
|
`})
|
|
|
|
// Every log rule that matches is noted, and the block rule ends the
|
|
// matching.
|
|
wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal")
|
|
wantMatched(t, files, get(t, "/page"), "every-path", "after")
|
|
|
|
// A ban rule ends it too.
|
|
files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
|
|
wantMatched(t, files, get(t, "/"), "ban")
|
|
}
|
|
|
|
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := load(t, ruleFiles{
|
|
"50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n",
|
|
firstFile: "a1 path log ^/\r\n",
|
|
// None is a rule file.
|
|
"notes.txt": "notes, not rules\n",
|
|
"10-c.rules.bak": "an old copy\n",
|
|
"20-d.rules/keep": "a file in a directory\n",
|
|
})
|
|
|
|
wantMatched(t, files, get(t, "/"), "a1", "b1", "b2")
|
|
|
|
if files.Len() != 3 {
|
|
t.Errorf("%d rules loaded, want 3", files.Len())
|
|
}
|
|
}
|
|
|
|
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
content string
|
|
line int
|
|
want string
|
|
}{
|
|
{
|
|
"too few fields", "env-file path ban\n", 1,
|
|
"is not a rule: an id, a target, an action and a regex, " +
|
|
"separated by spaces or tabs",
|
|
},
|
|
{
|
|
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
|
|
`the id "env.file" is not an id of letters, digits, - and _`,
|
|
},
|
|
{
|
|
"an unknown target", "env-file paths ban ^/\n", 1,
|
|
`the target "paths" is not path, query, uri, method, host, ` +
|
|
"user_agent, referer or header:<Name>",
|
|
},
|
|
{
|
|
"a header without a name", "env-file header: ban ^/\n", 1,
|
|
`the target "header:" is not path, query, uri, method, host, ` +
|
|
"user_agent, referer or header:<Name>",
|
|
},
|
|
{
|
|
"an unknown action", "env-file path deny ^/\n", 1,
|
|
`the action "deny" is not log, block or ban`,
|
|
},
|
|
{
|
|
"a regex that does not compile", "env-file path ban ^/(\n", 1,
|
|
"the regex does not compile: error parsing regexp: " +
|
|
"missing closing ): `^/(`",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
|
|
path := filepath.Join(dir, "00-default.rules")
|
|
|
|
wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{
|
|
"00-a.rules": "probe path log ^/a\n",
|
|
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
|
|
})
|
|
|
|
wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
|
|
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
|
|
}
|
|
|
|
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
|
|
|
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
|
": no such file or directory")
|
|
}
|
|
|
|
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
line := lines.waitFor(t, read)
|
|
if files.Len() != 0 || line["rules"] != 0.0 {
|
|
t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
|
|
}
|
|
}
|
|
|
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
|
params.Enabled = false
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
if files.Len() != 0 || files.Match(get(t, "/")) != nil {
|
|
t.Errorf("%d rules loaded with the rule files off", files.Len())
|
|
}
|
|
|
|
// It would watch until the test ends.
|
|
files.Watch(t.Context())
|
|
}
|
|
|
|
func TestEditsTakenInWhileRunning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
|
files, lines := watch(t, dir)
|
|
|
|
// matches reports whether path matches a rule.
|
|
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
|
|
|
|
// A file added.
|
|
save(t, dir, "50-b.rules", "second path block ^/second\n")
|
|
lines.waitUntil(t, func() bool { return matches("/second") })
|
|
wantMatched(t, files, get(t, "/first"), "first")
|
|
|
|
// A file edited.
|
|
save(t, dir, firstFile, "first path block ^/edited\n")
|
|
lines.waitUntil(t, func() bool { return !matches("/first") })
|
|
wantMatched(t, files, get(t, "/edited"), "first")
|
|
|
|
// A file removed.
|
|
err := os.Remove(filepath.Join(dir, "50-b.rules"))
|
|
if err != nil {
|
|
t.Fatalf("remove: %v", err)
|
|
}
|
|
|
|
lines.waitUntil(t, func() bool { return !matches("/second") })
|
|
wantMatched(t, files, get(t, "/edited"), "first")
|
|
}
|
|
|
|
func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
|
files, lines := watch(t, dir)
|
|
|
|
// The edit's second line has an unknown action, so the rules stay as
|
|
// they were, the first line's earlier version included.
|
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
|
|
|
|
line := lines.waitFor(t, hasError)
|
|
want := filepath.Join(dir, firstFile) +
|
|
`, line 2: the action "bann" is not log, block or ban`
|
|
|
|
if line["error"] != want || line["level"] != "ERROR" {
|
|
t.Errorf("logged %v, want an error %q", line, want)
|
|
}
|
|
|
|
wantMatched(t, files, get(t, "/first"), "first")
|
|
wantMatched(t, files, get(t, "/second"))
|
|
|
|
// Once mended, the file is read again.
|
|
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
|
|
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
|
|
wantMatched(t, files, get(t, "/edited"), "first")
|
|
}
|
|
|
|
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
params, _ := newParams(filepath.Join("..", "..", "share", "rules.d"))
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load the default file: %v", err)
|
|
}
|
|
|
|
// Probes sent by a browser, by the rule that refuses them.
|
|
for rule, targets := range map[string][]string{
|
|
"env-file": {"/.env", "/.env.production", "/.ENV"},
|
|
"vcs-dir": {"/.git/config", "/.git", "/.svn/entries"},
|
|
"secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"},
|
|
"secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"},
|
|
"editor-dir": {"/.vscode/sftp.json"},
|
|
"backup-file": {
|
|
"/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz",
|
|
},
|
|
"log-file": {"/debug.log"},
|
|
"compose-file": {"/docker-compose.yml", "/compose.yaml"},
|
|
"php-shell": {"/shell.php"},
|
|
"path-traversal": {
|
|
"/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
|
},
|
|
} {
|
|
for _, target := range targets {
|
|
wantRefusedBy(t, files, target, browser, rule)
|
|
}
|
|
}
|
|
|
|
// Scanners, by their user agents.
|
|
for _, scanner := range []string{
|
|
"sqlmap/1.8.4#stable (https://sqlmap.org)",
|
|
"Mozilla/5.0 (compatible; Nuclei - Open-source project)",
|
|
} {
|
|
wantRefusedBy(t, files, "/", scanner, "scanner-agent")
|
|
}
|
|
|
|
// Ordinary requests to a code forge for files of those names deeper
|
|
// in its paths, and for other files at its root.
|
|
for _, target := range []string{
|
|
"/owner/repo/src/branch/main/.env.example",
|
|
"/owner/repo/src/branch/main/.env",
|
|
"/owner/repo/src/branch/main/.github/workflows/ci.yml",
|
|
"/owner/repo/src/branch/main/.vscode/settings.json",
|
|
"/owner/repo/src/branch/main/.htaccess",
|
|
"/owner/repo/src/branch/main/docker-compose.yml",
|
|
"/owner/repo/src/branch/main/db/schema.sql",
|
|
"/owner/repo/raw/branch/main/debug.log",
|
|
"/owner/repo.git/info/refs?service=git-upload-pack",
|
|
"/owner/repo/src/branch/main/docs/../README.md",
|
|
"/user/login?redirect_to=%2fowner%2frepo",
|
|
"/index.php",
|
|
"/.well-known/security.txt",
|
|
} {
|
|
r := get(t, target)
|
|
r.Header.Set(userAgent, browser)
|
|
|
|
matched := files.Match(r)
|
|
if len(matched) != 0 {
|
|
t.Errorf("%s matched %v, want no rule", target, ids(matched))
|
|
}
|
|
}
|
|
|
|
// A request without a user agent is only noted.
|
|
wantMatched(t, files, get(t, "/"), "empty-agent")
|
|
}
|
|
|
|
// ruleFiles are files to write into a directory of rule files, by name.
|
|
type ruleFiles map[string]string
|
|
|
|
// writeFiles writes files into a new directory, and returns it.
|
|
func writeFiles(t *testing.T, files ruleFiles) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
|
|
for name, content := range files {
|
|
path := filepath.Join(dir, name)
|
|
|
|
err := os.MkdirAll(filepath.Dir(path), 0o700)
|
|
if err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
err = os.WriteFile(path, []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
// save writes content to the rule file name in dir as an editor that
|
|
// saves by renaming does, so that the file is never seen half written.
|
|
func save(t *testing.T, dir, name, content string) {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(dir, name)
|
|
|
|
err := os.WriteFile(path+".tmp", []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
|
|
err = os.Rename(path+".tmp", path)
|
|
if err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
}
|
|
|
|
// newParams returns Params for the rule files in dir, switched on, with
|
|
// the process log in the processLog returned.
|
|
func newParams(dir string) (rules.Params, processLog) {
|
|
lines := make(processLog, maxLogLines)
|
|
|
|
return rules.Params{
|
|
Dir: dir,
|
|
Enabled: true,
|
|
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
|
|
}, lines
|
|
}
|
|
|
|
// load writes files into a new directory and loads the rules in it.
|
|
func load(t *testing.T, files ruleFiles) *rules.Files {
|
|
t.Helper()
|
|
|
|
params, _ := newParams(writeFiles(t, files))
|
|
params.ProcessLog = slog.New(slog.DiscardHandler)
|
|
|
|
loaded, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
return loaded
|
|
}
|
|
|
|
// watch loads the rules in dir, runs their Watch until the test ends, and
|
|
// waits until it watches the directory.
|
|
func watch(t *testing.T, dir string) (*rules.Files, processLog) {
|
|
t.Helper()
|
|
|
|
params, lines := newParams(dir)
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
ctx, stop := context.WithCancel(t.Context())
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
files.Watch(ctx)
|
|
close(stopped)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
stop()
|
|
<-stopped
|
|
})
|
|
|
|
lines.waitFor(t, watching)
|
|
|
|
return files, lines
|
|
}
|
|
|
|
// wantRefused checks that loading the rule files in dir fails with the
|
|
// error want.
|
|
func wantRefused(t *testing.T, dir, want string) {
|
|
t.Helper()
|
|
|
|
params, _ := newParams(dir)
|
|
|
|
_, err := rules.Load(params)
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("error %v, want %s", err, want)
|
|
}
|
|
}
|
|
|
|
// get returns a GET request for target, a path and an optional query, as
|
|
// smallwebwaf's server reads it, without a user agent.
|
|
func get(t *testing.T, target string) *http.Request {
|
|
t.Helper()
|
|
|
|
return httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
"http://app.example"+target, nil)
|
|
}
|
|
|
|
// wantRefusedBy checks that a GET request for target with the user agent
|
|
// sent matches rule alone, and that rule refuses it.
|
|
func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
|
|
t.Helper()
|
|
|
|
r := get(t, target)
|
|
r.Header.Set(userAgent, sent)
|
|
|
|
matched := files.Match(r)
|
|
if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
|
|
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
|
|
sent, ids(matched), rule)
|
|
}
|
|
}
|
|
|
|
// wantMatched checks the ids of the rules r matches, in order.
|
|
func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
|
|
t.Helper()
|
|
|
|
got := ids(files.Match(r))
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("%s matched %v, want %v", r.URL, got, want)
|
|
}
|
|
}
|
|
|
|
// ids returns the ids of matched.
|
|
func ids(matched []rules.Rule) []string {
|
|
got := make([]string, 0, len(matched))
|
|
for _, rule := range matched {
|
|
got = append(got, rule.ID)
|
|
}
|
|
|
|
return got
|
|
}
|
|
|
|
// processLog receives the lines of a process log, each a JSON object, for
|
|
// a test to wait for.
|
|
type processLog chan string
|
|
|
|
// Write receives a line of the process log.
|
|
func (l processLog) Write(line []byte) (int, error) {
|
|
l <- string(line)
|
|
|
|
return len(line), nil
|
|
}
|
|
|
|
// waitFor returns the next line of the process log whose message is msg,
|
|
// passing over the lines before it. It waits as long as that takes, so
|
|
// that a slow test process cannot fail the test.
|
|
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
|
|
t.Helper()
|
|
|
|
for line := range l {
|
|
var fields map[string]any
|
|
|
|
err := json.Unmarshal([]byte(line), &fields)
|
|
if err != nil {
|
|
t.Fatalf("process log line %q is not JSON: %v", line, err)
|
|
}
|
|
|
|
if fields["msg"] == msg {
|
|
return fields
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// waitUntil waits for the rule files to be read until done reports true,
|
|
// as it does once they have been read after the test's last change. One
|
|
// change can be seen more than once, and so read more than once.
|
|
func (l processLog) waitUntil(t *testing.T, done func() bool) {
|
|
t.Helper()
|
|
|
|
for !done() {
|
|
l.waitFor(t, read)
|
|
}
|
|
}
|