diff --git a/packages/aws-lambda/src/sdk/deploySite.test.ts b/packages/aws-lambda/src/sdk/deploySite.test.ts index 9863b051878..c89bdaa38fb 100644 --- a/packages/aws-lambda/src/sdk/deploySite.test.ts +++ b/packages/aws-lambda/src/sdk/deploySite.test.ts @@ -94,6 +94,18 @@ describe("deploySite", () => { expect(s3.ops.filter((op) => op.kind === "head")).toHaveLength(2); }); + it("uploads a distinct tree when its contents resemble another file record", async () => { + const s3 = new FakeS3(); + writeFileSync(join(projectDir, "a"), "ab\0def"); + const first = await deploySite({ projectDir, bucketName: "test-bucket", s3: asS3Client(s3) }); + writeFileSync(join(projectDir, "a"), ""); + writeFileSync(join(projectDir, "ab"), "def"); + const second = await deploySite({ projectDir, bucketName: "test-bucket", s3: asS3Client(s3) }); + expect(second.siteId).not.toBe(first.siteId); + expect(second.uploaded).toBe(true); + expect(s3.ops.filter((op) => op.kind === "put")).toHaveLength(2); + }); + it("honours a caller-supplied siteId", async () => { const s3 = new FakeS3(); const result = await deploySite({ diff --git a/packages/gcp-cloud-run/src/sdk/deploySite.test.ts b/packages/gcp-cloud-run/src/sdk/deploySite.test.ts index 6c631ef6c9b..b6a1e6637eb 100644 --- a/packages/gcp-cloud-run/src/sdk/deploySite.test.ts +++ b/packages/gcp-cloud-run/src/sdk/deploySite.test.ts @@ -72,6 +72,27 @@ describe("deploySite", () => { expect(gcs.ops.filter((o) => o.kind === "upload").length).toBe(1); }); + it("uploads a distinct tree when its contents resemble another file record", async () => { + const gcs = new FakeGcs(); + const directory = mkProject("same"); + writeFileSync(join(directory, "a"), "bc\0def"); + const first = await deploySite({ + projectDir: directory, + bucketName: "b", + storage: asStorage(gcs), + }); + writeFileSync(join(directory, "a"), ""); + writeFileSync(join(directory, "bc"), "def"); + const second = await deploySite({ + projectDir: directory, + bucketName: "b", + storage: asStorage(gcs), + }); + expect(second.siteId).not.toBe(first.siteId); + expect(second.uploaded).toBe(true); + expect(gcs.ops.filter((op) => op.kind === "upload")).toHaveLength(2); + }); + it("honours an explicit siteId override", async () => { const gcs = new FakeGcs(); const handle = await deploySite({ diff --git a/packages/producer/src/services/distributed/projectHash.test.ts b/packages/producer/src/services/distributed/projectHash.test.ts new file mode 100644 index 00000000000..13416f91b7a --- /dev/null +++ b/packages/producer/src/services/distributed/projectHash.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { hashProjectDir } from "./projectHash.js"; + +const projects: string[] = []; +function project(files: Record): string { + const directory = mkdtempSync(join(tmpdir(), "hf-project-hash-")); + projects.push(directory); + for (const [name, content] of Object.entries(files)) { + const path = join(directory, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + return directory; +} +afterEach(() => { + for (const directory of projects.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("hashProjectDir", () => { + it("distinguishes file content from another file path", () => { + const combined = project({ a: "bc\0def" }); + const split = project({ a: "", bc: "def" }); + expect(hashProjectDir(combined)).not.toBe(hashProjectDir(split)); + }); + + it("distinguishes binary content from multiple file records", () => { + const content = Buffer.from([0, 255, 128, 1]); + const combined = project({ a: Buffer.concat([Buffer.from("asset\0"), content]) }); + const split = project({ a: "", asset: content }); + expect(hashProjectDir(combined)).not.toBe(hashProjectDir(split)); + }); + + it("is stable across directory locations and file creation order", () => { + const first = project({ "nested/b": "雪😀", a: "" }); + const second = project({ a: "", "nested/b": "雪😀" }); + expect(hashProjectDir(first)).toMatch(/^[a-f0-9]{16}$/); + expect(hashProjectDir(first)).toBe(hashProjectDir(second)); + }); + + it("includes paths and empty files", () => { + expect(hashProjectDir(project({ a: "same" }))).not.toBe(hashProjectDir(project({ b: "same" }))); + expect(hashProjectDir(project({}))).not.toBe(hashProjectDir(project({ a: "" }))); + }); + + it("ignores skipped top-level directories", () => { + const base = { "index.html": "same" }; + const first = project(base); + const second = project({ ...base, "node_modules/package/ignored": "different" }); + expect(hashProjectDir(first)).toBe(hashProjectDir(second)); + }); + + it("includes nested directories with a skipped top-level name", () => { + const first = project({ "assets/node_modules/content": "first" }); + const second = project({ "assets/node_modules/content": "second" }); + expect(hashProjectDir(first)).not.toBe(hashProjectDir(second)); + }); +}); diff --git a/packages/producer/src/services/distributed/projectHash.ts b/packages/producer/src/services/distributed/projectHash.ts index bc64c031574..286bf01516f 100644 --- a/packages/producer/src/services/distributed/projectHash.ts +++ b/packages/producer/src/services/distributed/projectHash.ts @@ -18,7 +18,7 @@ import { PLAN_PROJECT_DIR_SKIP_SEGMENTS } from "./plan.js"; * SHA-256 over every regular file under `projectDir` (sorted by relative * path) → 16-character hex prefix. The prefix is the `siteId`. * - * The hash includes the relative path plus every byte of each file, so a + * The hash includes the relative path, byte length, and contents of each file, so a * same-bytes rename still yields a fresh id. We trim to 16 chars because the * full 64 isn't useful in an object key for legibility. Top-level segments in * {@link PLAN_PROJECT_DIR_SKIP_SEGMENTS} (e.g. `node_modules`) are skipped to @@ -44,9 +44,12 @@ export function hashProjectDir(projectDir: string): string { walk(projectDir, true); for (const file of files) { const rel = relative(projectDir, file).replaceAll("\\", "/"); + const content = readFileSync(file); hash.update(rel); hash.update("\0"); - hash.update(readFileSync(file)); + hash.update(String(content.length)); + hash.update("\0"); + hash.update(content); } return hash.digest("hex").slice(0, 16); }