Skip to content

Commit 0e17f73

Browse files
fix(s3): use EmulatorConfig for FLOCI_HOSTNAME to fix native image crash
S3VirtualHostFilter used @ConfigProperty injection in its constructor, which gets baked into the GraalVM native binary at build time. Setting FLOCI_HOSTNAME at runtime via Docker env var caused an IllegalStateException because the runtime value differed from the build-time value (null). Refactor to inject EmulatorConfig (which uses @ConfigMapping and is runtime-safe) instead of raw @ConfigProperty, following the established pattern used by StorageFactory, ServiceRegistry, RegionResolver, etc. Also make bucket extraction hostname-aware: only treat the first label as a bucket name when the remainder matches the configured base hostname (or a well-known AWS S3 domain). This prevents false positives when Floci sits behind a multi-label hostname like floci.svc.cluster.local. Co-Authored-By: Matej Snuderl <ematej.snuderl@gmail.com>
1 parent 46e7f7e commit 0e17f73

2 files changed

Lines changed: 142 additions & 43 deletions

File tree

src/main/java/io/github/hectorvent/floci/services/s3/S3VirtualHostFilter.java

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
package io.github.hectorvent.floci.services.s3;
22

3+
import io.github.hectorvent.floci.config.EmulatorConfig;
4+
import jakarta.inject.Inject;
35
import jakarta.ws.rs.container.ContainerRequestContext;
46
import jakarta.ws.rs.container.ContainerRequestFilter;
57
import jakarta.ws.rs.container.PreMatching;
68
import jakarta.ws.rs.core.UriBuilder;
79
import jakarta.ws.rs.ext.Provider;
10+
811
import java.net.URI;
912

