diff --git a/crates/fakecloud-cloudfront/src/dataplane.rs b/crates/fakecloud-cloudfront/src/dataplane.rs index fa191d47a..f2163e325 100644 --- a/crates/fakecloud-cloudfront/src/dataplane.rs +++ b/crates/fakecloud-cloudfront/src/dataplane.rs @@ -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 — `.s3..amazonaws.com` +/// (what `S3OriginConfig` origins carry; CDK's `S3BucketOrigin` emits the bucket's +/// `RegionalDomainName`), plus the legacy `.s3.amazonaws.com` and +/// dash-separated `.s3-.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..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 @@ -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 (`.s3..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) { @@ -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(), @@ -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( diff --git a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs index d65d40c1f..2f97b1d37 100644 --- a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs +++ b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs @@ -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 +/// (`.s3..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();