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("/"); + }); });