package edge import ( "context" "errors" "fmt" "io" "mime" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // reqStatic is one request the way a browser holding a cached copy makes it: // no redirect following, no transparent gzip (an explicit Accept-Encoding // turns the client's own off), and the validators in hdr. func reqStatic(t *testing.T, method, base, path string, hdr map[string]string) (*http.Response, string) { t.Helper() req, err := http.NewRequest(method, base+path, nil) if err != nil { t.Fatalf("new request: %v", err) } req.Host = "site.example.com" for k, v := range hdr { req.Header.Set(k, v) } client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { t.Fatalf("%s %s%s: %v", method, base, path, err) } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("%s %s%s: read body: %v", method, base, path, err) } return resp, string(b) } // siteServer ships files as version of app into its own store and serves it // from a fresh handler, behind the same compression wrapper the edge applies. func siteServer(t *testing.T, app, version string, files map[string]string) *httptest.Server { t.Helper() srv, _ := siteServerTree(t, app, version, files) return srv } // siteServerTree is siteServer that also says where version's tree is // extracted, for the tests that reach under the handler. func siteServerTree(t *testing.T, app, version string, files map[string]string) (*httptest.Server, string) { t.Helper() store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) priv, pub := testKeys(t) shipSite(t, store, app, version, files) if err := release.Promote(context.Background(), store, priv, release.OpsAccount, app, "canary/x", version); err != nil { t.Fatalf("Promote %s: %v", version, err) } rt := registry.Route{Host: "site.example.com", Kind: "static", App: app, Channel: "canary/x", Status: "ready"} h := newStaticHandler(rt, store, pub, filepath.Join(t.TempDir(), "cache")) h.(*staticSite).recheck = 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if gw := newGzipWriter(w, r); gw != nil { w = gw defer gw.finish() } h.ServeHTTP(w, r) })) t.Cleanup(srv.Close) return srv, h.(*staticSite).dir(siteDirOf(t, store, app, version)) } // A static response used to carry Last-Modified and nothing else, and a // browser handed only that guesses a freshness lifetime — typically a tenth of // the file's age — and reuses its copy without asking. A site last deployed a // month ago kept showing the old page for days after the next deploy // (discussion #195). no-cache makes every load ask; the ETag makes the answer // a cheap 304 until the bytes actually change. func TestStaticResponsesMakeTheBrowserRevalidate(t *testing.T) { _, _, _, srv := staticFixture(t) for _, path := range []string{"/", "/docs/page.html", "/style.css"} { resp, _ := reqStatic(t, "GET", srv.URL, path, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("GET %s = %d, want 200", path, resp.StatusCode) } if got := resp.Header.Get("Cache-Control"); got != "no-cache" { t.Errorf("GET %s Cache-Control = %q, want %q", path, got, "no-cache") } if got := resp.Header.Get("ETag"); !strings.HasPrefix(got, `W/"`) || len(got) < 20 { t.Errorf("GET %s ETag = %q, want a weak content tag", path, got) } // The mtime of an extracted file is when THIS box unpacked it. It is // not a fact about the content and must not be offered as one. if got := resp.Header.Get("Last-Modified"); got != "" { t.Errorf("GET %s Last-Modified = %q, want none", path, got) } } // The tree's own 404 must not be cached on a guess either: the page it // stands in for may exist in the next deploy. It names no representation, // so it carries no validator. resp, _ := reqStatic(t, "GET", srv.URL, "/missing.html", nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("GET /missing.html = %d, want 404", resp.StatusCode) } if got := resp.Header.Get("Cache-Control"); got != "no-cache" { t.Errorf("404 Cache-Control = %q, want %q", got, "no-cache") } if got := resp.Header.Get("ETag"); got != "" { t.Errorf("404 ETag = %q, want none", got) } } // The validator is the file's CONTENT. Unchanged bytes revalidate to a 304 — // across a deploy too, so a release that touched one page does not make every // browser refetch every asset — and changed bytes answer 200, in both // directions: a rollback is as much a change as a deploy. func TestStaticValidatorFollowsTheContent(t *testing.T) { store, _, priv, srv := staticFixture(t) promote := func(ver string) { t.Helper() if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", ver); err != nil { t.Fatalf("Promote %s: %v", ver, err) } } tagOf := func(path string) string { t.Helper() resp, _ := reqStatic(t, "GET", srv.URL, path, nil) tag := resp.Header.Get("ETag") if resp.StatusCode != http.StatusOK || tag == "" { t.Fatalf("GET %s = %d with ETag %q", path, resp.StatusCode, tag) } return tag } homeV1, cssV1 := tagOf("/"), tagOf("/style.css") if homeV1 == cssV1 { t.Fatalf("two different files share the tag %q", homeV1) } resp, _ := reqStatic(t, "GET", srv.URL, "/", map[string]string{"If-None-Match": homeV1}) if resp.StatusCode != http.StatusNotModified { t.Fatalf("revalidating unchanged content = %d, want 304", resp.StatusCode) } // A 304 carries what its 200 would have: the cache updates from it. if got := resp.Header.Get("ETag"); got != homeV1 { t.Errorf("304 ETag = %q, want the 200's %q", got, homeV1) } if got := resp.Header.Get("Cache-Control"); got != "no-cache" { t.Errorf("304 Cache-Control = %q, want %q", got, "no-cache") } // v2 changes the home page and leaves the stylesheet byte-identical. shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2", "style.css": "body{}"}) promote("v2") resp, body := reqStatic(t, "GET", srv.URL, "/", map[string]string{"If-None-Match": homeV1}) if resp.StatusCode != http.StatusOK || body != "home v2" { t.Fatalf("revalidating a changed page after a deploy = %d %q, want 200 %q", resp.StatusCode, body, "home v2") } homeV2 := resp.Header.Get("ETag") if homeV2 == homeV1 { t.Fatalf("the changed page kept the tag %q", homeV1) } resp, _ = reqStatic(t, "GET", srv.URL, "/style.css", map[string]string{"If-None-Match": cssV1}) if resp.StatusCode != http.StatusNotModified { t.Fatalf("revalidating an UNCHANGED file after a deploy = %d, want 304", resp.StatusCode) } promote("v1") // roll back resp, body = reqStatic(t, "GET", srv.URL, "/", map[string]string{"If-None-Match": homeV2}) if resp.StatusCode != http.StatusOK || body != "home v1" { t.Fatalf("revalidating after a rollback = %d %q, want 200 %q", resp.StatusCode, body, "home v1") } if got := resp.Header.Get("ETag"); got != homeV1 { t.Errorf("the rolled-back page's tag = %q, want its original %q", got, homeV1) } } // A browser whose copy predates the ETag holds only the old Last-Modified and // revalidates on the date alone. Against an extraction mtime that answer is // wrong after a rollback — the tree rolled back to was unpacked BEFORE the one // the browser holds, so "modified since?" says no. With no date offered there // is nothing to compare, and the browser gets the bytes. func TestStaticDateOnlyRevalidationGetsTheBytes(t *testing.T) { _, _, _, srv := staticFixture(t) resp, body := reqStatic(t, "GET", srv.URL, "/", map[string]string{ "If-Modified-Since": "Fri, 01 Jan 2100 00:00:00 GMT", }) if resp.StatusCode != http.StatusOK || body != "home v1" { t.Fatalf("date-only revalidation = %d %q, want 200 %q", resp.StatusCode, body, "home v1") } } // A version NAME is not a fact about the bytes. A pruned version can be // re-shipped under its old name with different content, and a hostname can // move between two apps that both have a "v1" — in either case a tag made of // the name would let a browser keep the old page on a 304. func TestStaticSameVersionNameDifferentContent(t *testing.T) { a := siteServer(t, "site", "v1", map[string]string{"index.html": "the first site"}) b := siteServer(t, "other", "v1", map[string]string{"index.html": "a different site"}) respA, _ := reqStatic(t, "GET", a.URL, "/", nil) tagA := respA.Header.Get("ETag") if tagA == "" { t.Fatal("no ETag from the first site") } resp, body := reqStatic(t, "GET", b.URL, "/", map[string]string{"If-None-Match": tagA}) if resp.StatusCode != http.StatusOK || body != "a different site" { t.Fatalf("revalidating one v1 against another app's v1 = %d %q, want 200 %q", resp.StatusCode, body, "a different site") } // And identical bytes agree wherever they are served from — two edges // unpacking the same release must hand out the same tag. c := siteServer(t, "third", "v9", map[string]string{"index.html": "the first site"}) resp, _ = reqStatic(t, "GET", c.URL, "/", map[string]string{"If-None-Match": tagA}) if resp.StatusCode != http.StatusNotModified { t.Fatalf("identical bytes from another handler = %d, want 304", resp.StatusCode) } } // The edge gzips a compressible response and, because gzipped bytes are a // different representation, weakens a strong ETag as it does — but only on // the 200. A 304 and a HEAD pass through untouched, so a strong tag would // come back weak on the page and strong on its revalidation, and a cache may // not update the one from the other. A tag that is weak from the start reads // the same on all three. func TestStaticTagIsTheSameCompressedRevalidatedAndOnHead(t *testing.T) { page := "" + strings.Repeat("a page big enough to be worth compressing. ", 200) + "" srv := siteServer(t, "site", "v1", map[string]string{"index.html": page}) gz := map[string]string{"Accept-Encoding": "gzip"} resp, _ := reqStatic(t, "GET", srv.URL, "/", gz) if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Encoding") != "gzip" { t.Fatalf("GET / = %d, Content-Encoding %q; want a gzipped 200", resp.StatusCode, resp.Header.Get("Content-Encoding")) } tag := resp.Header.Get("ETag") if !strings.HasPrefix(tag, `W/"`) { t.Fatalf("gzipped 200 ETag = %q, want a weak tag", tag) } resp, _ = reqStatic(t, "GET", srv.URL, "/", map[string]string{"Accept-Encoding": "gzip", "If-None-Match": tag}) if resp.StatusCode != http.StatusNotModified { t.Fatalf("revalidating the gzipped page = %d, want 304", resp.StatusCode) } if got := resp.Header.Get("ETag"); got != tag { t.Errorf("304 ETag = %q, want the 200's %q", got, tag) } resp, _ = reqStatic(t, "HEAD", srv.URL, "/", gz) if got := resp.Header.Get("ETag"); got != tag { t.Errorf("HEAD ETag = %q, want the GET's %q", got, tag) } // Asked for by name: with no Accept-Encoding of its own the Go client // requests gzip and hides that it did. resp, body := reqStatic(t, "GET", srv.URL, "/", map[string]string{"Accept-Encoding": "identity"}) if resp.StatusCode != http.StatusOK || body != page || resp.Header.Get("Content-Encoding") != "" { t.Fatalf("identity GET = %d, Content-Encoding %q, %d bytes; want the plain page", resp.StatusCode, resp.Header.Get("Content-Encoding"), len(body)) } if got := resp.Header.Get("ETag"); got != tag { t.Errorf("identity GET ETag = %q, want the gzipped GET's %q", got, tag) } } // What http.ServeFile gave for free and this handler must keep now that it // serves the file itself: the canonical redirect off /index.html, ranges, and // the refusal of a path with a ".." element. func TestStaticKeepsServeFileBehaviour(t *testing.T) { _, _, _, srv := staticFixture(t) resp, _ := reqStatic(t, "GET", srv.URL, "/index.html?x=1", nil) if resp.StatusCode != http.StatusMovedPermanently || resp.Header.Get("Location") != "./?x=1" { t.Fatalf("GET /index.html = %d, Location %q; want 301 to ./?x=1", resp.StatusCode, resp.Header.Get("Location")) } resp, body := reqStatic(t, "GET", srv.URL, "/", map[string]string{"Range": "bytes=0-3"}) if resp.StatusCode != http.StatusPartialContent || body != "home" { t.Fatalf("ranged GET = %d %q, want 206 %q", resp.StatusCode, body, "home") } // A weak tag cannot vouch for a byte range: If-Range falls back to the // whole file, which is the safe answer and the documented cost. tag := resp.Header.Get("ETag") resp, body = reqStatic(t, "GET", srv.URL, "/", map[string]string{"Range": "bytes=0-3", "If-Range": tag}) if resp.StatusCode != http.StatusOK || body != "home v1" { t.Fatalf("If-Range with a weak tag = %d %q, want the full 200", resp.StatusCode, body) } req, err := http.NewRequest("GET", srv.URL+"/", nil) if err != nil { t.Fatal(err) } req.Host = "site.example.com" req.URL.Opaque = "/docs/../index.html" // sent as written, not cleaned by the client resp2, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp2.Body.Close() if resp2.StatusCode != http.StatusBadRequest { t.Fatalf("a path with a .. element = %d, want 400", resp2.StatusCode) } } // The same bytes can be two representations. /about as an extensionless file // is sniffed as text/plain; redeployed as about/index.html it is text/html, // byte for byte the same. A tag made of the bytes alone would answer 304 and // leave the browser rendering markup as text. func TestStaticTagCoversTheContentType(t *testing.T) { store, _, priv, srv := staticFixture(t) const same = "Hello world" shipSite(t, store, "site", "v2", map[string]string{"index.html": "home", "about": same}) if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil { t.Fatalf("Promote v2: %v", err) } resp, _ := reqStatic(t, "GET", srv.URL, "/about", nil) plainTag, plainType := resp.Header.Get("ETag"), resp.Header.Get("Content-Type") if resp.StatusCode != http.StatusOK || !strings.HasPrefix(plainType, "text/plain") { t.Fatalf("GET /about = %d %q, want a text/plain 200", resp.StatusCode, plainType) } shipSite(t, store, "site", "v3", map[string]string{"index.html": "home", "about/index.html": same}) if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v3"); err != nil { t.Fatalf("Promote v3: %v", err) } resp, body := reqStatic(t, "GET", srv.URL, "/about", map[string]string{"If-None-Match": plainTag}) if resp.StatusCode != http.StatusOK || body != same || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") { t.Fatalf("revalidating /about after it became HTML = %d %q; want a text/html 200", resp.StatusCode, resp.Header.Get("Content-Type")) } } // openTagged writes body at dir/name and opens it the way serveTree does. func openTagged(t *testing.T, dir, name, body string) (string, *os.File, os.FileInfo) { t.Helper() fp := filepath.Join(dir, name) if err := os.WriteFile(fp, []byte(body), 0o644); err != nil { t.Fatal(err) } f, err := os.Open(fp) if err != nil { t.Fatal(err) } t.Cleanup(func() { f.Close() }) fi, err := f.Stat() if err != nil { t.Fatal(err) } return fp, f, fi } // A cold file is hashed ONCE however many requests arrive for it together. // Without that, a burst of requests against one large uncached asset — HEADs // and one-byte ranges included — would each read and hash the whole file // before sending a byte. // // The overlap is forced, not hoped for: the first hash is held until every // other caller is known to be waiting on it. func TestTagCacheHashesAColdFileOnceForOverlappingRequests(t *testing.T) { c := newTagCache() c.wait = time.Minute held, release := make(chan struct{}), make(chan struct{}) var heldOnce, releaseOnce sync.Once c.hold = func() { heldOnce.Do(func() { close(held) }); <-release } letGo := func() { releaseOnce.Do(func() { close(release) }) } defer letGo() // a failure below must not strand the callers dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "big.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { t.Fatal(err) } const callers = 12 tags := make([]string, callers) errs := make([]error, callers) var wg sync.WaitGroup call := func(i int) { defer wg.Done() fp := filepath.Join(dir, "big.bin") f, err := os.Open(fp) if err != nil { errs[i] = err return } defer f.Close() fi, err := f.Stat() if err != nil { errs[i] = err return } tags[i], _, errs[i] = c.tag(context.Background(), fp, f, fi) } wg.Add(1) go call(0) <-held // the first caller is inside its hash for i := 1; i < callers; i++ { wg.Add(1) go call(i) } deadline := time.Now().Add(10 * time.Second) for c.waiting.Load() != callers-1 { if time.Now().After(deadline) { t.Fatalf("%d of %d callers are waiting on the hash in flight", c.waiting.Load(), callers-1) } time.Sleep(time.Millisecond) } letGo() wg.Wait() for i := range tags { if errs[i] != nil || tags[i] == "" || tags[i] != tags[0] { t.Fatalf("caller %d = %q, %v; want every caller to get %q", i, tags[i], errs[i], tags[0]) } } if got := c.hashed.Load(); got != 1 { t.Fatalf("the file was hashed %d times for %d overlapping callers, want once", got, callers) } } // Large files hash a few at a time, and a request that cannot get a turn — // or whose file is still being hashed for someone else — is not made to wait // for it: past a short budget it is served without a tag. A small file never // queues behind them at all. Otherwise four requests for four large cold // assets would hold up the first load of every other static site on the box. func TestTagCacheDoesNotQueueBehindLargeHashes(t *testing.T) { c := newTagCache() c.wait = 20 * time.Millisecond dir := t.TempDir() for i := 0; i < tagHashers; i++ { c.sem <- struct{}{} // every hasher busy with someone's large asset } fp, f, fi := openTagged(t, dir, "big.bin", strings.Repeat("x", tagInline+1)) if _, _, err := c.tag(context.Background(), fp, f, fi); !errors.Is(err, errTagBusy) { t.Fatalf("a large file with no free hasher = %v, want errTagBusy", err) } if n := len(c.flights); n != 0 { t.Fatalf("%d flights left registered after giving up", n) } fp, f, fi = openTagged(t, dir, "page.html", "
a small page
") tag, ctype, err := c.tag(context.Background(), fp, f, fi) if err != nil || tag == "" || !strings.HasPrefix(ctype, "text/html") { t.Fatalf("a small file while every hasher is busy = %q %q %v; want it tagged at once", tag, ctype, err) } // Once a hasher frees up the large file is tagged, and stays tagged. // The budget is lifted first: at 20ms a stalled scheduler could find the // timer and the free hasher ready together, and pick the timer. <-c.sem c.wait = time.Minute fp, f, fi = openTagged(t, dir, "big.bin", strings.Repeat("x", tagInline+1)) if tag, _, err := c.tag(context.Background(), fp, f, fi); err != nil || tag == "" { t.Fatalf("a large file with a free hasher = %q, %v", tag, err) } } // The tag covers the Content-Type the response will carry, not the extension // it was derived from: two boxes with different MIME tables type the same // bytes differently, and a 304 from one must not confirm the other's page. func TestTagCacheTagCoversTheResolvedContentType(t *testing.T) { c := newTagCache() const body = "# notes\n\nsome text\n" // The MIME table is process-wide and an added type stays added, so each // run takes an extension of its own (-count=N would otherwise find the // last run's mapping in place of an unknown one). ext := fmt.Sprintf(".zzcarlos%d", time.Now().UnixNano()) fp, f, fi := openTagged(t, t.TempDir(), "notes"+ext, body) sniffedTag, sniffedType, err := c.tag(context.Background(), fp, f, fi) if err != nil || !strings.HasPrefix(sniffedType, "text/plain") { t.Fatalf("an unknown extension = %q %v, want it sniffed as text/plain", sniffedType, err) } // "Another box": the same bytes under the same name, with the type known. if err := mime.AddExtensionType(ext, "text/markdown; charset=utf-8"); err != nil { t.Fatal(err) } fp, f, fi = openTagged(t, t.TempDir(), "notes"+ext, body) knownTag, knownType, err := c.tag(context.Background(), fp, f, fi) if err != nil || !strings.HasPrefix(knownType, "text/markdown") { t.Fatalf("a mapped extension = %q %v, want text/markdown", knownType, err) } if knownTag == sniffedTag { t.Fatalf("the same bytes typed %q and %q share the tag %s", sniffedType, knownType, knownTag) } } // A remembered tag describes the file it was computed from, not the path. If // the tree is replaced on disk under a live handler — cache maintenance, then // another handler unpacking a re-shipped version of the same name — the old // tag must not be handed out over the new bytes, nor a 304 against it. func TestStaticTagDoesNotOutliveTheFileItDescribes(t *testing.T) { srv, tree := siteServerTree(t, "site", "v1", map[string]string{"index.html": "home", "style.css": "body{}"}) resp, _ := reqStatic(t, "GET", srv.URL, "/style.css", nil) oldTag := resp.Header.Get("ETag") if oldTag == "" { t.Fatal("no ETag for /style.css") } // Replace the file the way a fresh extraction would — a new file renamed // into place — and make it the hardest case to notice: the same length // and the same mtime, so only the file's identity gives it away. fp := filepath.Join(tree, "style.css") before, err := os.Stat(fp) if err != nil { t.Fatal(err) } if err := os.WriteFile(fp+".new", []byte("main{}"), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(fp+".new", before.ModTime(), before.ModTime()); err != nil { t.Fatal(err) } if err := os.Rename(fp+".new", fp); err != nil { t.Fatal(err) } resp, body := reqStatic(t, "GET", srv.URL, "/style.css", map[string]string{"If-None-Match": oldTag}) if resp.StatusCode != http.StatusOK || body != "main{}" { t.Fatalf("revalidating against a replaced file = %d %q, want 200 with the new bytes", resp.StatusCode, body) } if got := resp.Header.Get("ETag"); got == "" || got == oldTag { t.Fatalf("ETag over the replaced file = %q, want a new tag (old %q)", got, oldTag) } } // A file that is there and cannot be read is not a missing page. ServeFile // said 403, and an operator reading the access log must still be able to tell // a broken cache from a mistyped URL. func TestStaticUnreadableFileIsNotA404(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root reads through file modes") } srv, tree := siteServerTree(t, "site", "v1", map[string]string{"index.html": "home", "style.css": "body{}"}) reqStatic(t, "GET", srv.URL, "/", nil) // extract the tree fp := filepath.Join(tree, "style.css") if err := os.Chmod(fp, 0); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(fp, 0o644) }) resp, _ := reqStatic(t, "GET", srv.URL, "/style.css", nil) if resp.StatusCode != http.StatusForbidden { t.Fatalf("GET of an unreadable file = %d, want 403", resp.StatusCode) } } // failingFile reads like a file whose later blocks are bad: the first good // bytes come back, then every read fails. Seeks work. type failingFile struct { data []byte good int pos int64 } func (f *failingFile) Read(p []byte) (int, error) { if f.pos >= int64(f.good) { return 0, errors.New("input/output error") } n := copy(p, f.data[f.pos:f.good]) f.pos += int64(n) return n, nil } func (f *failingFile) Seek(off int64, whence int) (int64, error) { switch whence { case io.SeekStart: f.pos = off case io.SeekCurrent: f.pos += off case io.SeekEnd: f.pos = int64(len(f.data)) + off } return f.pos, nil } // A hash that fails part-way must hand the file back at its start. The // handler then serves it untagged, and net/http sniffs the Content-Type from // wherever the file is positioned: left mid-file, an extensionless image // would go out as text/plain. func TestTagCacheRewindsTheFileWhenHashingFails(t *testing.T) { c := newTagCache() _, _, fi := openTagged(t, t.TempDir(), "picture", "placeholder") for _, good := range []int{100, 2000} { // fails inside the sniffed prefix, and after it f := &failingFile{data: make([]byte, 4096), good: good} if _, _, err := c.tag(context.Background(), fmt.Sprintf("/nowhere/picture-%d", good), f, fi); err == nil { t.Fatalf("hashing a file that fails after %d bytes reported no error", good) } if f.pos != 0 { t.Fatalf("after a failed hash (%d good bytes) the file is at offset %d, want 0", good, f.pos) } } if n := len(c.entries); n != 0 { t.Fatalf("%d tags remembered from hashes that failed", n) } }