Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/aws-lambda/src/sdk/deploySite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions packages/gcp-cloud-run/src/sdk/deploySite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html>same</html>");
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({
Expand Down
60 changes: 60 additions & 0 deletions packages/producer/src/services/distributed/projectHash.test.ts
Original file line number Diff line number Diff line change
@@ -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, string | Buffer>): 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));
});
});
7 changes: 5 additions & 2 deletions packages/producer/src/services/distributed/projectHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}