Repository: floci-io/floci (2026-03-26T16:39:51Z)

Issue:
[FEAT] Support virtual paths in S3
## Service

S3

## API Action / Feature

Any request

## AWS Documentation

https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access
https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access
## Why is this needed?

Path style requests will be removed and are effectively deprecated

## Are you willing to contribute a PR?

- [x ] Yes
- [ ] No


Discussion:
Hey @ByronLudwig, thanks for opening this! 
Great that you're willing to work on it. Feel free to go ahead, the issue is yours!

**A few things to help you get started:**

- Check the[Contributing guide](https://hectorvent.dev/floci/contributing/) for setup instructions
- Once fixed, please add a validation test in the[ sdk-test-java](https://github.com/hectorvent/floci-compatibility-tests/tree/main/sdk-test-java) module of [ floci-compatibility-tests](https://github.com/hectorvent/floci-compatibility-tests).
- It's the best way to make sure this doesn't regress

If you have questions or get stuck, drop a comment here and we'll help out

Looking forward to your PR! 💪



Patch:
diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilter.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilter.java
index 888f5c1e..94454d00 100644
--- a/src/main/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilter.java
+++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilter.java
@@ -16,27 +16,69 @@ public void filter(ContainerRequestContext requestContext) {
         String host = requestContext.getHeaderString("Host");
         if (host == null) return;
 
-        // Pattern: bucket.localhost:4566 or bucket.s3.amazonaws.com or bucket.s3.region.amazonaws.com
-        if (host.contains(".localhost") || host.contains(".s3.")) {
-            int dotIndex = host.indexOf('.');
-            String bucket = host.substring(0, dotIndex);
-            
-            // Check if it's not just "s3" or "localhost"
-            if (bucket.equalsIgnoreCase("s3") || bucket.equalsIgnoreCase("localhost")) {
-                return;
+        String bucket = extractBucket(host);
+        if (bucket == null) return;
+
+        URI uri = requestContext.getUriInfo().getRequestUri();
+        String path = uri.getRawPath();
+
+        // Rewrite path from /key to /bucket/key
+        String newPath = "/" + bucket + (path.startsWith("/") ? "" : "/") + path;
+
+        URI newUri = UriBuilder.fromUri(uri)
+                .replacePath(newPath)
+                .build();
+
+        requestContext.setRequestUri(newUri);
+    }
+
+    /**
+     * Extracts a bucket name from a virtual-hosted-style Host header.
+     *
+     * The first label of the hostname (before the first dot) is treated as the
+     * bucket name whenever the hostname contains at least one dot and is not an
+     * IP address. This works for any endpoint hostname — localhost, custom hosts,
+     * or S3-style domains — without requiring configuration.
+     *
+     * Note: AWS SDKs automatically fall back to path-style for bucket names that
+     * contain dots, so the first-label heuristic is sufficient.
+     *
+     * Returns null if the host does not match a virtual-hosted pattern.
+     */
+    static String extractBucket(String host) {
+        if (host == null) return null;
+
+        // Strip port if present
+        String hostname = host;
+        int colonIndex = hostname.lastIndexOf(':');
+        if (colonIndex > 0) {
+            String maybePart = hostname.substring(colonIndex + 1);
+            if (!maybePart.isEmpty() && maybePart.chars().allMatch(Character::isDigit)) {
+                hostname = hostname.substring(0, colonIndex);
             }
+        }
 
-            URI uri = requestContext.getUriInfo().getRequestUri();
-            String path = uri.getRawPath();
-            
-            // Rewrite path from /key to /bucket/key
-            String newPath = "/" + bucket + (path.startsWith("/") ? "" : "/") + path;
-            
-            URI newUri = UriBuilder.fromUri(uri)
-                    .replacePath(newPath)
-                    .build();
-            
-            requestContext.setRequestUri(newUri);
+        // Need at least one dot for a subdomain to exist
+        int firstDot = hostname.indexOf('.');
+        if (firstDot <= 0) {
+            return null;
+        }
+
+        // Skip IPv4 addresses (e.g., 192.168.1.1)
+        if (isIpv4Address(hostname)) {
+            return null;
+        }
+
+        return hostname.substring(0, firstDot);
+    }
+
+    private static boolean isIpv4Address(String hostname) {
+        for (int i = 0; i < hostname.length(); i++) {
+            char c = hostname.charAt(i);
+            if (c != '.' && (c < '0' || c > '9')) {
+                return false;
+            }
         }
+        return true;
     }
 }


Test patch:
diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilterTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilterTest.java
new file mode 100644
index 00000000..6f4d68ca
--- /dev/null
+++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilterTest.java
@@ -0,0 +1,46 @@
+package io.github.hectorvent.floci.services.s3;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class S3VirtualHostFilterTest {
+
+    @ParameterizedTest
+    @CsvSource({
+            // localhost variants
+            "my-bucket.localhost:4566,       my-bucket",
+            "my-bucket.localhost,            my-bucket",
+            // S3-style domains
+            "my-bucket.s3.amazonaws.com,     my-bucket",
+            "my-bucket.s3.amazonaws.com:443, my-bucket",
+            "my-bucket.s3.us-east-1.amazonaws.com,     my-bucket",
+            "my-bucket.s3.eu-west-1.amazonaws.com:443, my-bucket",
+            // Custom / arbitrary hostnames
+            "my-bucket.myhost:4566,          my-bucket",
+            "my-bucket.custom.internal:9000, my-bucket",
+            "my-bucket.emulator.local,       my-bucket",
+    })
+    void extractsBucketFromVirtualHostedStyle(String host, String expectedBucket) {
+        assertEquals(expectedBucket, S3VirtualHostFilter.extractBucket(host));
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+            "localhost:4566",
+            "localhost",
+            "plain-host",
+            "plain-host:8080",
+            "192.168.1.1",
+            "192.168.1.1:4566",
+            "127.0.0.1",
+            "10.0.0.1:9000",
+    })
+    @NullSource
+    void returnsNullForNonVirtualHostedStyle(String host) {
+        assertNull(S3VirtualHostFilter.extractBucket(host));
+    }
+}
diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3VirtualHostIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3VirtualHostIntegrationTest.java
new file mode 100644
index 00000000..a497358b
--- /dev/null
+++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3VirtualHostIntegrationTest.java
@@ -0,0 +1,204 @@
+package io.github.hectorvent.floci.services.s3;
+
+import io.quarkus.test.junit.QuarkusTest;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.*;
+
+/**
+ * Integration tests for virtual-hosted-style S3 requests.
+ * Bucket name is sent via the Host header instead of the path.
+ */
+@QuarkusTest
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class S3VirtualHostIntegrationTest {
+
+    private static final String BUCKET = "vhost-bucket";
+    private static final String HOST = BUCKET + ".localhost";
+
+    @Test
+    @Order(1)
+    void createBucketViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+        .when()
+            .put("/")
+        .then()
+            .statusCode(200)
+            .header("Location", equalTo("/" + BUCKET));
+    }
+
+    @Test
+    @Order(2)
+    void headBucketViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+        .when()
+            .head("/")
+        .then()
+            .statusCode(200)
+            .header("x-amz-bucket-region", notNullValue());
+    }
+
+    @Test
+    @Order(3)
+    void putObjectViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+            .contentType("text/plain")
+            .header("x-amz-meta-source", "virtual-host-test")
+            .body("virtual hosted content")
+        .when()
+            .put("/hello.txt")
+        .then()
+            .statusCode(200)
+            .header("ETag", notNullValue());
+    }
+
+    @Test
+    @Order(4)
+    void getObjectViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+        .when()
+            .get("/hello.txt")
+        .then()
+            .statusCode(200)
+            .header("x-amz-meta-source", equalTo("virtual-host-test"))
+            .body(equalTo("virtual hosted content"));
+    }
+
+    @Test
+    @Order(5)
+    void headObjectViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+        .when()
+            .head("/hello.txt")
+        .then()
+            .statusCode(200)
+            .header("ETag", notNullValue())
+            .header("Content-Length", notNullValue());
+    }
+
+    @Test
+    @Order(6)
+    void putObjectWithNestedKeyViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+            .contentType("application/json")
+            .body("{\"nested\": true}")
+        .when()
+            .put("/path/to/nested.json")
+        .then()
+            .statusCode(200);
+    }
+
+    @Test
+    @Order(7)
+    void listObjectsViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+        .when()
+            .get("/")
+        .then()
+            .statusCode(200)
+            .body(containsString("hello.txt"))
+            .body(containsString("path/to/nested.json"));
+    }
+
+    @Test
+    @Order(8)
+    void listObjectsWithPrefixViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+            .queryParam("prefix", "path/")
+        .when()
+            .get("/")
+        .then()
+            .statusCode(200)
+            .body(containsString("path/to/nested.json"))
+            .body(not(containsString("hello.txt")));
+    }
+
+    @Test
+    @Order(9)
+    void copyObjectViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+            .header("x-amz-copy-source", "/" + BUCKET + "/hello.txt")
+        .when()
+            .put("/hello-copy.txt")
+        .then()
+            .statusCode(200)
+            .body(containsString("CopyObjectResult"));
+
+        given()
+            .header("Host", HOST)
+        .when()
+            .get("/hello-copy.txt")
+        .then()
+            .statusCode(200)
+            .body(equalTo("virtual hosted content"));
+    }
+
+    @Test
+    @Order(10)
+    void deleteObjectViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+        .when()
+            .delete("/hello-copy.txt")
+        .then()
+            .statusCode(204);
+
+        given()
+            .header("Host", HOST)
+        .when()
+            .get("/hello-copy.txt")
+        .then()
+            .statusCode(404);
+    }
+
+    @Test
+    @Order(11)
+    void getObjectNotFoundViaVirtualHost() {
+        given()
+            .header("Host", HOST)
+        .when()
+            .get("/nonexistent.txt")
+        .then()
+            .statusCode(404)
+            .body(containsString("NoSuchKey"));
+    }
+
+    @Test
+    @Order(12)
+    void pathStyleAndVirtualHostSeeTheSameData() {
+        // Object created via virtual-host should be visible via path-style
+        given()
+        .when()
+            .get("/" + BUCKET + "/hello.txt")
+        .then()
+            .statusCode(200)
+            .body(equalTo("virtual hosted content"));
+    }
+
+    @Test
+    @Order(20)
+    void cleanupAndDeleteBucket() {
+        given().header("Host", HOST).delete("/hello.txt");
+        given().header("Host", HOST).delete("/path/to/nested.json");
+
+        given()
+            .header("Host", HOST)
+        .when()
+            .delete("/")
+        .then()
+            .statusCode(204);
+    }
+}