Repository: floci-io/floci (2026-03-29T06:30:06Z)

Issue:
[BUG] S3 CopyObject returns NoSuchKey for objects with non-ASCII (multibyte) keys

## Service

S3

## AWS API Action

CopyObject

## Expected behavior

`CopyObject` should succeed when the source key contains non-ASCII characters (e.g. Japanese/CJK), since `PutObject` and `GetObject` work correctly with the same key.

## Actual behavior

`CopyObject` returns `NoSuchKey: The specified key does not exist.` when the `CopySource` key contains multibyte characters, even though the object exists and can be retrieved via `GetObject`.

## Reproduction

```python
import boto3

s3 = boto3.client('s3', endpoint_url='http://localhost:4566',
                  region_name='ap-northeast-1',
                  aws_access_key_id='test', aws_secret_access_key='test')

s3.create_bucket(Bucket='test-bucket',
                 CreateBucketConfiguration={'LocationConstraint': 'ap-northeast-1'})

# PutObject with Japanese key — succeeds
s3.put_object(Bucket='test-bucket', Key='src/テスト画像.png', Body=b'hello')

# GetObject — succeeds
resp = s3.get_object(Bucket='test-bucket', Key='src/テスト画像.png')
print(resp['Body'].read())  # b'hello'

# CopyObject — fails with NoSuchKey
s3.copy_object(
    Bucket='test-bucket',
    Key='dst/テスト画像.png',
    CopySource={'Bucket': 'test-bucket', 'Key': 'src/テスト画像.png'}
)
# botocore.errorfactory.NoSuchKey: An error occurred (NoSuchKey) when calling the CopyObject operation
```

Note: `CopyObject` with ASCII-only keys works correctly.

## Environment

- Floci version / image tag: `hectorvent/floci:latest` (1.0.11)
- How you're running Floci: Docker



Discussion:
@dk-moriha Thank you for reporting this! The root cause was that non-ASCII (multibyte) characters in S3 object keys weren't being decoded correctly, causing `CopyObject` to fail with `NoSuchKey`.

I've opened PR #112 with a fix — would you mind giving it a try and letting us know if it resolves your issue? Your validation would be really helpful before we merge.
 
PR https://github.com/hectorvent/floci/pull/112



Patch:
diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java
index 71d0cc03..ebf9a8a6 100644
--- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java
+++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java
@@ -25,6 +25,7 @@
 import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.core.UriInfo;
 import java.io.ByteArrayOutputStream;
+import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.time.ZoneId;
@@ -1048,7 +1049,7 @@ private Response handleCopyObject(String copySource, String destBucket, String d
             throw new AwsException("InvalidArgument", "Invalid copy source: " + copySource, 400);
         }
         String sourceBucket = source.substring(0, slashIndex);