1013
@Provider
1114
@PreMatching
1215
public class S3VirtualHostFilter implements ContainerRequestFilter {
1316

17+
private final String baseHostname;
18+
19+
@Inject
20+
public S3VirtualHostFilter(EmulatorConfig config) {
21+
this.baseHostname = extractHostnameFromUrl(config.effectiveBaseUrl());
22+
}
23+
1424
@Override
1525
public void filter(ContainerRequestContext requestContext) {
1626
String host = requestContext.getHeaderString("Host");
@@ -22,7 +32,7 @@ public void filter(ContainerRequestContext requestContext) {
2232
return;
2333
}
2434

25-
// S3 does not use these content types for bucket/object operations,
35+
// S3 does not use these content types for bucket/object operations,
2636
// but other AWS services (AwsQuery, JSON protocols) do.
2737
String contentType = requestContext.getHeaderString("Content-Type");
2838
if (contentType != null && (
@@ -31,7 +41,7 @@ public void filter(ContainerRequestContext requestContext) {
3141
return;
3242
}
3343

34-
String bucket = extractBucket(host);
44+
String bucket = extractBucket(host, baseHostname);
3545
if (bucket == null) return;
3646

3747
URI uri = requestContext.getUriInfo().getRequestUri();
@@ -50,28 +60,27 @@ public void filter(ContainerRequestContext requestContext) {
5060
/**
5161
* Extracts a bucket name from a virtual-hosted-style Host header.
5262
*
53-
* The first label of the hostname (before the first dot) is treated as the
54-
* bucket name whenever the hostname contains at least one dot and is not an
55-
* IP address. This works for any endpoint hostname — localhost, custom hosts,
56-
* or S3-style domains — without requiring configuration.
63+
* A request is considered virtual-hosted-style when the hostname's remainder
64+
* after the first label matches the configured Floci base hostname, or when it
65+
* matches a well-known AWS S3 domain pattern (for DNS-redirect setups).
5766
*
58-
* Note: AWS SDKs automatically fall back to path-style for bucket names that
59-
* contain dots, so the first-label heuristic is sufficient.
67+
* Examples with baseHostname="localhost":
68+
* my-bucket.localhost:4566 → "my-bucket"
69+
* my-bucket.localhost → "my-bucket"
70+
* floci.svc.cluster.local → null (no bucket prefix, path-style)
71+
* my-svc.floci.svc.cluster.local → null (remainder doesn't match "localhost")
72+
*
73+
* Examples with baseHostname="floci.svc.cluster.local":
74+
* my-bucket.floci.svc.cluster.local → "my-bucket"
75+
* floci.svc.cluster.local → null (no bucket prefix, path-style)
6076
*
6177
* Returns null if the host does not match a virtual-hosted pattern.
6278
*/
63-
static String extractBucket(String host) {
79+
static String extractBucket(String host, String baseHostname) {
6480
if (host == null) return null;
6581

6682
// Strip port if present
67-
String hostname = host;
68-
int colonIndex = hostname.lastIndexOf(':');
69-
if (colonIndex > 0) {
70-
String maybePart = hostname.substring(colonIndex + 1);
71-
if (!maybePart.isEmpty() && maybePart.chars().allMatch(Character::isDigit)) {
72-
hostname = hostname.substring(0, colonIndex);
73-
}
74-
}
83+
String hostname = stripPort(host);
7584

7685
// Need at least one dot for a subdomain to exist
7786
int firstDot = hostname.indexOf('.');
@@ -84,7 +93,42 @@ static String extractBucket(String host) {
8493
return null;
8594
}
8695

87-
return hostname.substring(0, firstDot);
96+
String firstLabel = hostname.substring(0, firstDot);
97+
String remainder = hostname.substring(firstDot + 1);
98+
99+
// Primary: remainder must match the configured base hostname
100+
if (baseHostname != null && remainder.equalsIgnoreCase(baseHostname)) {
101+
return firstLabel;
102+
}
103+
104+
// Fallback: well-known AWS S3 domains, for users who route AWS DNS to Floci
105+
if (isAwsS3Domain(remainder)) {
106+
return firstLabel;
107+
}
108+
109+
return null;
110+
}
111+
112+
/** Extracts the hostname (without scheme or port) from a URL string. */
113+
static String extractHostnameFromUrl(String url) {
114+
if (url == null) return null;
115+
try {
116+
URI uri = URI.create(url);
117+
return uri.getHost();
118+
} catch (Exception e) {
119+
return null;
120+
}
121+
}
122+
123+
private static String stripPort(String host) {
124+
int colonIndex = host.lastIndexOf(':');
125+
if (colonIndex > 0) {
126+
String maybePart = host.substring(colonIndex + 1);
127+
if (!maybePart.isEmpty() && maybePart.chars().allMatch(Character::isDigit)) {
128+
return host.substring(0, colonIndex);
129+
}
130+
}
131+
return host;
88132
}
89133

90134
private static boolean isIpv4Address(String hostname) {
@@ -96,4 +140,12 @@ private static boolean isIpv4Address(String hostname) {
96140
}
97141
return true;
98142
}
143+
144+
/** Returns true for *.s3.amazonaws.com and *.s3.<region>.amazonaws.com domains. */
145+
private static boolean isAwsS3Domain(String remainder) {
146+
if ("s3.amazonaws.com".equals(remainder)) return true;
147+
// s3.<region>.amazonaws.com
148+
if (remainder.startsWith("s3.") && remainder.endsWith(".amazonaws.com")) return true;
149+
return false;
150+
}
99151
}
Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.hectorvent.floci.services.s3;
22

3+
import org.junit.jupiter.api.Test;
34
import org.junit.jupiter.params.ParameterizedTest;
45
import org.junit.jupiter.params.provider.CsvSource;
56
import org.junit.jupiter.params.provider.NullSource;
@@ -9,38 +10,84 @@
910

1011
class S3VirtualHostFilterTest {
1112

13+
// --- extractBucket with baseHostname ---
14+
15+
@ParameterizedTest
16+
@CsvSource({
17+
// Default baseHostname = localhost
18+
"my-bucket.localhost:4566, localhost, my-bucket",
19+
"my-bucket.localhost, localhost, my-bucket",
20+
// Custom baseHostname (Docker Compose service name)
21+
"my-bucket.floci:4566, floci, my-bucket",
22+
"my-bucket.floci, floci, my-bucket",
23+
// Multi-label baseHostname (Kubernetes)
24+
"my-bucket.floci.svc.cluster.local, floci.svc.cluster.local, my-bucket",
25+
})
26+
void extractsBucketWithBaseHostname(String host, String baseHostname, String expectedBucket) {
27+
assertEquals(expectedBucket, S3VirtualHostFilter.extractBucket(host, baseHostname));
28+
}
29+
30+
@ParameterizedTest
31+
@CsvSource({
32+
// AWS S3-style domains work regardless of baseHostname
33+
"my-bucket.s3.amazonaws.com, localhost, my-bucket",
34+
"my-bucket.s3.amazonaws.com:443, localhost, my-bucket",
35+
"my-bucket.s3.us-east-1.amazonaws.com, localhost, my-bucket",
36+
"my-bucket.s3.eu-west-1.amazonaws.com:443, localhost, my-bucket",
37+
})
38+
void extractsBucketFromAwsS3Domains(String host, String baseHostname, String expectedBucket) {
39+
assertEquals(expectedBucket, S3VirtualHostFilter.extractBucket(host, baseHostname));
40+
}
41+
1242
@ParameterizedTest
1343
@CsvSource({
14-
// localhost variants
15-
"my-bucket.localhost:4566, my-bucket",
16-
"my-bucket.localhost, my-bucket",
17-
// S3-style domains
18-
"my-bucket.s3.amazonaws.com, my-bucket",
19-
"my-bucket.s3.amazonaws.com:443, my-bucket",
20-
"my-bucket.s3.us-east-1.amazonaws.com, my-bucket",
21-
"my-bucket.s3.eu-west-1.amazonaws.com:443, my-bucket",
22-
// Custom / arbitrary hostnames
23-
"my-bucket.myhost:4566, my-bucket",
24-
"my-bucket.custom.internal:9000, my-bucket",
25-
"my-bucket.emulator.local, my-bucket",
44+
// No subdomain
45+
"localhost:4566, localhost",
46+
"localhost, localhost",
47+
"floci:4566, floci",
48+
// Remainder doesn't match baseHostname
49+
"my-bucket.other:4566, localhost",
50+
"my-bucket.wrong.host, floci.svc.cluster.local",
51+
// Plain host without dots
52+
"plain-host, localhost",
53+
"plain-host:8080, localhost",
54+
// IPv4 addresses
55+
"192.168.1.1, localhost",
56+
"192.168.1.1:4566, localhost",
57+
"127.0.0.1, localhost",
58+
"10.0.0.1:9000, localhost",
2659
})
27-
void extractsBucketFromVirtualHostedStyle(String host, String expectedBucket) {
28-
assertEquals(expectedBucket, S3VirtualHostFilter.extractBucket(host));
60+
void returnsNullWhenNotVirtualHosted(String host, String baseHostname) {
61+
assertNull(S3VirtualHostFilter.extractBucket(host, baseHostname));
2962
}
3063

64+
@Test
65+
void returnsNullForNullHost() {
66+
assertNull(S3VirtualHostFilter.extractBucket(null, "localhost"));
67+
}
68+
69+
@Test
70+
void returnsNullForNullBaseHostname() {
71+
// Without a baseHostname, only AWS S3 domains should match
72+
assertNull(S3VirtualHostFilter.extractBucket("my-bucket.localhost:4566", null));
73+
assertEquals("my-bucket", S3VirtualHostFilter.extractBucket("my-bucket.s3.amazonaws.com", null));
74+
}
75+
76+
// --- extractHostnameFromUrl ---
77+
3178
@ParameterizedTest
3279
@CsvSource({
33-
"localhost:4566",
34-
"localhost",
35-
"plain-host",
36-
"plain-host:8080",
37-
"192.168.1.1",
38-
"192.168.1.1:4566",
39-
"127.0.0.1",
40-
"10.0.0.1:9000",
80+
"http://localhost:4566, localhost",
81+
"http://floci:4566, floci",
82+
"http://my-host.local:4566, my-host.local",
83+
"https://s3.amazonaws.com, s3.amazonaws.com",
4184
})
42-
@NullSource
43-
void returnsNullForNonVirtualHostedStyle(String host) {
44-
assertNull(S3VirtualHostFilter.extractBucket(host));
85+
void extractsHostnameFromUrl(String url, String expectedHostname) {
86+
assertEquals(expectedHostname, S3VirtualHostFilter.extractHostnameFromUrl(url));
87+
}
88+
89+
@Test
90+
void extractHostnameFromUrlReturnsNullForNull() {
91+
assertNull(S3VirtualHostFilter.extractHostnameFromUrl(null));
4592
}
4693
}

0 commit comments

Comments
 (0)