From 9f7d1a32ba57907ca408e7ebcad370c3bb1c61a9 Mon Sep 17 00:00:00 2001 From: Diango Date: Wed, 14 Oct 2026 21:13:19 -0400 Subject: [PATCH] fix(assets): verify uploaded cosmetic size from the R2 listing stat() reported 0 bytes for a 538-byte pack.json that the bucket listing showed was stored in full, so publishing an emote pack failed with a size mismatch. Read the size from list(), as the upload script already does. --- .../infrastructure/R2CosmeticAssetStorage.ts | 15 ++++- .../R2CosmeticAssetStorage.test.ts | 67 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/unit/modules/assets/infrastructure/R2CosmeticAssetStorage.test.ts diff --git a/src/modules/assets/infrastructure/R2CosmeticAssetStorage.ts b/src/modules/assets/infrastructure/R2CosmeticAssetStorage.ts index d5476e8..8bff178 100644 --- a/src/modules/assets/infrastructure/R2CosmeticAssetStorage.ts +++ b/src/modules/assets/infrastructure/R2CosmeticAssetStorage.ts @@ -18,10 +18,10 @@ export class R2CosmeticAssetStorage implements CosmeticAssetStorage { }); try { - const persisted = await target.stat(); - if (written !== upload.bytes.byteLength || persisted.size !== upload.bytes.byteLength) { + const persisted = await this.listedSize(upload.key); + if (written !== upload.bytes.byteLength || persisted !== upload.bytes.byteLength) { throw new Error( - `R2 upload size mismatch for "${upload.key}": local=${upload.bytes.byteLength}, write=${written}, remote=${persisted.size}`, + `R2 upload size mismatch for "${upload.key}": local=${upload.bytes.byteLength}, write=${written}, remote=${persisted ?? "missing"}`, ); } } catch (error) { @@ -30,6 +30,15 @@ export class R2CosmeticAssetStorage implements CosmeticAssetStorage { } } + /** + * Reads the stored size from a listing. `stat()` reported 0 bytes for a small + * JSON object that the listing showed was stored in full. + */ + private async listedSize(key: string): Promise { + const listed = await this.client.list({ prefix: key }); + return listed.contents?.find((object) => object.key === key)?.size; + } + async delete(key: string): Promise { await this.client.delete(key); } diff --git a/tests/unit/modules/assets/infrastructure/R2CosmeticAssetStorage.test.ts b/tests/unit/modules/assets/infrastructure/R2CosmeticAssetStorage.test.ts new file mode 100644 index 0000000..731987c --- /dev/null +++ b/tests/unit/modules/assets/infrastructure/R2CosmeticAssetStorage.test.ts @@ -0,0 +1,67 @@ +import type { S3Client } from "bun"; +import { describe, expect, it, mock } from "bun:test"; + +import { R2CosmeticAssetStorage } from "../../../../../src/modules/assets/infrastructure/R2CosmeticAssetStorage"; + +interface FakeR2 { + exists?: boolean; + written?: number; + statSize?: number; + listedSize?: number; +} + +function fakeClient({ exists = false, written, statSize = 0, listedSize }: FakeR2) { + const deleteObject = mock(async (_key: string) => undefined); + const client = { + file: (key: string) => ({ + exists: async () => exists, + stat: async () => ({ size: statSize }), + delete: () => deleteObject(key), + }), + write: async (_key: string, bytes: Uint8Array) => written ?? bytes.byteLength, + list: async ({ prefix }: { prefix: string }) => ({ + contents: listedSize === undefined ? [] : [{ key: prefix, size: listedSize }], + }), + delete: (key: string) => deleteObject(key), + }; + return { client: client as unknown as S3Client, deleteObject }; +} + +const upload = { + key: "emote-packs/halloween-duelist/pack.json", + bytes: new Uint8Array(538), + contentType: "application/json", +}; + +describe("R2CosmeticAssetStorage.put", () => { + it("accepts an upload whose listed size matches even when stat reports 0 bytes", async () => { + const { client, deleteObject } = fakeClient({ statSize: 0, listedSize: 538 }); + + await new R2CosmeticAssetStorage(client).put(upload); + + expect(deleteObject).not.toHaveBeenCalled(); + }); + + it("rejects and deletes an upload whose listed size differs", async () => { + const { client, deleteObject } = fakeClient({ listedSize: 0 }); + + await expect(new R2CosmeticAssetStorage(client).put(upload)).rejects.toThrow( + 'R2 upload size mismatch for "emote-packs/halloween-duelist/pack.json": local=538, write=538, remote=0', + ); + expect(deleteObject).toHaveBeenCalledWith(upload.key); + }); + + it("rejects and deletes an upload that is missing from the listing", async () => { + const { client, deleteObject } = fakeClient({}); + + await expect(new R2CosmeticAssetStorage(client).put(upload)).rejects.toThrow("remote=missing"); + expect(deleteObject).toHaveBeenCalledWith(upload.key); + }); + + it("rejects and deletes an upload whose write count differs", async () => { + const { client, deleteObject } = fakeClient({ written: 10, listedSize: 538 }); + + await expect(new R2CosmeticAssetStorage(client).put(upload)).rejects.toThrow("write=10"); + expect(deleteObject).toHaveBeenCalledWith(upload.key); + }); +});