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
7 changes: 7 additions & 0 deletions .changeset/r2-local-object-key-encoding.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions packages/wrangler/src/__tests__/r2/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/wrangler/src/r2/helpers/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export async function usingLocalBucket<T>(
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);
Expand Down
42 changes: 24 additions & 18 deletions packages/wrangler/src/r2/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
}
);
})
);

Expand Down
Loading