diff --git a/src/server/routes/file-etag.ts b/src/server/routes/file-etag.ts index 90ad240..a65fe0b 100644 --- a/src/server/routes/file-etag.ts +++ b/src/server/routes/file-etag.ts @@ -1,16 +1,20 @@ import { createHash } from "crypto"; -// Build an ETag that fingerprints both the upstream content (?v=) and -// the anonymization config the user has saved. Without the config part, the -// browser kept serving content anonymized under an older term list — see -// #439 (anonymization "doesn't work" in regular tabs but works in incognito). +// Build an ETag that fingerprints the upstream content (?v=), the file +// path, and the anonymization config the user has saved. Without the config +// part the browser kept serving bytes anonymized under an older term list +// (#439). The path is folded in so two different files inside the same repo +// can never collide. export function fileETag( versionParam: string | undefined, + filePath: string, options: unknown ): string { const h = createHash("sha1"); h.update(versionParam || ""); h.update("|"); + h.update(filePath || ""); + h.update("|"); h.update(JSON.stringify(options ?? null)); return `"f-${h.digest("hex")}"`; } diff --git a/src/server/routes/file.ts b/src/server/routes/file.ts index 69157d4..7cc1789 100644 --- a/src/server/routes/file.ts +++ b/src/server/routes/file.ts @@ -54,6 +54,7 @@ router.get( } const etag = fileETag( req.query.v as string | undefined, + anonymizedPath, repo.model.options ); res.header("ETag", etag); diff --git a/test/file-etag.test.js b/test/file-etag.test.js index ecd78f8..079ac07 100644 --- a/test/file-etag.test.js +++ b/test/file-etag.test.js @@ -3,37 +3,50 @@ require("ts-node/register/transpile-only"); const { fileETag } = require("../src/server/routes/file-etag"); describe("fileETag", function () { + const opts = { terms: ["alice"] }; + it("changes when the upstream sha changes", function () { - const opts = { terms: ["alice"] }; - expect(fileETag("sha1", opts)).to.not.equal(fileETag("sha2", opts)); + expect(fileETag("sha1", "README.md", opts)).to.not.equal( + fileETag("sha2", "README.md", opts) + ); + }); + + it("changes when the file path changes", function () { + expect(fileETag("sha1", "README.md", opts)).to.not.equal( + fileETag("sha1", "src/index.ts", opts) + ); }); // #439 — without folding the anonymization options into the ETag, editing // the term list left the same URL serving stale anonymized bytes. it("changes when the anonymization terms change", function () { - const a = fileETag("sha1", { terms: ["alice"] }); - const b = fileETag("sha1", { terms: ["alice", "bob"] }); - expect(a).to.not.equal(b); + expect(fileETag("sha1", "README.md", { terms: ["alice"] })).to.not.equal( + fileETag("sha1", "README.md", { terms: ["alice", "bob"] }) + ); }); it("changes when an anonymization toggle changes", function () { - const a = fileETag("sha1", { terms: ["alice"], image: true }); - const b = fileETag("sha1", { terms: ["alice"], image: false }); - expect(a).to.not.equal(b); + expect( + fileETag("sha1", "README.md", { terms: ["alice"], image: true }) + ).to.not.equal( + fileETag("sha1", "README.md", { terms: ["alice"], image: false }) + ); }); it("is stable for the same inputs", function () { - const opts = { terms: ["alice", "bob"], image: true }; - expect(fileETag("sha1", opts)).to.equal(fileETag("sha1", opts)); + expect(fileETag("sha1", "README.md", opts)).to.equal( + fileETag("sha1", "README.md", opts) + ); }); it("treats missing version like an empty string", function () { - const opts = { terms: [] }; - expect(fileETag(undefined, opts)).to.equal(fileETag("", opts)); + expect(fileETag(undefined, "README.md", { terms: [] })).to.equal( + fileETag("", "README.md", { terms: [] }) + ); }); it("returns a quoted opaque tag", function () { - const tag = fileETag("sha1", { terms: [] }); + const tag = fileETag("sha1", "README.md", { terms: [] }); expect(tag).to.match(/^"f-[0-9a-f]{40}"$/); }); });