Skip to content
Open
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
29 changes: 29 additions & 0 deletions packages/@emulators/aws/src/__tests__/aws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,35 @@ describe("AWS plugin - S3 Objects", () => {
const body = await getRes.text();
expect(body).toBe("copy me");
});

it("round-trips a binary object byte-for-byte", async () => {
// Bytes that are NOT valid UTF-8 (0x80, 0xff, 0xfe) plus a NUL and PNG
// magic. The old handler called `.text()`, which decodes the body as
// UTF-8 and replaces every non-UTF-8 byte with U+FFFD (EF BF BD) — so
// binary uploads (audio, images, gzip, …) came back corrupted and longer
// than they went in. Storing the body base64 round-trips any byte exactly.
const binary = new Uint8Array([0x00, 0x01, 0x02, 0x80, 0xff, 0xfe, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);

const putRes = await app.request(`${base}/emulate-default/audio.bin`, {
method: "PUT",
headers: { ...authHeaders(), "Content-Type": "application/octet-stream" },
body: binary,
});
expect(putRes.status).toBe(200);

const getRes = await app.request(`${base}/emulate-default/audio.bin`, {
method: "GET",
headers: authHeaders(),
});
expect(getRes.status).toBe(200);
expect(getRes.headers.get("Content-Type")).toBe("application/octet-stream");

const received = new Uint8Array(await getRes.arrayBuffer());
expect(Array.from(received)).toEqual(Array.from(binary));
// Content-Length must reflect the raw byte count, not the inflated
// U+FFFD-substituted or base64 length.
expect(getRes.headers.get("Content-Length")).toBe(String(binary.byteLength));
});
});

describe("AWS plugin - S3 ListObjectsV2 pagination", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/@emulators/aws/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface S3Bucket extends Entity {
export interface S3Object extends Entity {
bucket_name: string;
key: string;
/** Object bytes, base64-encoded (binary-safe — see s3.ts handlePutObject). */
body: string;
content_type: string;
content_length: number;
Expand Down
2 changes: 1 addition & 1 deletion packages/@emulators/aws/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function generateReceiptHandle(): string {
return randomBytes(48).toString("base64url");
}

export function md5(content: string): string {
export function md5(content: string | Buffer | Uint8Array): string {
return createHash("md5").update(content).digest("hex");
}

Expand Down
28 changes: 19 additions & 9 deletions packages/@emulators/aws/src/routes/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,13 @@ ${prefixesXml}
}
}

// Store the object
const fileContent = await file.text();
// Store the object. Read as raw bytes and persist base64 so binary
// payloads (audio/images/etc.) survive — `.text()` would UTF-8-mangle them.
const fileBuf = Buffer.from(await file.arrayBuffer());
const fileContent = fileBuf.toString("base64");
const contentType = (body["Content-Type"] as string) ?? file.type ?? "application/octet-stream";
const etag = md5(fileContent);
const contentLength = new TextEncoder().encode(fileContent).byteLength;
const etag = md5(fileBuf);
const contentLength = fileBuf.byteLength;

const existing = aws()
.s3Objects.findBy("bucket_name", bucketName)
Expand Down Expand Up @@ -347,9 +349,15 @@ ${prefixesXml}
});
}

const body = await c.req.text();
// Read the raw request bytes and store them base64-encoded. Using
// `c.req.text()` here would decode the body as UTF-8 and replace every
// non-UTF-8 byte with U+FFFD (EF BF BD), corrupting binary uploads
// (audio, images, gzip, …). base64 round-trips any byte exactly.
const bodyBuf = Buffer.from(await c.req.arrayBuffer());
const body = bodyBuf.toString("base64");
const contentLength = bodyBuf.byteLength;
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
const etag = md5(body);
const etag = md5(bodyBuf);

// Extract user metadata (x-amz-meta-*)
const metadata: Record<string, string> = {};
Expand All @@ -367,7 +375,7 @@ ${prefixesXml}
aws().s3Objects.update(existing.id, {
body,
content_type: contentType,
content_length: new TextEncoder().encode(body).byteLength,
content_length: contentLength,
etag,
last_modified: new Date().toISOString(),
metadata,
Expand All @@ -378,7 +386,7 @@ ${prefixesXml}
key,
body,
content_type: contentType,
content_length: new TextEncoder().encode(body).byteLength,
content_length: contentLength,
etag,
last_modified: new Date().toISOString(),
metadata,
Expand Down Expand Up @@ -415,7 +423,9 @@ ${prefixesXml}
headers[`x-amz-meta-${k}`] = v;
}

return c.text(obj.body, 200, headers);
// obj.body is base64 (see handlePutObject); decode back to raw bytes so
// binary objects stream out byte-for-byte. c.body sends a Uint8Array as-is.
return c.body(Buffer.from(obj.body, "base64"), 200, headers);
};

const handleHeadObject = (c: S3ObjectContext) => {
Expand Down