Skip to content
Closed
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
93 changes: 86 additions & 7 deletions crates/fakecloud-cloudfront/src/dataplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,20 @@ impl CloudFrontDataPlane {
));
};

let path_and_query = parts
.uri
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string();
// A root request is fetched as the distribution's DefaultRootObject; the
// query string is preserved, as CloudFront does.
let path_and_query = match &route.root_object {
Some(object) => match parts.uri.query() {
Some(q) => format!("{object}?{q}"),
None => object.clone(),
},
None => parts
.uri
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string(),
};
let url = format!("{}{path_and_query}", route.upstream.url_base);
trace!(%host, path = %parts.uri.path(), origin = %route.upstream.host_header, "CloudFront data plane: proxying");
let resp = self
Expand Down Expand Up @@ -293,6 +301,9 @@ struct RouteResolution {
default_upstream: UpstreamTarget,
/// CustomErrorResponses that have a response page path.
error_rules: Vec<ErrorRule>,
/// `DefaultRootObject` as an origin path (`/index.html`), set only when this
/// request is for the distribution root and the distribution configures one.
root_object: Option<String>,
}

/// A resolved origin address: the scheme+authority to connect to and the `Host`
Expand Down Expand Up @@ -350,10 +361,21 @@ fn resolve_route(
.collect()
})
.unwrap_or_default();
// DefaultRootObject: a request for the distribution ROOT is served the named
// object from the origin. AWS applies this to the root only -- a request for a
// subdirectory is never rewritten to `<dir>/<object>` -- so the rewrite is
// decided here, from the original viewer path, and the cache behavior is still
// selected on that original path.
let root_object = matches!(path, "" | "/")
.then(|| cfg.default_root_object.as_deref().map(str::trim))
.flatten()
.filter(|o| !o.is_empty())
.map(|o| format!("/{}", o.trim_start_matches('/')));
Some(RouteResolution {
upstream,
default_upstream,
error_rules,
root_object,
})
}

Expand Down Expand Up @@ -497,7 +519,9 @@ fn is_hop_by_hop(name: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{AliasItems, Aliases, CustomOriginConfig, Origin};
use crate::model::{
AliasItems, Aliases, CustomOriginConfig, DefaultCacheBehavior, Origin, OriginItems, Origins,
};
use crate::state::StoredDistribution;
use chrono::Utc;

Expand Down Expand Up @@ -608,6 +632,61 @@ mod tests {
assert_eq!(up.host_header, "b.s3-website-us-east-1.amazonaws.com");
}

fn cfg_with_root(root: Option<&str>) -> DistributionConfig {
DistributionConfig {
default_root_object: root.map(Into::into),
origins: Origins {
quantity: 1,
items: Some(OriginItems {
origin: vec![origin("b.s3.us-east-1.amazonaws.com", None)],
}),
},
default_cache_behavior: DefaultCacheBehavior {
target_origin_id: "o".into(),
..Default::default()
},
..Default::default()
}
}

fn root_object_for(cfg: &DistributionConfig, path: &str) -> Option<String> {
resolve_route(cfg, path, "127.0.0.1:4566")
.expect("route resolves")
.root_object
}

#[test]
fn default_root_object_applies_to_the_distribution_root() {
let cfg = cfg_with_root(Some("index.html"));
assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("/index.html"));
assert_eq!(root_object_for(&cfg, "").as_deref(), Some("/index.html"));
}

#[test]
fn default_root_object_does_not_apply_below_the_root() {
// AWS serves the default root object for the distribution root ONLY; a
// subdirectory request is never rewritten to `<dir>/<object>`.
let cfg = cfg_with_root(Some("index.html"));
assert_eq!(root_object_for(&cfg, "/about/"), None);
assert_eq!(root_object_for(&cfg, "/about"), None);
assert_eq!(root_object_for(&cfg, "/index.html"), None);
}

#[test]
fn default_root_object_unset_or_blank_leaves_the_root_alone() {
for root in [None, Some(""), Some(" ")] {
assert_eq!(root_object_for(&cfg_with_root(root), "/"), None, "{root:?}");
}
}

#[test]
fn default_root_object_is_normalized_to_a_single_leading_slash() {
// AWS rejects a leading slash in the config, but accept one defensively
// rather than proxying a `//index.html` path to the origin.
let cfg = cfg_with_root(Some("/index.html"));
assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("/index.html"));
}

#[test]
fn https_only_custom_origin_uses_https_and_port() {
let up = upstream_for(
Expand Down
65 changes: 65 additions & 0 deletions crates/fakecloud-e2e/tests/cloudfront_dataplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,33 @@ pub async fn make_spa_distribution(
.clone()
}

/// Create a SPA distribution that also sets `DefaultRootObject`.
pub async fn make_spa_distribution_with_root_object(
cf: &aws_sdk_cloudfront::Client,
default_origin_domain: &str,
root_object: &str,
) -> aws_sdk_cloudfront::types::Distribution {
let mut config = spa_config(
default_origin_domain,
None,
&format!("spa-{}", uuid_like()),
true,
true,
&[],
);
config.default_root_object = Some(root_object.to_string());
let create = cf
.create_distribution()
.distribution_config(config)
.send()
.await
.expect("create_distribution");
create
.distribution()
.expect("distribution returned")
.clone()
}

/// Build the SPA distribution config. Shared by create (enabled=true) and the
/// disable-via-update path (enabled=false) so both use an identical shape; the
/// `caller_reference` must be preserved across an UpdateDistribution.
Expand Down Expand Up @@ -644,3 +671,41 @@ async fn api_traffic_is_not_intercepted() {
.await
.expect("s3 list_buckets must pass through the viewer middleware");
}

/// Regression: `DefaultRootObject` was stored on the model but never applied, so a
/// viewer request for the distribution root reached the bucket root and returned
/// S3's `ListBucketResult` XML instead of the SPA shell.
#[tokio::test]
async fn serves_default_root_object_at_the_distribution_root() {
let server = TestServer::start().await;
let s3 = server.s3_client().await;
s3.create_bucket()
.bucket("rootsite")
.send()
.await
.expect("create_bucket");
put_object(&s3, "rootsite", "index.html", "text/html", b"SHELL").await;
put_object(&s3, "rootsite", "nested/index.html", "text/html", b"NESTED").await;

let cf = server.cloudfront_client().await;
let dist = make_spa_distribution_with_root_object(
&cf,
"rootsite.s3-website-us-east-1.amazonaws.com",
"index.html",
)
.await;
assert!(wait_for_served(&server, dist.id(), Duration::from_secs(10)).await);
let host = dist.domain_name();

// The root serves the default root object, not a bucket listing.
let r = viewer_get(&server, host, "/").await;
assert_eq!(r.status(), 200);
assert_eq!(r.text().await.unwrap(), "SHELL");

// A subdirectory is NOT rewritten to `nested/index.html`: AWS applies the
// default root object to the distribution root only. Here the miss falls
// through to the SPA CustomErrorResponse rule instead.
let r = viewer_get(&server, host, "/nested/").await;
assert_eq!(r.status(), 200);
assert_eq!(r.text().await.unwrap(), "SHELL");
}
Loading