Sub-threshold finding from a focused security review of the CI URL work — defense-in-depth even though it sits inside the documented "trust upstream" boundary. The mirror walker's purgeOrphans deletes local files that aren't in the upstream's listing. It walked a dirPath built recursively from upstream-supplied entry names and called os.Remove on the resolved local path with no containment check. A hostile or compromised upstream returning ".." in a directory listing could steer the walker out of cache.root and into the parent — deleting whatever matches the upstream's "expected to be there" filter in the wrong directory. A healthy master never produces such entries (listing.FromDirEntries filters dot-prefix names), so the bug only fires under an actively malicious or MITM'd upstream — confidence stayed below the report threshold. But the fix is small and the cost of being wrong is real deletion of files outside the cache, so it's worth doing. Two layers: 1. walker.go walkDir filters upstream listing entries with name == "" / "." / ".." or containing "/" / "\" before recursing. Logs a WARN with the dropped name so an operator can see if their upstream is misbehaving. 2. purgeOrphans verifies the resolved localDir is contained under s.cache.root (HasPrefix(root + sep) || == root) before ReadDir+Remove. Logs a WARN and bails on mismatch. Either layer alone would fix the original vector; both together match the defense-in-depth pattern cachePathFor already follows for single-file writes (line 506). New TestWalker_HostileUpstreamCannotEscapeCacheRoot constructs a fake upstream that returns a "../" entry in its listing, places a sentinel file in the parent of cache.root, runs a mirror walk, and asserts the sentinel survives. Both filter and containment guard fire; the sentinel stays put. Existing mirror tests unchanged — the filter only drops names that shouldn't appear in healthy listings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
495 lines
16 KiB
Go
495 lines
16 KiB
Go
package cache
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
)
|
|
|
|
// fakeUpstream returns a handler that serves a small fixed tree:
|
|
//
|
|
// /Vendors/Acme/ → JSON listing of [a.txt, b.txt, sub/]
|
|
// /Vendors/Acme/a.txt → "alpha"
|
|
// /Vendors/Acme/b.txt → "beta"
|
|
// /Vendors/Acme/sub/ → JSON listing of [c.txt]
|
|
// /Vendors/Acme/sub/c.txt → "charlie"
|
|
// /Vendors/Beta/ → JSON listing of [out-of-scope.txt]
|
|
// /Vendors/Beta/out-of-scope.txt → "should-not-be-fetched"
|
|
//
|
|
// hit counts every URL request so tests can assert which paths the
|
|
// walker visits.
|
|
func fakeUpstream() (http.HandlerFunc, *sync.Map) {
|
|
hits := &sync.Map{}
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
old, _ := hits.LoadOrStore(r.URL.Path, int64(0))
|
|
hits.Store(r.URL.Path, old.(int64)+1)
|
|
switch r.URL.Path {
|
|
case "/Vendors/Acme/":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"name":"a.txt","is_dir":false},{"name":"b.txt","is_dir":false},{"name":"sub/","is_dir":true}]`))
|
|
case "/Vendors/Acme/a.txt":
|
|
_, _ = w.Write([]byte("alpha"))
|
|
case "/Vendors/Acme/b.txt":
|
|
_, _ = w.Write([]byte("beta"))
|
|
case "/Vendors/Acme/sub/":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"name":"c.txt","is_dir":false}]`))
|
|
case "/Vendors/Acme/sub/c.txt":
|
|
_, _ = w.Write([]byte("charlie"))
|
|
case "/Vendors/Beta/":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"name":"out-of-scope.txt","is_dir":false}]`))
|
|
case "/Vendors/Beta/out-of-scope.txt":
|
|
_, _ = w.Write([]byte("should-not-be-fetched"))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}, hits
|
|
}
|
|
|
|
func newWalkerTest(t *testing.T, subtrees []string, minInterval time.Duration) (*Cache, *MirrorScheduler, *httptest.Server, *sync.Map) {
|
|
t.Helper()
|
|
handler, hits := fakeUpstream()
|
|
upstream := httptest.NewServer(handler)
|
|
t.Cleanup(upstream.Close)
|
|
root := t.TempDir()
|
|
c, err := New(config.Config{Root: root, Upstream: upstream.URL, Mode: "mirror"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
sched, err := NewMirrorScheduler(c, subtrees, minInterval, 0)
|
|
if err != nil {
|
|
t.Fatalf("NewMirrorScheduler: %v", err)
|
|
}
|
|
if sched == nil {
|
|
t.Fatal("expected scheduler, got nil")
|
|
}
|
|
return c, sched, upstream, hits
|
|
}
|
|
|
|
// hitsCount returns the number of times urlPath was hit on upstream.
|
|
func hitsCount(hits *sync.Map, urlPath string) int64 {
|
|
v, ok := hits.Load(urlPath)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return v.(int64)
|
|
}
|
|
|
|
// waitForFile polls until the named file exists or the deadline
|
|
// passes. Used to wait on background walker writes without sleep loops.
|
|
func waitForFile(t *testing.T, path string, deadline time.Duration) {
|
|
t.Helper()
|
|
end := time.Now().Add(deadline)
|
|
for time.Now().Before(end) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("file did not appear within %v: %s", deadline, path)
|
|
}
|
|
|
|
func TestNewMirrorScheduler_NoSubtrees(t *testing.T) {
|
|
root := t.TempDir()
|
|
c, _ := New(config.Config{Root: root, Upstream: "http://example.com", Mode: "cache"})
|
|
sched, err := NewMirrorScheduler(c, nil, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if sched != nil {
|
|
t.Errorf("expected nil scheduler for empty subtree list")
|
|
}
|
|
}
|
|
|
|
func TestMirrorScheduler_NormalizesSubtrees(t *testing.T) {
|
|
root := t.TempDir()
|
|
c, _ := New(config.Config{Root: root, Upstream: "http://example.com", Mode: "mirror"})
|
|
sched, _ := NewMirrorScheduler(c, []string{"Vendors/Acme/", "/Vendors/Beta", " Public ", "/"}, 0, 0)
|
|
got := sched.Subtrees()
|
|
want := []string{"/Vendors/Acme", "/Vendors/Beta", "/Public", "/"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("subtrees = %v, want %v", got, want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("subtree[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchSubtree(t *testing.T) {
|
|
c, _ := New(config.Config{Root: t.TempDir(), Upstream: "http://example.com", Mode: "mirror"})
|
|
sched, _ := NewMirrorScheduler(c, []string{"/Vendors/Acme", "/Public"}, 0, 0)
|
|
cases := []struct {
|
|
url string
|
|
want string
|
|
}{
|
|
{"/Vendors/Acme/foo.txt", "/Vendors/Acme"},
|
|
{"/Vendors/Acme", "/Vendors/Acme"},
|
|
{"/Vendors/Acme/sub/x.txt", "/Vendors/Acme"},
|
|
{"/Vendors/Beta/x.txt", ""},
|
|
{"/Public/file", "/Public"},
|
|
{"/Other", ""},
|
|
{"", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := sched.matchSubtree(tc.url); got != tc.want {
|
|
t.Errorf("matchSubtree(%q) = %q, want %q", tc.url, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchSubtree_RootMatchesEverything(t *testing.T) {
|
|
c, _ := New(config.Config{Root: t.TempDir(), Upstream: "http://example.com", Mode: "mirror"})
|
|
sched, _ := NewMirrorScheduler(c, []string{"/"}, 0, 0)
|
|
for _, u := range []string{"/anything", "/Project/X", "/"} {
|
|
if got := sched.matchSubtree(u); got != "/" {
|
|
t.Errorf("matchSubtree(%q) = %q, want /", u, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTrigger_FetchesSubtree(t *testing.T) {
|
|
c, sched, _, hits := newWalkerTest(t, []string{"/Vendors/Acme"}, 5*time.Millisecond)
|
|
|
|
// Trigger via a request URL under the subtree.
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
// Wait for the walker to drop all expected files.
|
|
waitForFile(t, filepath.Join(c.root, "Vendors", "Acme", "a.txt"), 2*time.Second)
|
|
waitForFile(t, filepath.Join(c.root, "Vendors", "Acme", "b.txt"), 2*time.Second)
|
|
waitForFile(t, filepath.Join(c.root, "Vendors", "Acme", "sub", "c.txt"), 2*time.Second)
|
|
|
|
// JSON listing sidecars exist for the walked dirs.
|
|
waitForFile(t, filepath.Join(c.root, "Vendors", "Acme", listingCachePrefix+"json"), 2*time.Second)
|
|
waitForFile(t, filepath.Join(c.root, "Vendors", "Acme", "sub", listingCachePrefix+"json"), 2*time.Second)
|
|
|
|
// Out-of-scope path was not visited.
|
|
if got := hitsCount(hits, "/Vendors/Beta/"); got != 0 {
|
|
t.Errorf("walker hit out-of-scope /Vendors/Beta/ %d times", got)
|
|
}
|
|
}
|
|
|
|
func TestTrigger_OutOfScopeIsNoOp(t *testing.T) {
|
|
_, sched, _, hits := newWalkerTest(t, []string{"/Vendors/Acme"}, 5*time.Millisecond)
|
|
sched.Trigger("/Other/path.txt")
|
|
time.Sleep(150 * time.Millisecond)
|
|
// No upstream calls should have happened.
|
|
count := 0
|
|
hits.Range(func(_, _ interface{}) bool { count++; return true })
|
|
if count != 0 {
|
|
t.Errorf("triggered %d upstream calls for out-of-scope URL", count)
|
|
}
|
|
}
|
|
|
|
func TestTrigger_RateLimitsRapidAccesses(t *testing.T) {
|
|
_, sched, _, hits := newWalkerTest(t, []string{"/Vendors/Acme"}, 1*time.Hour)
|
|
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
// Wait for first walk to complete.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
sched.mu.Lock()
|
|
flight := sched.inFlight["/Vendors/Acme"]
|
|
st := sched.state["/Vendors/Acme"]
|
|
sched.mu.Unlock()
|
|
if !flight && !st.LastWalkAt.IsZero() {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
first := hitsCount(hits, "/Vendors/Acme/a.txt")
|
|
// Subsequent triggers within the min-interval are no-ops.
|
|
for i := 0; i < 5; i++ {
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
second := hitsCount(hits, "/Vendors/Acme/a.txt")
|
|
if second != first {
|
|
t.Errorf("rate limit failed: hits went %d → %d", first, second)
|
|
}
|
|
}
|
|
|
|
func TestTrigger_AfterIntervalElapsesWalksAgain(t *testing.T) {
|
|
_, sched, _, hits := newWalkerTest(t, []string{"/Vendors/Acme"}, 50*time.Millisecond)
|
|
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
sched.mu.Lock()
|
|
flight := sched.inFlight["/Vendors/Acme"]
|
|
sched.mu.Unlock()
|
|
if !flight {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
first := hitsCount(hits, "/Vendors/Acme/a.txt")
|
|
|
|
// Wait past min-interval, trigger again, walk should re-fire.
|
|
time.Sleep(100 * time.Millisecond)
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
deadline = time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if hitsCount(hits, "/Vendors/Acme/a.txt") > first {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
second := hitsCount(hits, "/Vendors/Acme/a.txt")
|
|
if second <= first {
|
|
t.Errorf("expected walk after interval; hits %d → %d", first, second)
|
|
}
|
|
}
|
|
|
|
func TestTrigger_PurgesOrphanedFiles(t *testing.T) {
|
|
c, sched, _, _ := newWalkerTest(t, []string{"/Vendors/Acme"}, 5*time.Millisecond)
|
|
|
|
// Pre-seed an orphan that's NOT in upstream's listing.
|
|
if err := os.MkdirAll(filepath.Join(c.root, "Vendors", "Acme"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
orphan := filepath.Join(c.root, "Vendors", "Acme", "stale.txt")
|
|
if err := os.WriteFile(orphan, []byte("orphan"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
waitForFile(t, filepath.Join(c.root, "Vendors", "Acme", "a.txt"), 2*time.Second)
|
|
|
|
// Wait for walk to fully complete (orphan purge happens after fetches).
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
sched.mu.Lock()
|
|
flight := sched.inFlight["/Vendors/Acme"]
|
|
sched.mu.Unlock()
|
|
if !flight {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
|
|
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
|
|
t.Errorf("orphan file not purged: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPersistedState_SurvivesRestart(t *testing.T) {
|
|
c, sched, _, _ := newWalkerTest(t, []string{"/Vendors/Acme"}, 5*time.Millisecond)
|
|
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
sched.mu.Lock()
|
|
flight := sched.inFlight["/Vendors/Acme"]
|
|
st := sched.state["/Vendors/Acme"]
|
|
sched.mu.Unlock()
|
|
if !flight && !st.LastWalkAt.IsZero() {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
|
|
// State file should exist.
|
|
statePath := filepath.Join(c.root, MirrorStateFile)
|
|
if _, err := os.Stat(statePath); err != nil {
|
|
t.Fatalf("state file missing: %v", err)
|
|
}
|
|
|
|
// New scheduler against the same root reads the prior state and
|
|
// honors the rate-limit gate.
|
|
sched2, _ := NewMirrorScheduler(c, []string{"/Vendors/Acme"}, 1*time.Hour, 0)
|
|
st := sched2.state["/Vendors/Acme"]
|
|
if st.LastWalkAt.IsZero() {
|
|
t.Error("restart did not load prior LastWalkAt from state file")
|
|
}
|
|
}
|
|
|
|
func TestTrigger_ConcurrentSameSubtreeDoesNotDoubleWalk(t *testing.T) {
|
|
_, sched, _, hits := newWalkerTest(t, []string{"/Vendors/Acme"}, 1*time.Hour)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
sched.mu.Lock()
|
|
flight := sched.inFlight["/Vendors/Acme"]
|
|
sched.mu.Unlock()
|
|
if !flight {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
// Each file should have been fetched exactly once even though we
|
|
// triggered 10 times concurrently.
|
|
if got := hitsCount(hits, "/Vendors/Acme/a.txt"); got != 1 {
|
|
t.Errorf("a.txt fetched %d times, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_KicksMirrorOnAccess(t *testing.T) {
|
|
// End-to-end through the cache layer's ServeHTTP, verifying the
|
|
// onAccess hook fires and the walker prefetches.
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/Vendors/Acme/":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"name":"only.txt","is_dir":false}]`))
|
|
case "/Vendors/Acme/only.txt":
|
|
_, _ = w.Write([]byte("data"))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
root := t.TempDir()
|
|
c, err := New(config.Config{Root: root, Upstream: upstream.URL, Mode: "mirror"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
sched, err := NewMirrorScheduler(c, []string{"/Vendors/Acme"}, 5*time.Millisecond, 0)
|
|
if err != nil {
|
|
t.Fatalf("NewMirrorScheduler: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/Vendors/Acme/only.txt", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
// The user's request itself fetched only.txt. Then the walker
|
|
// (kicked async) fetches the listing + only.txt. So only.txt may
|
|
// be fetched twice. The walker MUST have hit the listing.
|
|
waitForFile(t, filepath.Join(root, "Vendors", "Acme", listingCachePrefix+"json"), 2*time.Second)
|
|
waitForFile(t, filepath.Join(root, "Vendors", "Acme", "only.txt"), 2*time.Second)
|
|
|
|
// Drain the walk before t.TempDir cleanup races with file writes.
|
|
waitForWalkDrain(t, sched, "/Vendors/Acme", 2*time.Second)
|
|
}
|
|
|
|
// waitForWalkDrain blocks until no walk is in flight for subtree.
|
|
// Avoids TempDir cleanup races where the walker is still writing
|
|
// when the test finishes.
|
|
func waitForWalkDrain(t *testing.T, sched *MirrorScheduler, subtree string, deadline time.Duration) {
|
|
t.Helper()
|
|
end := time.Now().Add(deadline)
|
|
for time.Now().Before(end) {
|
|
sched.mu.Lock()
|
|
flight := sched.inFlight[subtree]
|
|
sched.mu.Unlock()
|
|
if !flight {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("walk for %s did not drain within %v", subtree, deadline)
|
|
}
|
|
|
|
func TestPersistMirrorState_AtomicWrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, MirrorStateFile)
|
|
state := map[string]subtreeState{
|
|
"/Vendors/Acme": {LastWalkAt: time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)},
|
|
}
|
|
persistMirrorState(path, state)
|
|
|
|
got := loadMirrorState(path)
|
|
if got["/Vendors/Acme"].LastWalkAt.IsZero() {
|
|
t.Error("round-trip lost LastWalkAt")
|
|
}
|
|
// No leftover tmp files.
|
|
entries, _ := os.ReadDir(dir)
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), ".zddc-mirror-state-tmp-") {
|
|
t.Errorf("leftover tmp: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadMirrorState_MissingReturnsEmpty(t *testing.T) {
|
|
got := loadMirrorState(filepath.Join(t.TempDir(), "does-not-exist"))
|
|
if got == nil || len(got) != 0 {
|
|
t.Errorf("expected empty map, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadMirrorState_CorruptReturnsEmpty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "garbage")
|
|
_ = os.WriteFile(path, []byte("not json"), 0o644)
|
|
got := loadMirrorState(path)
|
|
if got == nil || len(got) != 0 {
|
|
t.Errorf("corrupt should yield empty map, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestWalker_HostileUpstreamCannotEscapeCacheRoot: a malicious upstream
|
|
// listing that includes ".." or slash-bearing entry names must not steer
|
|
// the walker's purge or fetch outside the cache root. The walker drops
|
|
// such entries early; purgeOrphans's containment check is a second line
|
|
// of defense if any slip through.
|
|
func TestWalker_HostileUpstreamCannotEscapeCacheRoot(t *testing.T) {
|
|
// Set up a parent directory we'd like NOT to lose files from.
|
|
parent := t.TempDir()
|
|
sentinel := filepath.Join(parent, "sentinel.txt")
|
|
if err := os.WriteFile(sentinel, []byte("must-not-be-deleted"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cacheRoot := filepath.Join(parent, "cache")
|
|
if err := os.MkdirAll(cacheRoot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Hostile upstream: returns a listing for /Vendors/Acme/ that
|
|
// includes a ".." entry. A naive walker would recurse into
|
|
// /Vendors/Acme/../ and then purgeOrphans would operate on the
|
|
// PARENT of cacheRoot — deleting the sentinel.
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/Vendors/Acme/":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[
|
|
{"name":"a.txt","is_dir":false},
|
|
{"name":"../","is_dir":true},
|
|
{"name":"sub/with/slashes","is_dir":false}
|
|
]`))
|
|
case "/Vendors/Acme/a.txt":
|
|
_, _ = w.Write([]byte("alpha"))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
c, err := New(config.Config{Root: cacheRoot, Upstream: upstream.URL, Mode: "mirror"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
sched, err := NewMirrorScheduler(c, []string{"/Vendors/Acme"}, 5*time.Millisecond, 0)
|
|
if err != nil {
|
|
t.Fatalf("NewMirrorScheduler: %v", err)
|
|
}
|
|
|
|
sched.Trigger("/Vendors/Acme/a.txt")
|
|
waitForFile(t, filepath.Join(cacheRoot, "Vendors", "Acme", "a.txt"), 2*time.Second)
|
|
|
|
// The sentinel outside the cache root must still be there.
|
|
if _, err := os.Stat(sentinel); err != nil {
|
|
t.Fatalf("sentinel was removed — walker escaped cache root: %v", err)
|
|
}
|
|
}
|