-        String sourceKey = source.substring(slashIndex + 1);
+        String sourceKey = URLDecoder.decode(source.substring(slashIndex + 1), StandardCharsets.UTF_8);
 
         S3Object copy = s3Service.copyObject(sourceBucket, sourceKey, destBucket, destKey,
                 httpHeaders.getHeaderString("x-amz-metadata-directive"),


Test patch:
diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java
index 4dfff4bb..9ee2ddd1 100644
--- a/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java
@@ -211,6 +211,180 @@ void deleteNonEmptyBucketFails() {
             .body(containsString("BucketNotEmpty"));
     }
 
+    @Test
+    @Order(15)
+    void getObjectAttributesRejectsUnknownSelector() {
+        given()
+            .header("x-amz-object-attributes", "ETag,UnknownThing")
+        .when()
+            .get("/test-bucket/greeting.txt?attributes")
+        .then()
+            .statusCode(400)
+            .body(containsString("InvalidArgument"));
+    }
+
+    @Test
+    @Order(16)
+    void getNonExistentBucket() {
+        given()
+        .when()
+            .get("/nonexistent-bucket")
+        .then()
+            .statusCode(404)
+            .body(containsString("NoSuchBucket"));
+    }
+
+    @Test
+    @Order(17)
+    void headBucketReturnsStoredRegionForLocationConstraintBucket() {
+        String bucket = "eu-head-bucket";
+        String createBucketConfiguration = """
+                <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+                    <LocationConstraint>eu-central-1</LocationConstraint>
+                </CreateBucketConfiguration>
+                """;
+
+        given()
+            .contentType("application/xml")
+            .body(createBucketConfiguration)
+        .when()
+            .put("/" + bucket)
+        .then()
+            .statusCode(200)
+            .header("Location", equalTo("/" + bucket));
+
+        given()
+        .when()
+            .head("/" + bucket)
+        .then()
+            .statusCode(200)
+            .header("x-amz-bucket-region", equalTo("eu-central-1"));
+
+        given()
+        .when()
+            .delete("/" + bucket)
+        .then()
+            .statusCode(204);
+    }
+
+    @Test
+    @Order(18)
+    void createBucketUsesSigningRegionWhenBodyEmpty() {
+        String bucket = "signed-region-bucket";
+
+        given()
+            .header("Authorization",
+                    "AWS4-HMAC-SHA256 Credential=test/20260325/eu-west-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test")
+        .when()
+            .put("/" + bucket)
+        .then()
+            .statusCode(200)
+            .header("Location", equalTo("/" + bucket));
+
+        given()
+        .when()
+            .head("/" + bucket)
+        .then()
+            .statusCode(200)
+            .header("x-amz-bucket-region", equalTo("eu-west-1"));
+
+        given()
+        .when()
+            .delete("/" + bucket)
+        .then()
+            .statusCode(204);
+    }
+
+    @Test
+    @Order(19)
+    void createBucketRejectsUsEast1LocationConstraint() {
+        String createBucketConfiguration = """
+                <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+                    <LocationConstraint>us-east-1</LocationConstraint>
+                </CreateBucketConfiguration>
+                """;
+
+        given()
+            .contentType("application/xml")
+            .body(createBucketConfiguration)
+        .when()
+            .put("/invalid-location-bucket")
+        .then()
+            .statusCode(400)
+            .body(containsString("InvalidLocationConstraint"));
+    }
+
+    @Test
+    @Order(20)
+    void copyObjectWithNonAsciiKeySucceeds() {
+        String bucket = "copy-nonascii-bucket";
+        String srcKey = "src/テスト画像.png";
+        String dstKey = "dst/テスト画像.png";
+        String encodedSrcKey = "src/%E3%83%86%E3%82%B9%E3%83%88%E7%94%BB%E5%83%8F.png";
+
+        given().put("/" + bucket).then().statusCode(200);
+
+        given()
+            .contentType("application/octet-stream")
+            .body("hello".getBytes())
+        .when()
+            .put("/" + bucket + "/" + srcKey)
+        .then()
+            .statusCode(200);
+
+        given()
+            .header("x-amz-copy-source", "/" + bucket + "/" + encodedSrcKey)
+        .when()
+            .put("/" + bucket + "/" + dstKey)
+        .then()
+            .statusCode(200)
+            .body(containsString("ETag"));
+
+        given()
+        .when()
+            .get("/" + bucket + "/" + dstKey)
+        .then()
+            .statusCode(200)
+            .body(equalTo("hello"));
+
+        given().delete("/" + bucket + "/" + srcKey);
+        given().delete("/" + bucket + "/" + dstKey);
+        given().delete("/" + bucket);
+    }
+
+    @Test
+    @Order(21)
+    void putLargeObject() {
+        // 22 MB – exceeds the old Jackson 20 MB maxStringLength default
+        byte[] largeBody = new byte[22 * 1024 * 1024];
+        Arrays.fill(largeBody, (byte) 'A');
+
+        given()
+        .when()
+            .put("/large-object-bucket")
+        .then()
+            .statusCode(200);
+
+        given()
+            .contentType("application/octet-stream")
+            .body(largeBody)
+        .when()
+            .put("/large-object-bucket/large-file.bin")
+        .then()
+            .statusCode(200)
+            .header("ETag", notNullValue());
+
+        given()
+        .when()
+            .get("/large-object-bucket/large-file.bin")
+        .then()
+            .statusCode(200)
+            .header("Content-Length", String.valueOf(largeBody.length));
+
+        given().delete("/large-object-bucket/large-file.bin");
+        given().delete("/large-object-bucket");
+    }
+
     @Test
     @Order(30)
     void getObjectWithFullRange() {
@@ -558,140 +732,4 @@ void cleanupAndDeleteBucket() {
         .then()
             .statusCode(204);
     }
-
-    @Test
-    @Order(16)
-    void getObjectAttributesRejectsUnknownSelector() {
-        given()
-            .header("x-amz-object-attributes", "ETag,UnknownThing")
-        .when()
-            .get("/test-bucket/greeting.txt?attributes")
-        .then()
-            .statusCode(400)
-            .body(containsString("InvalidArgument"));
-    }
-
-    @Test
-    @Order(17)
-    void getNonExistentBucket() {
-        given()
-        .when()
-            .get("/nonexistent-bucket")
-        .then()
-            .statusCode(404)
-            .body(containsString("NoSuchBucket"));
-    }
-
-    @Test
-    @Order(17)
-    void headBucketReturnsStoredRegionForLocationConstraintBucket() {
-        String bucket = "eu-head-bucket";
-        String createBucketConfiguration = """
-                <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
-                    <LocationConstraint>eu-central-1</LocationConstraint>
-                </CreateBucketConfiguration>
-                """;
-
-        given()
-            .contentType("application/xml")
-            .body(createBucketConfiguration)
-        .when()
-            .put("/" + bucket)
-        .then()
-            .statusCode(200)
-            .header("Location", equalTo("/" + bucket));
-
-        given()
-        .when()
-            .head("/" + bucket)
-        .then()
-            .statusCode(200)
-            .header("x-amz-bucket-region", equalTo("eu-central-1"));
-
-        given()
-        .when()
-            .delete("/" + bucket)
-        .then()
-            .statusCode(204);
-    }
-
-    @Test
-    @Order(18)
-    void createBucketUsesSigningRegionWhenBodyEmpty() {
-        String bucket = "signed-region-bucket";
-
-        given()
-            .header("Authorization",
-                    "AWS4-HMAC-SHA256 Credential=test/20260325/eu-west-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test")
-        .when()
-            .put("/" + bucket)
-        .then()
-            .statusCode(200)
-            .header("Location", equalTo("/" + bucket));
-
-        given()
-        .when()
-            .head("/" + bucket)
-        .then()
-            .statusCode(200)
-            .header("x-amz-bucket-region", equalTo("eu-west-1"));
-
-        given()
-        .when()
-            .delete("/" + bucket)
-        .then()
-            .statusCode(204);
-    }
-
-    @Test
-    @Order(19)
-    void createBucketRejectsUsEast1LocationConstraint() {
-        String createBucketConfiguration = """
-                <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
-                    <LocationConstraint>us-east-1</LocationConstraint>
-                </CreateBucketConfiguration>
-                """;
-
-        given()
-            .contentType("application/xml")
-            .body(createBucketConfiguration)
-        .when()
-            .put("/invalid-location-bucket")
-        .then()
-            .statusCode(400)
-            .body(containsString("InvalidLocationConstraint"));
-    }
-
-    @Test
-    @Order(20)
-    void putLargeObject() {
-        // 22 MB – exceeds the old Jackson 20 MB maxStringLength default
-        byte[] largeBody = new byte[22 * 1024 * 1024];
-        Arrays.fill(largeBody, (byte) 'A');
-
-        given()
-        .when()
-            .put("/large-object-bucket")
-        .then()
-            .statusCode(200);
-
-        given()
-            .contentType("application/octet-stream")
-            .body(largeBody)
-        .when()
-            .put("/large-object-bucket/large-file.bin")
-        .then()
-            .statusCode(200)
-            .header("ETag", notNullValue());
-
-        given()
-        .when()
-            .get("/large-object-bucket/large-file.bin")
-        .then()
-            .statusCode(200)
-            .header("Content-Length", String.valueOf(largeBody.length));
-
-        given().delete("/large-object-bucket/large-file.bin");
-        given().delete("/large-object-bucket");
-    }
 }