Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 17dc7f6846 Keep the bans, the clients and GeoJS's answers in state files (closes #17)
check / check (push) Successful in 4m22s
smallwebwaf now copies its state to bans.json, clients.json and
lookups.json in SWWAF_STATE_DIR, as "Persistent state" in SPEC.md
describes, and reads them back at start, so a restart lifts no ban and
gives no client a fresh allowance. Each client gains a history, and a
ban's notes count the netblock's requests. bans.json is written
SWWAF_STATE_WRITE_DELAY after a ban, and every file every
SWWAF_STATE_COUNTER_INTERVAL and at the stop. A ban read back is masked
to its netblock and refuses every client in it, whatever
SWWAF_BAN_SCOPE_V4_PREFIX is now. A file that does not parse, an unknown
version or an unwritable directory stops the start.

Deviation: no AS number or name, and no ban cause, reason or lifting yet.

Model: opus-5-5
2026-10-06 05:29:48 +00:00
8 changed files with 259 additions and 88 deletions
+11 -8
View File
@@ -283,14 +283,17 @@ new one, whole. A write that fails is logged, and tried again at the next write.
A hard kill loses what changed since the last write. A hard kill loses what changed since the last write.
At start the files are read back: each client keeps its counts, so a restart At start the files are read back: each client keeps its counts, so a restart
gives it no fresh allowance, and each ban keeps refusing until it ends. Buckets gives it no fresh allowance, and each ban keeps refusing every client in its
and answers whose time has passed are dropped. A missing file is empty state, as netblock until it ends, even after `SWWAF_BAN_SCOPE_V4_PREFIX` has changed. A
on a first start. A file that does not parse, or has another `version`, stops netblock whose address has bits past its length, such as `203.0.113.9/24`, is
the start with a message naming the file, and the line and column where Go's read as the netblock it is in, `203.0.113.0/24`. Buckets and answers whose time
JSON decoder gives them; so does a state directory `smallwebwaf` cannot write. has passed are dropped. A missing file is empty state, as on a first start. A
An edit made while `smallwebwaf` runs is overwritten by its next write: taking file that does not parse, or has another `version`, stops the start with a
it in comes with https://git.eeqj.de/sneak/smallwebwaf/issues/68. The AS number message naming the file, and the line and column where Go's JSON decoder gives
and AS name come with their lookup. them; so does a state directory `smallwebwaf` cannot write. An edit made while
`smallwebwaf` runs is overwritten by its next write: taking it in comes with
https://git.eeqj.de/sneak/smallwebwaf/issues/68. The AS number and AS name come
with their lookup.
## Why ## Why
+45 -21
View File
@@ -107,11 +107,17 @@ type Ledger struct {
changed chan struct{} changed chan struct{}
mu sync.Mutex mu sync.Mutex
// netblocks holds each banned netblock's bans, oldest first. Each // netblocks holds each banned netblock's bans, oldest first. Check
// request from a netblock makes it the most recently seen. // makes each netblock it finds the most recently seen.
netblocks *simplelru.LRU[netip.Prefix, *[]Ban] netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
// held is how many bans netblocks holds, at most rules.MaxBans. // held is how many bans netblocks holds, at most rules.MaxBans.
held int held int
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
// netblocks that have been banned. Check looks for a ban at each of
// them, so that a ban read from bans.json refuses every client in its
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
// or another length of an IPv6 client's netblock.
v4Lengths, v6Lengths []int
} }
// New returns a Ledger with no ban yet. // New returns a Ledger with no ban yet.
@@ -136,29 +142,36 @@ func (l *Ledger) Changed() <-chan struct{} {
return l.changed return l.changed
} }
// Check is called for each request from netblock, at now. It reports // Check is called for each request from client, at now. It reports
// whether a ban on netblock is active, and returns that ban, with the // whether a ban on a netblock client is in is active, and returns that
// request counted among those it refused. // ban, with the request counted among those it refused.
func (l *Ledger) Check(netblock netip.Prefix, now time.Time) (Ban, bool) { func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
bans, found := l.netblocks.Get(netblock) lengths := l.v6Lengths
if !found { if client.Is4() {
return Ban{}, false lengths = l.v4Lengths
} }
// A ban is made only once the one before has ended, so only the last for _, length := range lengths {
// can be active. bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
last := &(*bans)[len(*bans)-1] if !found {
if !last.ActiveAt(now) { continue
return Ban{}, false }
// A ban is made only once the one before has ended, so only the
// last can be active.
last := &(*bans)[len(*bans)-1]
if last.ActiveAt(now) {
last.Notes.Requests++
last.Notes.Refused++
return *last, true
}
} }
last.Notes.Requests++ return Ban{}, false
last.Notes.Refused++
return *last, true
} }
// BanForLimit bans netblock at now for a broken limit, with notes, and // BanForLimit bans netblock at now for a broken limit, with notes, and
@@ -236,9 +249,10 @@ func (l *Ledger) Snapshot() []Ban {
// Load puts bans read from bans.json into a ledger that holds none yet, // Load puts bans read from bans.json into a ledger that holds none yet,
// in the order they started, so that a netblock whose last ban started // in the order they started, so that a netblock whose last ban started
// latest counts as the most recently seen. Each text in their notes is // latest counts as the most recently seen. Each netblock is masked to its
// cut to 256 bytes, and past MaxBans the earliest bans are dropped, as // length, so that 203.0.113.9/24 is 203.0.113.0/24, and each text in the
// when they are made. // notes is cut to 256 bytes. Past MaxBans the earliest bans are dropped,
// as when they are made.
func (l *Ledger) Load(bans []Ban) { func (l *Ledger) Load(bans []Ban) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
@@ -249,6 +263,7 @@ func (l *Ledger) Load(bans []Ban) {
}) })
for _, ban := range bans { for _, ban := range bans {
ban.Netblock = ban.Netblock.Masked()
ban.Notes.Request = ban.Notes.Request.cut() ban.Notes.Request = ban.Notes.Request.cut()
l.add(ban) l.add(ban)
} }
@@ -271,6 +286,15 @@ func (l *Ledger) add(ban Ban) {
*bans = append(*bans, ban) *bans = append(*bans, ban)
l.held++ l.held++
lengths := &l.v6Lengths
if ban.Netblock.Addr().Is4() {
lengths = &l.v4Lengths
}
if !slices.Contains(*lengths, ban.Netblock.Bits()) {
*lengths = append(*lengths, ban.Netblock.Bits())
}
} }
// expiry returns when a ban for a broken limit made at now ends, or zero // expiry returns when a ban for a broken limit made at now ends, or zero
+6 -6
View File
@@ -39,7 +39,7 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires) t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
} }
_, banned := ledger.Check(netblock, now.Add(100*365*day)) _, banned := ledger.Check(netblock.Addr(), now.Add(100*365*day))
if !banned { if !banned {
t.Error("a permanent ban ended") t.Error("a permanent ban ended")
} }
@@ -138,18 +138,18 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
for range 3 { for range 3 {
got, banned := ledger.Check(netblock, ban.Expires.Add(-time.Nanosecond)) got, banned := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
if !banned || got.Start != ban.Start { if !banned || got.Start != ban.Start {
t.Fatalf("check during the ban gives %+v and %t", got, banned) t.Fatalf("check during the ban gives %+v and %t", got, banned)
} }
} }
_, banned := ledger.Check(netip.MustParsePrefix("203.0.113.10/32"), midnight()) _, banned := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
if banned { if banned {
t.Error("another netblock is banned") t.Error("another netblock is banned")
} }
_, banned = ledger.Check(netblock, ban.Expires) _, banned = ledger.Check(netblock.Addr(), ban.Expires)
if banned { if banned {
t.Error("the ban did not end") t.Error("the ban did not end")
} }
@@ -180,7 +180,7 @@ func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
// A request from a makes b the netblock seen longest ago, and its ban // A request from a makes b the netblock seen longest ago, and its ban
// goes to make room for d's. // goes to make room for d's.
ledger.Check(a, now) ledger.Check(a.Addr(), now)
ledger.BanForLimit(d, now, bans.Notes{}) ledger.BanForLimit(d, now, bans.Notes{})
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 0, c: 1, d: 1}) wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 0, c: 1, d: 1})
@@ -190,7 +190,7 @@ func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
// With d seen since, a is seen longest ago, and its earlier ban goes // With d seen since, a is seen longest ago, and its earlier ban goes
// first. // first.
ledger.Check(d, first.Expires) ledger.Check(d.Addr(), first.Expires)
ledger.BanForLimit(b, first.Expires, bans.Notes{}) ledger.BanForLimit(b, first.Expires, bans.Notes{})
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1}) wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1})
+44 -2
View File
@@ -24,7 +24,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
// A limit broken during the ban makes no other, and a refusal changes // A limit broken during the ban makes no other, and a refusal changes
// only the counts in the notes, which wait for the interval's write. // only the counts in the notes, which wait for the interval's write.
ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{}) ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
ledger.Check(netblock, midnight().Add(time.Minute)) ledger.Check(netblock.Addr(), midnight().Add(time.Minute))
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
// Two bans before the value is read leave one. // Two bans before the value is read leave one.
@@ -76,7 +76,7 @@ func TestLoadedBansCarryOn(t *testing.T) {
after := bans.New(defaultRules()) after := bans.New(defaultRules())
after.Load(before.Snapshot()) after.Load(before.Snapshot())
_, banned := after.Check(netblock, ban.Expires.Add(-time.Second)) _, banned := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
if !banned { if !banned {
t.Error("the loaded ban does not refuse") t.Error("the loaded ban does not refuse")
} }
@@ -88,6 +88,48 @@ func TestLoadedBansCarryOn(t *testing.T) {
} }
} }
func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
t.Parallel()
// Two entries as an admin might write them, with addresses not masked
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
// ban covers, beside a ban the ledger makes on one IPv4 address.
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
})
ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(), bans.Notes{})
for client, want := range map[string]bool{
"203.0.113.0": true,
"203.0.113.200": true,
"203.0.114.1": false,
"2001:db8:0:5::1": true,
"2001:db8:1::1": false,
"198.51.100.7": true,
"198.51.100.8": false,
} {
_, banned := ledger.Check(netip.MustParseAddr(client), midnight())
if banned != want {
t.Errorf("%s is refused: %t, want %t", client, banned, want)
}
}
// The loaded netblocks are written back masked.
snapshot := ledger.Snapshot()
got := make([]string, 0, len(snapshot))
for _, ban := range snapshot {
got = append(got, ban.Netblock.String())
}
want := []string{"198.51.100.7/32", "203.0.113.0/24", "2001:db8::/48"}
if !slices.Equal(got, want) {
t.Errorf("the ledger holds bans on %v, want %v", got, want)
}
}
func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) { func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
t.Parallel() t.Parallel()
+3 -3
View File
@@ -14,10 +14,10 @@ func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action} return &refusal{status: rq.h.config.BanResponse, action: action}
} }
// banned reports whether a ban on the client's netblock refuses the // banned reports whether a ban on a netblock the client is in refuses
// request at now, and notes for the log line when that ban ends. // the request at now, and notes for the log line when that ban ends.
func (rq *request) banned(now time.Time) bool { func (rq *request) banned(now time.Time) bool {
ban, banned := rq.h.ledger.Check(rq.netblock(), now) ban, banned := rq.h.ledger.Check(rq.client, now)
if banned { if banned {
rq.line.BanExpires = banExpires(ban) rq.line.BanExpires = banExpires(ban)
} }
+6 -2
View File
@@ -164,8 +164,12 @@ func serve(
return 1 return 1
} }
// Every request has ended, and so has Run's last write: the files // Run's last write has ended, so nothing else writes the files. Every
// written now hold everything, and nothing else writes them. // request has ended too, but for two kinds that Go's server does not
// wait for: one cut off because Shutdown timed out, and one whose
// connection switched protocols, such as a WebSocket. Such a request
// adds to its client's history only as it ends, which can be after
// this write, and then that request is missing from clients.json.
<-written <-written
err = files.WriteAll() err = files.WriteAll()
+78 -9
View File
@@ -26,10 +26,11 @@ const (
// testVersion is the version the tests give smallwebwaf. // testVersion is the version the tests give smallwebwaf.
testVersion = "test" testVersion = "test"
// localhost is where the tests listen. // localhost is where the tests listen.
localhost = "127.0.0.1" localhost = "127.0.0.1"
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWWAF_LISTEN_ADDR"
upstreamURL = "SWWAF_UPSTREAM_URL" upstreamURL = "SWWAF_UPSTREAM_URL"
stateDir = "SWWAF_STATE_DIR" stateDir = "SWWAF_STATE_DIR"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
// greeting is what the tests' app answers. // greeting is what the tests' app answers.
greeting = "hello from the app" greeting = "hello from the app"
) )
@@ -188,10 +189,10 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
t.Parallel() t.Parallel()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t), upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
"SWWAF_RATE_LIMIT_PER_DAY": "2", rateLimitPerDay: "2",
// Neither comes due in the test: the files are written as // Neither comes due in the test: the files are written as
// smallwebwaf stops. // smallwebwaf stops.
"SWWAF_STATE_WRITE_DELAY": "1h", "SWWAF_STATE_WRITE_DELAY": "1h",
@@ -218,6 +219,46 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
out.line(t, "action", "banned") out.line(t, "action", "banned")
} }
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
t.Parallel()
const scope = "SWWAF_BAN_SCOPE_V4_PREFIX"
env := map[string]string{
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
"SWWAF_TRUSTED_PROXIES": localhost + "/32",
rateLimitPerDay: "1",
scope: "24",
}
// 203.0.113.9's second request breaks the day limit, and bans
// 203.0.113.0/24.
runUntilStopped(t, env, func(url string) {
wantStatus(t, url, "203.0.113.9", http.StatusOK)
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
})
// With each address a netblock of its own after a restart, that ban
// still refuses all of 203.0.113.0/24. 198.51.100.7 is banned alone.
env[scope] = "32"
runUntilStopped(t, env, func(url string) {
wantStatus(t, url, "203.0.113.200", http.StatusForbidden)
wantStatus(t, url, "203.0.114.1", http.StatusOK)
wantStatus(t, url, "198.51.100.7", http.StatusOK)
wantStatus(t, url, "198.51.100.7", http.StatusForbidden)
})
// With /24 netblocks again, that ban still refuses 198.51.100.7, and
// no other address.
env[scope] = "24"
runUntilStopped(t, env, func(url string) {
wantStatus(t, url, "198.51.100.7", http.StatusForbidden)
wantStatus(t, url, "198.51.100.8", http.StatusOK)
})
}
func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) { func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
@@ -336,7 +377,7 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000", "SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
"SWWAF_RATE_LIMIT_PER_HOUR": "10000", "SWWAF_RATE_LIMIT_PER_HOUR": "10000",
"SWWAF_RATE_LIMIT_PER_DAY": "50000", rateLimitPerDay: "50000",
"SWWAF_DENIED_COUNTRIES": "", "SWWAF_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWWAF_BAN_RESPONSE": "403", "SWWAF_BAN_RESPONSE": "403",
@@ -409,3 +450,31 @@ func wantRefused(t *testing.T, url string) {
t.Errorf("status %d, want %d", res.StatusCode, http.StatusForbidden) t.Errorf("status %d, want %d", res.StatusCode, http.StatusForbidden)
} }
} }
// wantStatus checks that a request to url from the client at from, as
// X-Forwarded-For names it, is answered with status.
func wantStatus(t *testing.T, url, from string, status int) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("X-Forwarded-For", from)
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
_ = res.Body.Close()
if res.StatusCode != status {
t.Errorf("request from %s: status %d, want %d", from, res.StatusCode, status)
}
}
+66 -37
View File
@@ -10,6 +10,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"testing/synctest"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
@@ -19,10 +20,6 @@ import (
) )
const ( const (
// waitLimit bounds how long a test waits for what should happen.
waitLimit = 10 * time.Second
// pollInterval is how often a test looks again.
pollInterval = 10 * time.Millisecond
// The state files. // The state files.
bansJSON = "bans.json" bansJSON = "bans.json"
clientsJSON = "clients.json" clientsJSON = "clients.json"
@@ -231,41 +228,78 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
} }
} }
func TestBanWrittenAfterTheWriteDelay(t *testing.T) { // The two tests below run Run in a synctest bubble, where time is a clock
// of the test's own: time.Sleep moves it on at once, and synctest.Wait
// returns once Run waits for its next write, so that every write due by
// then is on disk.
func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
t.Parallel() t.Parallel()
dir := t.TempDir() synctest.Test(t, func(t *testing.T) {
params := newParams(dir) dir := t.TempDir()
params.WriteDelay = 10 * time.Millisecond params := newParams(dir)
run(t, load(t, params)) params.WriteDelay = 10 * time.Second
run(t, load(t, params))
ban := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), // A second ban, made while the first waits to be written, puts the
midnight(), bans.Notes{Limit: 1}) // write off no further, and is written with it.
first := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
midnight(), bans.Notes{})
waitForFile(t, filepath.Join(dir, bansJSON)) time.Sleep(5 * time.Second)
read := newParams(dir) second := params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
load(t, read) midnight(), bans.Notes{})
if got := read.Ledger.Snapshot(); !slices.Equal(got, []bans.Ban{ban}) { time.Sleep(5*time.Second - time.Nanosecond)
t.Errorf("bans.json holds %+v, want %+v", got, ban) synctest.Wait()
} wantFiles(t, dir)
// The other files wait for the interval, an hour away. time.Sleep(time.Nanosecond)
wantFiles(t, dir, bansJSON) synctest.Wait()
wantFiles(t, dir, bansJSON)
read := newParams(dir)
load(t, read)
want := []bans.Ban{first, second}
if got := read.Ledger.Snapshot(); !slices.Equal(got, want) {
t.Errorf("bans.json holds %+v, want %+v", got, want)
}
// That write was the only one: bans.json is not written again for
// the second ban. The other files wait for the interval, an hour
// away.
removeFiles(t, dir, bansJSON)
time.Sleep(params.WriteDelay)
synctest.Wait()
wantFiles(t, dir)
})
} }
func TestEveryFileWrittenEveryCounterInterval(t *testing.T) { func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
t.Parallel() t.Parallel()
dir := t.TempDir() synctest.Test(t, func(t *testing.T) {
params := newParams(dir) dir := t.TempDir()
params.CounterInterval = 10 * time.Millisecond params := newParams(dir)
run(t, load(t, params)) params.CounterInterval = time.Minute
run(t, load(t, params))
for _, file := range []string{bansJSON, clientsJSON, lookupsJSON} { // The files are removed once written, so that each interval shows
waitForFile(t, filepath.Join(dir, file)) // them written again.
} for range 3 {
time.Sleep(time.Minute - time.Nanosecond)
synctest.Wait()
wantFiles(t, dir)
time.Sleep(time.Nanosecond)
synctest.Wait()
wantFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
removeFiles(t, dir, bansJSON, clientsJSON, lookupsJSON)
}
})
} }
func TestFailedWriteLeavesTheFileAsItWas(t *testing.T) { func TestFailedWriteLeavesTheFileAsItWas(t *testing.T) {
@@ -440,21 +474,16 @@ func readFile(t *testing.T, path string) string {
return string(data) return string(data)
} }
// waitForFile waits for the file at path to exist. // removeFiles removes the named files from dir.
func waitForFile(t *testing.T, path string) { func removeFiles(t *testing.T, dir string, names ...string) {
t.Helper() t.Helper()
deadline := time.Now().Add(waitLimit) for _, name := range names {
for time.Now().Before(deadline) { err := os.Remove(filepath.Join(dir, name))
_, err := os.Stat(path) if err != nil {
if err == nil { t.Fatalf("remove: %v", err)
return
} }
time.Sleep(pollInterval)
} }
t.Fatalf("no %s after %s", path, waitLimit)
} }
// wantFiles checks the names of the files in dir. // wantFiles checks the names of the files in dir.