Fix S3 trailing slash preservation in object keys

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 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-09-17 17:01:17 +00:00
parent 31202ec210
commit c99b39cd05
2 changed files with 81 additions and 6 deletions

View File

@@ -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, "\\")) {

View File

@@ -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 =>
`<Contents><Key>${key}</Key><Size>0</Size></Contents>`
).join("");
return new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<Name>test-bucket</Name>
${contents}
<IsTruncated>false</IsTruncated>
</ListBucketResult>`,
{
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("/");
});
});