From c99b39cd051bbfcb3995c4c083cae4406014bda2 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Wed, 17 Sep 2025 17:01:17 +0000 Subject: [PATCH] Fix S3 trailing slash preservation in object keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 object keys with trailing slashes were being incorrectly stripped when writing to S3. This is problematic because trailing slashes have semantic meaning in S3 (commonly used to represent folders/prefixes). The fix removes the normalization logic that was stripping trailing slashes from S3 paths, while still removing leading slashes as required for proper S3 key formatting. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/bun.js/webcore/blob/Store.zig | 8 +-- test/js/bun/s3/s3-list-objects.test.ts | 79 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/bun.js/webcore/blob/Store.zig b/src/bun.js/webcore/blob/Store.zig index c8347b3dd0..8cbfc8ef39 100644 --- a/src/bun.js/webcore/blob/Store.zig +++ b/src/bun.js/webcore/blob/Store.zig @@ -311,12 +311,8 @@ pub const S3 = struct { pub fn path(this: *@This()) []const u8 { var path_name = bun.URL.parse(this.pathlike.slice()).s3Path(); - // normalize start and ending - if (strings.endsWith(path_name, "/")) { - path_name = path_name[0..path_name.len]; - } else if (strings.endsWith(path_name, "\\")) { - path_name = path_name[0 .. path_name.len - 1]; - } + // For S3, we only remove leading slashes but preserve trailing slashes + // as they are semantically significant (e.g., for folder representations) if (strings.startsWith(path_name, "/")) { path_name = path_name[1..]; } else if (strings.startsWith(path_name, "\\")) { diff --git a/test/js/bun/s3/s3-list-objects.test.ts b/test/js/bun/s3/s3-list-objects.test.ts index bacaca32ca..5ce69c50d4 100644 --- a/test/js/bun/s3/s3-list-objects.test.ts +++ b/test/js/bun/s3/s3-list-objects.test.ts @@ -1166,4 +1166,83 @@ describe.skipIf(!optionsFromEnv.accessKeyId)("S3 - CI - List Objects", () => { expect(storedFile.owner).toBeObject(); expect(storedFile.owner!.id).toBeString(); }); + + it("should preserve trailing slashes in S3 keys", async () => { + const testKeys = [ + "test_folder/", + "test_folder/subfolder/", + "test_file_without_slash", + ]; + const uploadedKeys: string[] = []; + + using server = createBunServer(async req => { + const url = new URL(req.url); + + // Handle PUT requests (write operations) + if (req.method === "PUT") { + // Extract the key from the URL path (remove leading slash) + const key = url.pathname.substring(1); + uploadedKeys.push(key); + + return new Response("", { + headers: { + ETag: '"test-etag"', + }, + status: 200, + }); + } + + // Handle GET requests (list operations) + if (req.method === "GET" && url.search.includes("list-type=2")) { + // Return the keys exactly as they were uploaded + const contents = uploadedKeys.map(key => + `${key}0` + ).join(""); + + return new Response( + ` + + test-bucket + ${contents} + false + `, + { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }, + ); + } + + return new Response("Not Found", { status: 404 }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + // Write objects with and without trailing slashes + for (const key of testKeys) { + await client.write(key, new ArrayBuffer(0)); + } + + // List objects and verify trailing slashes are preserved + const listResult = await client.list({}); + + expect(listResult.contents).toBeArray(); + expect(listResult.contents).toHaveLength(3); + + // Verify each key is preserved exactly as written + const listedKeys = listResult.contents!.map((item: any) => item.key); + expect(listedKeys).toContain("test_folder/"); + expect(listedKeys).toContain("test_folder/subfolder/"); + expect(listedKeys).toContain("test_file_without_slash"); + + // Specifically verify trailing slashes are preserved + expect(listedKeys[0]).toEndWith("/"); + expect(listedKeys[1]).toEndWith("/"); + expect(listedKeys[2]).not.toEndWith("/"); + }); });