diff --git a/.changeset/r2-local-object-key-encoding.md b/.changeset/r2-local-object-key-encoding.md new file mode 100644 index 00000000000..8271cf3281b --- /dev/null +++ b/.changeset/r2-local-object-key-encoding.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fix `r2 object put` and `r2 bulk put` storing a different key in local mode + +Keys that are not URL-safe were mangled on the way into local storage. A key with a space or a non-ASCII character was stored under its percent-encoded name, so a later `r2 object get` for that key reported that the key does not exist. Two keys that differ only after a `#` collapsed into a single object, and the second upload replaced the first. A key with a `%` that is not a valid escape failed outright with "Invalid URL string.", and one with a valid escape, such as `%41.txt`, was stored as `A.txt`. Spaces, non-ASCII characters, `#` and `%` now survive the trip into local storage. Remote writes were never affected, and objects already in local state are left where they are. diff --git a/packages/wrangler/src/__tests__/r2/local.test.ts b/packages/wrangler/src/__tests__/r2/local.test.ts index f6fbe543365..859852d042f 100644 --- a/packages/wrangler/src/__tests__/r2/local.test.ts +++ b/packages/wrangler/src/__tests__/r2/local.test.ts @@ -68,6 +68,82 @@ describe("r2", () => { `); }); + it("should round trip local objects whose keys are not URL safe", async ({ + expect, + }) => { + const keys = [ + "my report.pdf", + "report#1.pdf", + "sale?50.pdf", + "héllo.txt", + "invoices/2026/q1.pdf", + "50%off.pdf", + "my%20report.pdf", + ]; + fs.writeFileSync("payload", "quarterly numbers"); + + for (const key of keys) { + await runWrangler( + `r2 object put "bucket-object-test/${key}" --file ./payload` + ); + await runWrangler( + `r2 object get "bucket-object-test/${key}" --file ./downloaded` + ); + + expect(`${key} holds ${fs.readFileSync("downloaded", "utf8")}`).toBe( + `${key} holds quarterly numbers` + ); + fs.rmSync("downloaded"); + } + std.getAndClearOut(); + }); + + it("should keep two local objects whose keys differ after a hash", async ({ + expect, + }) => { + fs.writeFileSync("first", "january"); + fs.writeFileSync("second", "february"); + + await runWrangler( + `r2 object put "bucket-object-test/archive#2026-01.zip" --file ./first` + ); + await runWrangler( + `r2 object put "bucket-object-test/archive#2026-02.zip" --file ./second` + ); + std.getAndClearOut(); + + await runWrangler( + `r2 object get "bucket-object-test/archive#2026-01.zip" --file ./got-first` + ); + await runWrangler( + `r2 object get "bucket-object-test/archive#2026-02.zip" --file ./got-second` + ); + std.getAndClearOut(); + + expect(fs.readFileSync("got-first", "utf8")).toBe("january"); + expect(fs.readFileSync("got-second", "utf8")).toBe("february"); + }); + + it("should bulk put a local object whose key is not URL safe", async ({ + expect, + }) => { + fs.writeFileSync("payload", "quarterly numbers"); + fs.writeFileSync( + "list.json", + JSON.stringify([{ key: "my report #1.pdf", file: "payload" }]) + ); + + await runWrangler( + `r2 bulk put bucket-object-test --filename ./list.json` + ); + await runWrangler( + `r2 object get "bucket-object-test/my report #1.pdf" --file ./downloaded` + ); + std.getAndClearOut(); + + expect(fs.readFileSync("downloaded", "utf8")).toBe("quarterly numbers"); + }); + it("should bulk put R2 objects to a local bucket", async ({ expect }) => { await expect(() => runWrangler( diff --git a/packages/wrangler/src/r2/helpers/object.ts b/packages/wrangler/src/r2/helpers/object.ts index ea74b36b2be..c32b3f65cde 100644 --- a/packages/wrangler/src/r2/helpers/object.ts +++ b/packages/wrangler/src/r2/helpers/object.ts @@ -176,7 +176,7 @@ export async function usingLocalBucket( try { if (request.method !== "PUT") return new Response(null, { status: 405 }); const url = new URL(request.url); - const key = url.pathname.substring(1); + const key = decodeURIComponent(url.pathname.substring(1)); const optsHeader = request.headers.get("Wrangler-R2-Put-Options"); const opts = JSON.parse(optsHeader); await env.BUCKET.put(key, request.body, opts); diff --git a/packages/wrangler/src/r2/object.ts b/packages/wrangler/src/r2/object.ts index 2d3a451f73c..2a40cd165e6 100644 --- a/packages/wrangler/src/r2/object.ts +++ b/packages/wrangler/src/r2/object.ts @@ -373,15 +373,18 @@ export const r2ObjectPutCommand = createCommand({ // currently doesn't support sending these. Instead, // `usingLocalBucket()` provides a single `PUT` endpoint // for writing to a local bucket. - await mf.dispatchFetch(`http://localhost/${key}`, { - method: "PUT", - body: objectStream, - duplex: "half", - headers: { - "Content-Length": String(sizeBytes), - "Wrangler-R2-Put-Options": JSON.stringify(putOptions), - }, - }); + await mf.dispatchFetch( + `http://localhost/${encodeURIComponent(key)}`, + { + method: "PUT", + body: objectStream, + duplex: "half", + headers: { + "Content-Length": String(sizeBytes), + "Wrangler-R2-Put-Options": JSON.stringify(putOptions), + }, + } + ); } ); } else { @@ -654,15 +657,18 @@ export const r2BulkPutCommand = createCommand({ // currently doesn't support sending these. Instead, // `usingLocalBucket()` provides a single `PUT` endpoint // for writing to a local bucket. - await mf.dispatchFetch(`http://localhost/${entry.key}`, { - method: "PUT", - body: stream.Readable.toWeb(fs.createReadStream(entry.file)), - duplex: "half", - headers: { - "Content-Length": String(entry.size), - "Wrangler-R2-Put-Options": jsonPutOptions, - }, - }); + await mf.dispatchFetch( + `http://localhost/${encodeURIComponent(entry.key)}`, + { + method: "PUT", + body: stream.Readable.toWeb(fs.createReadStream(entry.file)), + duplex: "half", + headers: { + "Content-Length": String(entry.size), + "Wrangler-R2-Put-Options": jsonPutOptions, + }, + } + ); }) );