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).
This commit is contained in:
tdurieux
2026-05-03 21:19:39 +02:00
parent 1f966841ad
commit 88fe8570fd
3 changed files with 35 additions and 17 deletions
+8 -4
View File
@@ -1,16 +1,20 @@
import { createHash } from "crypto"; import { createHash } from "crypto";
// Build an ETag that fingerprints both the upstream content (?v=<sha>) and // Build an ETag that fingerprints the upstream content (?v=<sha>), the file
// the anonymization config the user has saved. Without the config part, the // path, and the anonymization config the user has saved. Without the config
// browser kept serving content anonymized under an older term list — see // part the browser kept serving bytes anonymized under an older term list
// #439 (anonymization "doesn't work" in regular tabs but works in incognito). // (#439). The path is folded in so two different files inside the same repo
// can never collide.
export function fileETag( export function fileETag(
versionParam: string | undefined, versionParam: string | undefined,
filePath: string,
options: unknown options: unknown
): string { ): string {
const h = createHash("sha1"); const h = createHash("sha1");
h.update(versionParam || ""); h.update(versionParam || "");
h.update("|"); h.update("|");
h.update(filePath || "");
h.update("|");
h.update(JSON.stringify(options ?? null)); h.update(JSON.stringify(options ?? null));
return `"f-${h.digest("hex")}"`; return `"f-${h.digest("hex")}"`;
} }
+1
View File
@@ -54,6 +54,7 @@ router.get(
} }
const etag = fileETag( const etag = fileETag(
req.query.v as string | undefined, req.query.v as string | undefined,
anonymizedPath,
repo.model.options repo.model.options
); );
res.header("ETag", etag); res.header("ETag", etag);
+26 -13
View File
@@ -3,37 +3,50 @@ require("ts-node/register/transpile-only");
const { fileETag } = require("../src/server/routes/file-etag"); const { fileETag } = require("../src/server/routes/file-etag");
describe("fileETag", function () { describe("fileETag", function () {
const opts = { terms: ["alice"] };
it("changes when the upstream sha changes", function () { it("changes when the upstream sha changes", function () {
const opts = { terms: ["alice"] }; expect(fileETag("sha1", "README.md", opts)).to.not.equal(
expect(fileETag("sha1", opts)).to.not.equal(fileETag("sha2", opts)); 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 // #439 — without folding the anonymization options into the ETag, editing
// the term list left the same URL serving stale anonymized bytes. // the term list left the same URL serving stale anonymized bytes.
it("changes when the anonymization terms change", function () { it("changes when the anonymization terms change", function () {
const a = fileETag("sha1", { terms: ["alice"] }); expect(fileETag("sha1", "README.md", { terms: ["alice"] })).to.not.equal(
const b = fileETag("sha1", { terms: ["alice", "bob"] }); fileETag("sha1", "README.md", { terms: ["alice", "bob"] })
expect(a).to.not.equal(b); );
}); });
it("changes when an anonymization toggle changes", function () { it("changes when an anonymization toggle changes", function () {
const a = fileETag("sha1", { terms: ["alice"], image: true }); expect(
const b = fileETag("sha1", { terms: ["alice"], image: false }); fileETag("sha1", "README.md", { terms: ["alice"], image: true })
expect(a).to.not.equal(b); ).to.not.equal(
fileETag("sha1", "README.md", { terms: ["alice"], image: false })
);
}); });
it("is stable for the same inputs", function () { it("is stable for the same inputs", function () {
const opts = { terms: ["alice", "bob"], image: true }; expect(fileETag("sha1", "README.md", opts)).to.equal(
expect(fileETag("sha1", opts)).to.equal(fileETag("sha1", opts)); fileETag("sha1", "README.md", opts)
);
}); });
it("treats missing version like an empty string", function () { it("treats missing version like an empty string", function () {
const opts = { terms: [] }; expect(fileETag(undefined, "README.md", { terms: [] })).to.equal(
expect(fileETag(undefined, opts)).to.equal(fileETag("", opts)); fileETag("", "README.md", { terms: [] })
);
}); });
it("returns a quoted opaque tag", function () { 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}"$/); expect(tag).to.match(/^"f-[0-9a-f]{40}"$/);
}); });
}); });