Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion crates/fakecloud-cloudfront/src/dataplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,20 @@ fn is_s3_website(domain: &str) -> bool {
domain.contains(".s3-website") && domain.ends_with(".amazonaws.com")
}

/// An S3 REST endpoint in virtual-hosted style — `<bucket>.s3.<region>.amazonaws.com`
/// (what `S3OriginConfig` origins carry; CDK's `S3BucketOrigin` emits the bucket's
/// `RegionalDomainName`), plus the legacy `<bucket>.s3.amazonaws.com` and
/// dash-separated `<bucket>.s3-<region>.amazonaws.com` forms.
///
/// Delegates to the shared `Host` parser so bucket names containing dots and the
/// LocalStack hostname convention are handled the same way the S3 front door
/// handles them. Requires a bucket, so a path-style `s3.<region>.amazonaws.com`
/// (no bucket to serve) is not treated as an origin of this kind.
fn is_s3_rest(domain: &str) -> bool {
fakecloud_core::protocol::parse_routing_host(domain)
.is_some_and(|h| h.service == "s3" && h.bucket.is_some())
}

/// Resolve an [`crate::model::Origin`] to the upstream to connect to.
///
/// - S3-website origins are served by this same fakecloud process, so connect to
Expand All @@ -392,7 +406,9 @@ fn is_s3_website(domain: &str) -> bool {
/// is fetched over HTTPS (else HTTP), and the configured `HTTPPort`/`HTTPSPort`
/// is appended UNLESS the `domain_name` already carries an explicit `:port`
/// (as local test origins do) or the port is the scheme default.
/// - Bare origins (no config) are reached over HTTP at their domain verbatim.
/// - Bare S3 REST origins (`<bucket>.s3.<region>.amazonaws.com`) are likewise
/// served by this process, with the bucket domain preserved in `Host`.
/// - Other bare origins (no config) are reached over HTTP at their domain verbatim.
fn upstream_for(origin: &crate::model::Origin, s3_endpoint: &str) -> UpstreamTarget {
let domain = &origin.domain_name;
if is_s3_website(domain) {
Expand Down Expand Up @@ -426,6 +442,16 @@ fn upstream_for(origin: &crate::model::Origin, s3_endpoint: &str) -> UpstreamTar
host_header: domain.clone(),
};
}
// A bare S3 REST origin is served by this same process, like an S3-website
// origin: connect to our own port with the bucket domain kept in `Host` so
// the S3 front door resolves it virtual-hosted style. Without this the
// domain resolves in real DNS and the fetch goes to real AWS S3.
if is_s3_rest(domain) {
return UpstreamTarget {
url_base: format!("http://{s3_endpoint}"),
host_header: domain.clone(),
};
}
UpstreamTarget {
url_base: format!("http://{domain}"),
host_header: domain.clone(),
Expand Down Expand Up @@ -608,6 +634,44 @@ mod tests {
assert_eq!(up.host_header, "b.s3-website-us-east-1.amazonaws.com");
}

#[test]
fn s3_rest_origin_routes_to_local_port() {
// What an `S3OriginConfig` origin carries (CDK's `S3BucketOrigin` emits the
// bucket's `RegionalDomainName`). These resolve in real DNS, so without
// rerouting they are proxied to real AWS S3, which answers `NoSuchBucket`.
for domain in [
"b.s3.us-east-1.amazonaws.com",
"b.s3.amazonaws.com",
"b.s3-us-east-1.amazonaws.com",
"my.dotted.bucket.s3.eu-west-2.amazonaws.com",
] {
let up = upstream_for(&origin(domain, None), "127.0.0.1:4566");
assert_eq!(up.url_base, "http://127.0.0.1:4566", "{domain}");
assert_eq!(up.host_header, domain, "{domain}");
}
}

#[test]
fn s3_lookalike_origin_is_not_rerouted() {
// Not an AWS hostname: a real custom origin that must keep its own domain.
let up = upstream_for(&origin("s3.example.com", None), "127.0.0.1:4566");
assert_eq!(up.url_base, "http://s3.example.com");
}

#[test]
fn custom_origin_config_wins_over_s3_rest_domain() {
// An S3 REST domain declared as an explicit custom origin keeps the
// configured scheme/port rather than being rerouted to the local port.
let up = upstream_for(
&origin(
"b.s3.us-east-1.amazonaws.com",
Some(custom("https-only", 80, 8443)),
),
"127.0.0.1:4566",
);
assert_eq!(up.url_base, "https://b.s3.us-east-1.amazonaws.com:8443");
}

#[test]
fn https_only_custom_origin_uses_https_and_port() {
let up = upstream_for(
Expand Down
31 changes: 31 additions & 0 deletions crates/fakecloud-e2e/tests/cloudfront_dataplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,37 @@ async fn serves_static_from_s3_website_origin_and_routes_api() {
assert_eq!(r.text().await.unwrap(), "ECHO /api/orders");
}

/// Regression: an `S3OriginConfig` origin carries the bucket's REST domain
/// (`<bucket>.s3.<region>.amazonaws.com` — what CDK's `S3BucketOrigin` emits),
/// which resolves in real DNS. Before the fix the data plane proxied it to real
/// AWS S3 instead of this process, so the distribution never served the bucket.
#[tokio::test]
async fn serves_static_from_s3_rest_origin() {
let server = TestServer::start().await;
let s3 = server.s3_client().await;
s3.create_bucket()
.bucket("restsite")
.send()
.await
.expect("create_bucket");
put_object(
&s3,
"restsite",
"assets/app.js",
"application/javascript",
b"APPJS",
)
.await;

let cf = server.cloudfront_client().await;
let dist = make_spa_distribution(&cf, "restsite.s3.us-east-1.amazonaws.com", None).await;
assert!(wait_for_served(&server, dist.id(), Duration::from_secs(10)).await);

let r = viewer_get(&server, dist.domain_name(), "/assets/app.js").await;
assert_eq!(r.status(), 200);
assert_eq!(r.text().await.unwrap(), "APPJS");
}

#[tokio::test]
async fn stays_served_after_restart_persistent() {
let tmp = tempfile::tempdir().unwrap();
Expand Down