From 88fe8570fd52f6617e4fa8f86fde030e74b23a8e Mon Sep 17 00:00:00 2001 From: tdurieux Date: Sun, 3 May 2026 21:19:39 +0200 Subject: [PATCH] fix: include file path in cache ETag Without the path, two different files in the same repo (same sha, same anonymization options) shared an ETag. If a browser ever sent the cached ETag for one file while requesting another, the server would have returned 304 against the wrong cache entry. Fold the path into the ETag so each file has its own fingerprint. Follow-up to b3c1030 (#439). --- src/server/routes/file-etag.ts | 12 +++++++---- src/server/routes/file.ts | 1 + test/file-etag.test.js | 39 ++++++++++++++++++++++------------ 3 files changed, 35 insertions(+), 17 deletions(-) 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}"$/); }); });