From bc68eb75fc8de7600b9b92c82378bd762b36a247 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 17 Oct 2026 12:19:51 +1100 Subject: [PATCH 1/2] Add nuget v3 remote type (#141) .NET builds restore from nuget.org directly because artifactapi has no NuGet remote type. - add `nuget` package type proxying a NuGet v3 feed; `index.json` maps to the upstream service index - add a `ServeRewriter` provider hook so cached JSON has upstream-origin URLs pointed back at the requesting host per request - treat `.nupkg`/`.nuspec` as immutable and service, registration and flat container indexes as mutable - add a `nuget.config` usage snippet to the UI and a nuget e2e caching case Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/141 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- README.md | 3 +- e2e-docker/README.md | 2 +- e2e-docker/caching_test.go | 1 + .../nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg | Bin 0 -> 22 bytes internal/api/v1/proxy.go | 14 ++- internal/provider/nuget/nuget.go | 85 ++++++++++++++++++ internal/provider/nuget/nuget_test.go | 68 ++++++++++++++ internal/provider/provider.go | 7 ++ internal/server/server.go | 1 + pkg/models/package_type.go | 2 + pkg/models/package_type_test.go | 1 + ui/src/components/UsageInstructions.tsx | 16 ++++ ui/src/pages/Locals.tsx | 1 + ui/src/pages/Remotes.tsx | 1 + 14 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 e2e-docker/fixtures/nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg create mode 100644 internal/provider/nuget/nuget.go create mode 100644 internal/provider/nuget/nuget_test.go diff --git a/README.md b/README.md index 660a658..5b4a4e9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ArtifactAPI -Caching proxy for package repositories. Single Go binary, 11 package types, content-addressable storage, managed by Terraform. +Caching proxy for package repositories. Single Go binary, 12 package types, content-addressable storage, managed by Terraform. ## Quick Start @@ -33,6 +33,7 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173` | `terraform` | `*/versions` | `*/download/*/*` | | `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` | | `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` | +| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` | | `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) | Providers classify paths automatically. Users only configure what to proxy and TTLs. diff --git a/e2e-docker/README.md b/e2e-docker/README.md index 3125d23..ec2ee0c 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -23,7 +23,7 @@ already-running stack. - **Repository lifecycle** — add / change / delete for remote, local and virtual repos. - **Caching** — one immutable artifact per remote package type (generic, docker, - helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo) proxied through the + helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes verified against the origin fixture. - **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/` diff --git a/e2e-docker/caching_test.go b/e2e-docker/caching_test.go index d04ed6f..420d7f1 100644 --- a/e2e-docker/caching_test.go +++ b/e2e-docker/caching_test.go @@ -30,6 +30,7 @@ func TestCachingPerProvider(t *testing.T) { {"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"}, {"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"}, {"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"}, + {"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"}, {"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"}, {"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"}, } diff --git a/e2e-docker/fixtures/nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg b/e2e-docker/fixtures/nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..15cb0ecb3e219d1701294bfdf0fe3f5cb5d208e7 GIT binary patch literal 22 NcmWIWW@Tf*000g10H*)| literal 0 HcmV?d00001 diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index e77f43e..7429007 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -1,6 +1,7 @@ package v1 import ( + "bytes" "errors" "fmt" "io" @@ -105,13 +106,24 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { } defer result.Reader.Close() + var body io.Reader = result.Reader + if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable { + raw, err := io.ReadAll(result.Reader) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + raw = sr.RewriteServed(raw, *remote, fmt.Sprintf("%s://%s", scheme(r), r.Host)) + body, result.Size = bytes.NewReader(raw), int64(len(raw)) + } + w.Header().Set("Content-Type", result.ContentType) w.Header().Set("X-Artifact-Source", result.Source) if result.Size > 0 { w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size)) } w.WriteHeader(http.StatusOK) - io.Copy(w, result.Reader) + io.Copy(w, body) } func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { diff --git a/internal/provider/nuget/nuget.go b/internal/provider/nuget/nuget.go new file mode 100644 index 0000000..4857173 --- /dev/null +++ b/internal/provider/nuget/nuget.go @@ -0,0 +1,85 @@ +// Package nuget proxies a NuGet v3 feed: the service index, registration pages +// and flat container indexes are mutable, .nupkg/.nuspec downloads immutable. +package nuget + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + + "git.unkin.net/unkin/artifactapi/internal/auth" + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func init() { + provider.Register(&Provider{}) +} + +// serviceIndex is the path clients use as their source URL; it maps to base_url. +const serviceIndex = "index.json" + +type Provider struct{} + +func (p *Provider) Type() models.PackageType { return models.PackageNuGet } + +func (p *Provider) Classify(path string) provider.Mutability { + if isPackage(path) { + return provider.Immutable + } + return provider.Mutable +} + +func (p *Provider) ContentType(path string) string { + switch { + case strings.HasSuffix(path, ".nupkg"): + return "application/octet-stream" + case strings.HasSuffix(path, ".nuspec"): + return "application/xml" + } + return "application/json" +} + +// UpstreamURL maps index.json to base_url (the upstream service index) and any +// other path to the same path on the base_url's origin, where nuget.org serves +// v3-flatcontainer/ and v3/registration*/. +func (p *Provider) UpstreamURL(remote models.Remote, path string) string { + path = strings.TrimLeft(path, "/") + if path == serviceIndex { + return remote.BaseURL + } + return origin(remote.BaseURL) + "/" + path +} + +func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) { + return nil, nil +} + +// RewriteServed points upstream-origin URLs in cached JSON back at this remote. +// It runs per request because the URLs must name the requesting host. +func (p *Provider) RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte { + from := origin(remote.BaseURL) + if from == "" || !json.Valid(body) { + return body + } + to := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + return []byte(strings.ReplaceAll(string(body), from, to)) +} + +func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) { + return auth.BasicHeaders(remote), nil +} + +func origin(baseURL string) string { + u, err := url.Parse(baseURL) + if err != nil || u.Host == "" { + return "" + } + return u.Scheme + "://" + u.Host +} + +func isPackage(path string) bool { + return strings.HasSuffix(path, ".nupkg") || strings.HasSuffix(path, ".nuspec") +} diff --git a/internal/provider/nuget/nuget_test.go b/internal/provider/nuget/nuget_test.go new file mode 100644 index 0000000..688ca06 --- /dev/null +++ b/internal/provider/nuget/nuget_test.go @@ -0,0 +1,68 @@ +package nuget + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +var _ provider.ServeRewriter = (*Provider)(nil) + +var nugetOrg = models.Remote{Name: "nuget-org", BaseURL: "https://api.nuget.org/v3/index.json"} + +func TestClassify(t *testing.T) { + p := &Provider{} + tests := []struct { + path string + want provider.Mutability + }{ + {"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", provider.Immutable}, + {"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.nuspec", provider.Immutable}, + {"v3-flatcontainer/newtonsoft.json/index.json", provider.Mutable}, + {"v3/registration5-gz-semver2/newtonsoft.json/index.json", provider.Mutable}, + {"index.json", provider.Mutable}, + } + for _, tt := range tests { + if got := p.Classify(tt.path); got != tt.want { + t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestUpstreamURL(t *testing.T) { + p := &Provider{} + tests := []struct { + path, want string + }{ + {"index.json", "https://api.nuget.org/v3/index.json"}, + {"/index.json", "https://api.nuget.org/v3/index.json"}, + {"v3-flatcontainer/serilog/index.json", "https://api.nuget.org/v3-flatcontainer/serilog/index.json"}, + {"v3/registration5-gz-semver2/serilog/index.json", "https://api.nuget.org/v3/registration5-gz-semver2/serilog/index.json"}, + } + for _, tt := range tests { + if got := p.UpstreamURL(nugetOrg, tt.path); got != tt.want { + t.Errorf("UpstreamURL(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + +func TestRewriteServed(t *testing.T) { + p := &Provider{} + body := `{"resources":[` + + `{"@id":"https://api.nuget.org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` + + `{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}` + want := `{"resources":[` + + `{"@id":"http://aa.example:8000/api/v1/remote/nuget-org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` + + `{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}` + if got := string(p.RewriteServed([]byte(body), nugetOrg, "http://aa.example:8000/")); got != want { + t.Errorf("RewriteServed =\n%s\nwant\n%s", got, want) + } + if got := string(p.RewriteServed([]byte(body), models.Remote{Name: "x"}, "http://aa.example")); got != body { + t.Error("remote without a parseable base_url must pass the body through") + } + cert := "\x30\x82https://api.nuget.org" + if got := string(p.RewriteServed([]byte(cert), nugetOrg, "http://aa.example")); got != cert { + t.Error("non-JSON bodies must pass through untouched") + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8de9201..4c005f3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -75,6 +75,13 @@ type RemoteServer interface { ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, store RemoteMetadataStore) bool } +// ServeRewriter rewrites a cached mutable remote response per request, for +// indexes whose URLs must name the requesting host and so cannot be rewritten +// before caching. +type ServeRewriter interface { + RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte +} + // RemoteMetadataStore is the persistence surface a RemoteServer needs to cache // and read the metadata it derives per upstream asset. *database.DB satisfies it. type RemoteMetadataStore interface { diff --git a/internal/server/server.go b/internal/server/server.go index 88d12ac..eb6adac 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,6 +29,7 @@ import ( _ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy" _ "git.unkin.net/unkin/artifactapi/internal/provider/helm" _ "git.unkin.net/unkin/artifactapi/internal/provider/npm" + _ "git.unkin.net/unkin/artifactapi/internal/provider/nuget" _ "git.unkin.net/unkin/artifactapi/internal/provider/puppet" _ "git.unkin.net/unkin/artifactapi/internal/provider/pypi" "git.unkin.net/unkin/artifactapi/internal/provider/rpm" diff --git a/pkg/models/package_type.go b/pkg/models/package_type.go index 52c4cc2..5e310cb 100644 --- a/pkg/models/package_type.go +++ b/pkg/models/package_type.go @@ -17,6 +17,7 @@ const ( PackageTerraform PackageType = "terraform" PackageGoProxy PackageType = "goproxy" PackageCargo PackageType = "cargo" + PackageNuGet PackageType = "nuget" PackageGitHubRPM PackageType = "github_rpm" PackageGitHubDeb PackageType = "github_deb" PackageGitHubAlpine PackageType = "github_alpine" @@ -35,6 +36,7 @@ var validPackageTypes = map[PackageType]bool{ PackageTerraform: true, PackageGoProxy: true, PackageCargo: true, + PackageNuGet: true, PackageGitHubRPM: true, PackageGitHubDeb: true, PackageGitHubAlpine: true, diff --git a/pkg/models/package_type_test.go b/pkg/models/package_type_test.go index 5143487..dd519cf 100644 --- a/pkg/models/package_type_test.go +++ b/pkg/models/package_type_test.go @@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) { models.PackageTerraform, models.PackageGoProxy, models.PackageCargo, + models.PackageNuGet, models.PackageGitHubRPM, models.PackageGitHubDeb, models.PackageGitHubAlpine, diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx index 228a924..92c1e57 100644 --- a/ui/src/components/UsageInstructions.tsx +++ b/ui/src/components/UsageInstructions.tsx @@ -243,6 +243,22 @@ index = "sparse+${proxy}/"`, }, ]; + case 'nuget': + return [ + { + title: 'Use this feed instead of nuget.org', + language: 'xml', + code: ` + + + + + +`, + note: 'Serve artifactapi over HTTPS: NuGet rejects the repository signatures resource over plain HTTP.', + }, + ]; + case 'puppet': return [ { diff --git a/ui/src/pages/Locals.tsx b/ui/src/pages/Locals.tsx index 83c3fca..ba895ee 100644 --- a/ui/src/pages/Locals.tsx +++ b/ui/src/pages/Locals.tsx @@ -18,6 +18,7 @@ const typeColors: Record Date: Sat, 17 Oct 2026 12:31:49 +1100 Subject: [PATCH 2/2] Share the virtual merge cache across index types --- internal/virtual/engine.go | 90 ++++++++++++++++++++------------- internal/virtual/engine_test.go | 50 ++++++++++++++++++ 2 files changed, 105 insertions(+), 35 deletions(-) diff --git a/internal/virtual/engine.go b/internal/virtual/engine.go index d4c52d0..1f8bf7c 100644 --- a/internal/virtual/engine.go +++ b/internal/virtual/engine.go @@ -29,15 +29,24 @@ type Engine struct { mergeTTL time.Duration - sf singleflight.Group - mu sync.Mutex - rpm map[string]*rpmGen + sf singleflight.Group + mu sync.Mutex + gens map[string]*repoGen } -// rpmGen holds a virtual's current merge plus the previous one, so a client -// holding the previous repomd can still fetch its content-hashed data files. -type rpmGen struct { - cur, prev *RPMRepo +// mergedRepo is a merged index document plus the files it references. +type mergedRepo interface { + index() []byte + files() map[string][]byte +} + +func (r *RPMRepo) index() []byte { return r.Repomd } +func (r *RPMRepo) files() map[string][]byte { return r.Files } + +// repoGen holds a virtual's current merge plus the previous one, so a client +// holding the previous index can still fetch the files it references. +type repoGen struct { + cur, prev mergedRepo at time.Time } @@ -217,54 +226,57 @@ func (e *Engine) fetchRPM(ctx context.Context, virt models.Virtual, path string) if !strings.HasPrefix(path, "repodata/") { return nil, "", ErrNotFound } + key := "rpm\x00" + virt.Name if path != "repodata/repomd.xml" { - if body, ok := e.cachedRPMFile(virt.Name, path); ok { + if body, ok := e.cachedFile(key, path); ok { return body, "application/gzip", nil } } - repo, err := e.mergedRPM(ctx, virt) + repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) { + return e.mergeRPM(ctx, virt) + }) if err != nil { return nil, "", err } if path == "repodata/repomd.xml" { - return repo.Repomd, "application/xml", nil + return repo.index(), "application/xml", nil } - if body, ok := repo.Files[path]; ok { + if body, ok := repo.files()[path]; ok { return body, "application/gzip", nil } return nil, "", ErrNotFound } -// mergedRPM returns the virtual's merge, reusing it for mergeTTL and -// collapsing concurrent merges of the same virtual into one. +// merged returns the merge cached under key, reusing it for mergeTTL and +// collapsing concurrent merges of the same key into one. // ponytail: per-replica cache; behind a non-sticky LB a data request landing on // another replica re-merges and 404s if a member changed in between. Move to the // shared redis cache if that shows up. -func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) { +func (e *Engine) merged(ctx context.Context, key string, merge func(context.Context) (mergedRepo, error)) (mergedRepo, error) { e.mu.Lock() - g := e.rpm[virt.Name] + g := e.gens[key] if g != nil && time.Since(g.at) < e.mergeTTL { e.mu.Unlock() return g.cur, nil } e.mu.Unlock() - v, err, _ := e.sf.Do(virt.Name, func() (any, error) { - repo, err := e.mergeRPM(context.WithoutCancel(ctx), virt) + v, err, _ := e.sf.Do(key, func() (any, error) { + repo, err := merge(context.WithoutCancel(ctx)) if err != nil { return nil, err } e.mu.Lock() defer e.mu.Unlock() - if e.rpm == nil { - e.rpm = map[string]*rpmGen{} + if e.gens == nil { + e.gens = map[string]*repoGen{} } - g := e.rpm[virt.Name] + g := e.gens[key] switch { case g == nil: - e.rpm[virt.Name] = &rpmGen{cur: repo, at: time.Now()} - case string(g.cur.Repomd) == string(repo.Repomd): + e.gens[key] = &repoGen{cur: repo, at: time.Now()} + case string(g.cur.index()) == string(repo.index()): g.at = time.Now() repo = g.cur default: @@ -275,18 +287,31 @@ func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, if err != nil { return nil, err } - return v.(*RPMRepo), nil + return v.(mergedRepo), nil } func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) { - members := make([]RPMMember, len(virt.Members)) + members, err := fetchMembers(virt, func(name string) (*RPMMember, error) { return e.rpmMember(ctx, name) }) + if err != nil { + return nil, err + } + repo, err := MergeRPM(members) + if err != nil { + return nil, fmt.Errorf("merge rpm repodata: %w", err) + } + return repo, nil +} + +// fetchMembers reads every member in parallel, failing if any member fails. +func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error)) ([]T, error) { + members := make([]T, len(virt.Members)) errs := make([]error, len(virt.Members)) var wg sync.WaitGroup for i, name := range virt.Members { wg.Add(1) go func() { defer wg.Done() - m, err := e.rpmMember(ctx, name) + m, err := fetch(name) if err != nil { errs[i] = fmt.Errorf("member %q: %w", name, err) return @@ -299,26 +324,21 @@ func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, e if err := errors.Join(errs...); err != nil { return nil, fmt.Errorf("virtual %q: %v", virt.Name, err) } - - repo, err := MergeRPM(members) - if err != nil { - return nil, fmt.Errorf("merge rpm repodata: %w", err) - } - return repo, nil + return members, nil } -func (e *Engine) cachedRPMFile(virt, path string) ([]byte, bool) { +func (e *Engine) cachedFile(key, path string) ([]byte, bool) { e.mu.Lock() defer e.mu.Unlock() - g := e.rpm[virt] + g := e.gens[key] if g == nil { return nil, false } - for _, r := range []*RPMRepo{g.cur, g.prev} { + for _, r := range []mergedRepo{g.cur, g.prev} { if r == nil { continue } - if body, ok := r.Files[path]; ok { + if body, ok := r.files()[path]; ok { return body, true } } diff --git a/internal/virtual/engine_test.go b/internal/virtual/engine_test.go index d1c28c9..cb7a334 100644 --- a/internal/virtual/engine_test.go +++ b/internal/virtual/engine_test.go @@ -156,3 +156,53 @@ func TestMemberRedirect(t *testing.T) { } } } + +type fakeRepo struct { + idx string + fs map[string][]byte +} + +func (r *fakeRepo) index() []byte { return []byte(r.idx) } +func (r *fakeRepo) files() map[string][]byte { return r.fs } + +func genRepo(gen string) func(context.Context) (mergedRepo, error) { + return func(context.Context) (mergedRepo, error) { + return &fakeRepo{idx: gen, fs: map[string][]byte{gen + ".gz": []byte(gen), "fixed": []byte(gen)}}, nil + } +} + +func TestMergedRetainsPreviousGeneration(t *testing.T) { + e := fakeEngine(nil) + for _, gen := range []string{"g1", "g2", "g3"} { + if _, err := e.merged(context.Background(), "k", genRepo(gen)); err != nil { + t.Fatal(err) + } + } + for path, want := range map[string]string{"g3.gz": "g3", "g2.gz": "g2", "fixed": "g3"} { + if body, ok := e.cachedFile("k", path); !ok || string(body) != want { + t.Errorf("%s: got (%q, %v), want %q", path, body, ok, want) + } + } + if _, ok := e.cachedFile("k", "g1.gz"); ok { + t.Error("generation two merges back must be dropped") + } +} + +func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) { + e := fakeEngine(nil) + e.mergeTTL = time.Minute + for _, step := range []struct{ key, gen, want string }{ + {"a", "a1", "a1"}, {"b", "b1", "b1"}, {"a", "a2", "a1"}, + } { + repo, err := e.merged(context.Background(), step.key, genRepo(step.gen)) + if err != nil || string(repo.index()) != step.want { + t.Fatalf("%s: got %v (err %v), want %s", step.key, repo, err, step.want) + } + } + if _, ok := e.cachedFile("b", "a1.gz"); ok { + t.Error("key b served key a's file") + } + if _, ok := e.cachedFile("c", "a1.gz"); ok { + t.Error("unknown key served a file") + } +}