diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 5abd0f5884..be0ef6f0c2 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -779,7 +779,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { const absPath = resolve(projectDir, path); let version: string | null = null; try { - version = fileContentVersion(readFileSync(absPath, "utf-8")); + version = fileContentVersion(readFileSync(absPath)); } catch { // A deletion has no current bytes to match against an API write receipt. } diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts index dd2345abc4..a9e795966c 100644 --- a/packages/studio-server/src/helpers/fileVersion.ts +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -14,8 +14,8 @@ const RECEIPT_TTL_MS = 10_000; const receipts = new Map(); /** Strong content version used as both the JSON version and HTTP ETag. */ -export function fileContentVersion(content: string): string { - return `"sha256:${createHash("sha256").update(content, "utf8").digest("hex")}"`; +export function fileContentVersion(content: string | Uint8Array): string { + return `"sha256:${createHash("sha256").update(content).digest("hex")}"`; } export function createWriteToken(requestToken?: string): string { diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 0a68396a20..df98b9d2a8 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -284,6 +284,91 @@ describe("registerFileRoutes", () => { expect(response.headers.get("etag")).toBe(payload.version); }); + it.each(["POST", "PUT"])("preserves arbitrary bytes when creating through %s", async (method) => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0xff, 0xc0, 0x80]); + const url = "http://localhost/projects/demo/files/assets/image.png"; + const response = await app.request(url, { + method, + headers: { "Content-Type": "application/octet-stream", "If-None-Match": "*" }, + body: bytes, + }); + expect(response.status).toBe(method === "POST" ? 201 : 200); + expect(readFileSync(join(projectDir, "assets/image.png"))).toEqual(bytes); + const read = await app.request(url); + const payload = await read.json(); + expect(payload.content).toBe(bytes.toString("utf-8")); + expect(payload.version).toBe(fileContentVersion(bytes)); + expect(read.headers.get("etag")).toBe(payload.version); + const duplicate = await app.request(url, { + method, + headers: { "If-None-Match": "*" }, + body: "overwrite", + }); + expect(duplicate.status).toBe(409); + expect(readFileSync(join(projectDir, "assets/image.png"))).toEqual(bytes); + }); + + it("does not overwrite a file created while a POST body is being read", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const path = join(projectDir, "raced.bin"); + const existing = Buffer.from([0xff, 0, 0x80]); + const request = new Request("http://localhost/projects/demo/files/raced.bin", { + method: "POST", + body: "upload", + }); + const readBody = request.arrayBuffer.bind(request); + vi.spyOn(request, "arrayBuffer").mockImplementation(async () => { + writeFileSync(path, existing, { flag: "wx" }); + return readBody(); + }); + const response = await app.request(request); + expect(response.status).toBe(409); + expect(readFileSync(path)).toEqual(existing); + }); + + it("versions binary bytes exactly while preserving conflicts, backups, and write receipts", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const path = join(projectDir, "image.png"); + const before = Buffer.from([0xff, 0, 0x80, 0x81]); + const after = Buffer.from([0xfe, 0, 0x80]); + writeFileSync(path, before); + const url = "http://localhost/projects/demo/files/image.png"; + const read = await app.request(url); + const version = read.headers.get("etag")!; + // Invalid UTF-8 bytes may decode to the same string, but must not share a version. + const staleBytes = Buffer.from([0xfe, 0, 0x80, 0x81]); + expect(staleBytes.toString("utf-8")).toBe(before.toString("utf-8")); + for (const headers of [{}, { "If-Match": fileContentVersion(staleBytes) }]) { + const rejected = await app.request(url, { method: "PUT", headers, body: after }); + expect([428, 409]).toContain(rejected.status); + expect((await rejected.json()).currentVersion).toBe(version); + expect(readFileSync(path)).toEqual(before); + } + const response = await app.request(url, { + method: "PUT", + headers: { "If-Match": version, "X-Hyperframes-Write-Token": "binary-write" }, + body: after, + }); + const payload = await response.json(); + expect(response.status).toBe(200); + expect(readFileSync(path)).toEqual(after); + expect(readFileSync(join(projectDir, payload.backupPath))).toEqual(before); + expect(payload.version).toBe(fileContentVersion(after)); + expect(response.headers.get("etag")).toBe(payload.version); + expect(consumeFileWriteReceipt(path, payload.version)).toEqual({ + path: "image.png", + version: payload.version, + writeToken: "binary-write", + }); + }); + it("requires If-Match for updates and preserves the current bytes", async () => { const projectDir = createProjectDir(); const app = new Hono(); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 7c5d439be9..2fc39a7c7a 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -2253,10 +2253,10 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { return c.json({ error: "not found" }, 404); } - const content = readFileSync(res.absPath, "utf-8"); + const content = readFileSync(res.absPath); const version = fileContentVersion(content); c.header("ETag", version); - return c.json({ filename: res.filePath, content, version }); + return c.json({ filename: res.filePath, content: content.toString("utf-8"), version }); }); // ── Write (overwrite) ── @@ -2265,13 +2265,13 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const res = await resolveProjectFile(c, adapter); if ("error" in res) return res.error; - const body = await c.req.text(); + const body = Buffer.from(await c.req.arrayBuffer()); const expectedVersion = c.req.header("If-Match")?.trim() ?? null; const createOnly = c.req.header("If-None-Match")?.trim() === "*"; if (expectedVersion === null && !createOnly) { - let currentContent: string | null = null; + let currentContent: Buffer | null = null; try { - currentContent = readFileSync(res.absPath, "utf-8"); + currentContent = readFileSync(res.absPath); } catch (error) { if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") { throw error; @@ -2282,7 +2282,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { error: "precondition required", path: res.filePath, currentVersion: currentContent === null ? null : fileContentVersion(currentContent), - currentContent, + currentContent: currentContent?.toString("utf-8") ?? null, }, 428, ); @@ -2298,19 +2298,19 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") { throw error; } - const currentContent = readFileSync(res.absPath, "utf-8"); + const currentContent = readFileSync(res.absPath); return c.json( { error: "file conflict", path: res.filePath, currentVersion: fileContentVersion(currentContent), - currentContent, + currentContent: currentContent.toString("utf-8"), }, 409, ); } try { - writeSync(fd, body, 0, "utf-8"); + writeSync(fd, body, 0, body.length, 0); } finally { closeSync(fd); } @@ -2333,7 +2333,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { ); } try { - const currentContent = readFileSync(fd, "utf-8"); + const currentContent = readFileSync(fd); const currentVersion = fileContentVersion(currentContent); if (expectedVersion !== currentVersion) { return c.json( @@ -2341,7 +2341,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { error: "file conflict", path: res.filePath, currentVersion, - currentContent, + currentContent: currentContent.toString("utf-8"), }, 409, ); @@ -2350,7 +2350,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`); ftruncateSync(fd, 0); - writeSync(fd, body, 0, "utf-8"); + writeSync(fd, body, 0, body.length, 0); } finally { closeSync(fd); } @@ -2375,14 +2375,17 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const res = await resolveProjectFile(c, adapter); if ("error" in res) return res.error; - if (existsSync(res.absPath)) { + ensureDir(res.absPath); + const body = Buffer.from(await c.req.arrayBuffer()); + try { + writeFileSync(res.absPath, body, { flag: "wx" }); + } catch (error) { + if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") { + throw error; + } return c.json({ error: "already exists" }, 409); } - ensureDir(res.absPath); - const body = await c.req.text().catch(() => ""); - writeFileSync(res.absPath, body, "utf-8"); - return c.json({ ok: true, path: res.filePath }, 201); });