use std::collections::BTreeMap;
use crate::config::{DeployConfig, TrailingSlash};
use crate::file::FileEntry;
use crate::matcher::Pattern;
use crate::predicate::{EvalEnv, RequestContext};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Redirect {
location: String,
status: u16,
},
File {
path: String,
entry: FileEntry,
},
Proxy {
url: String,
},
NotFound {
error: Option<(String, FileEntry)>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveResult {
pub outcome: Outcome,
pub vary: Vec<String>,
}
pub fn resolve(
config: &DeployConfig,
files: &BTreeMap<String, FileEntry>,
request_path: &str,
) -> Outcome {
resolve_ctx(config, files, request_path, &RequestContext::default()).outcome
}
pub fn resolve_ctx(
config: &DeployConfig,
files: &BTreeMap<String, FileEntry>,
request_path: &str,
ctx: &RequestContext,
) -> ResolveResult {
let path = if request_path.starts_with('/') {
request_path.to_string()
} else {
format!("/{request_path}")
};
let path = normalize_dot_segments(&path);
let mut vary: Vec<String> = Vec::new();
let file_exists = |p: &str| resolve_file(config, files, p).is_some();
let finish = |outcome: Outcome, mut vary: Vec<String>| {
vary.sort();
vary.dedup();
ResolveResult { outcome, vary }
};
if let Some(location) = normalize_trailing_slash(config, &path) {
return finish(
Outcome::Redirect {
location,
status: 308,
},
vary,
);
}
for redirect in &config.redirects {
let Some(m) = Pattern::compile_with(&redirect.from, config.case_insensitive)
.ok()
.and_then(|p| p.match_path(&path))
else {
continue;
};
if !eval_when(&redirect.when, ctx, &path, &file_exists, &mut vary) {
continue; }
let to = interpolate_to(&redirect.to, ctx, &path, &file_exists, &mut vary);
return finish(
Outcome::Redirect {
location: m.expand(&to),
status: redirect.status,
},
vary,
);
}
if let Some((path, entry)) = resolve_file(config, files, &path) {
return finish(Outcome::File { path, entry }, vary);
}
for rewrite in &config.rewrites {
let Some(m) = Pattern::compile_with(&rewrite.from, config.case_insensitive)
.ok()
.and_then(|p| p.match_path(&path))
else {
continue;
};
if !eval_when(&rewrite.when, ctx, &path, &file_exists, &mut vary) {
continue;
}
let to = interpolate_to(&rewrite.to, ctx, &path, &file_exists, &mut vary);
let target = m.expand(&to);
if is_absolute_url(&target) {
return finish(Outcome::Proxy { url: target }, vary);
}
if let Some((path, entry)) = resolve_file(config, files, &target) {
return finish(Outcome::File { path, entry }, vary);
}
}
let error = config.error_documents.get(&404).and_then(|doc| {
let key = doc.trim_start_matches('/').to_string();
files.get(&key).map(|entry| (key, entry.clone()))
});
finish(Outcome::NotFound { error }, vary)
}
fn eval_when(
when: &Option<String>,
ctx: &RequestContext,
path: &str,
file_exists: &dyn Fn(&str) -> bool,
vary: &mut Vec<String>,
) -> bool {
let Some(src) = when else { return true };
match crate::predicate::compile_cached(src) {
Ok(pred) => {
vary.extend(pred.vary_headers().iter().cloned());
pred.eval(&EvalEnv {
ctx,
path,
file_exists,
})
}
Err(_) => false,
}
}
fn interpolate_to(
to: &str,
ctx: &RequestContext,
path: &str,
file_exists: &dyn Fn(&str) -> bool,
vary: &mut Vec<String>,
) -> String {
if !crate::predicate::Template::is_template(to) {
return to.to_string();
}
match crate::predicate::compile_template_cached(to) {
Ok(t) => {
vary.extend(t.vary_headers().iter().cloned());
t.expand(&EvalEnv {
ctx,
path,
file_exists,
})
}
Err(_) => to.to_string(),
}
}
pub fn match_handler<'a>(
handlers: &'a [crate::config::HandlerConfig],
method: &str,
request_path: &str,
) -> Option<&'a crate::config::HandlerConfig> {
let path = if request_path.starts_with('/') {
std::borrow::Cow::Borrowed(request_path)
} else {
std::borrow::Cow::Owned(format!("/{request_path}"))
};
handlers.iter().find(|handler| {
let method_ok = handler.methods.is_empty()
|| handler
.methods
.iter()
.any(|m| m.eq_ignore_ascii_case(method));
method_ok
&& Pattern::compile(&handler.route)
.map(|pattern| pattern.is_match(&path))
.unwrap_or(false)
})
}
fn resolve_file(
config: &DeployConfig,
files: &BTreeMap<String, FileEntry>,
path: &str,
) -> Option<(String, FileEntry)> {
let key = path.trim_start_matches('/');
let ci = config.case_insensitive;
if let Some(hit) = lookup(files, key, ci) {
return Some(hit);
}
if config.clean_urls && !key.is_empty() && !last_segment(key).contains('.') {
let html = format!("{key}.html");
if let Some(hit) = lookup(files, &html, ci) {
return Some(hit);
}
}
let base = key.trim_end_matches('/');
for index in &config.index {
let candidate = if base.is_empty() {
index.clone()
} else {
format!("{base}/{index}")
};
if let Some(hit) = lookup(files, &candidate, ci) {
return Some(hit);
}
}
None
}
fn lookup(
files: &BTreeMap<String, FileEntry>,
key: &str,
case_insensitive: bool,
) -> Option<(String, FileEntry)> {
if let Some(entry) = files.get(key) {
return Some((key.to_string(), entry.clone()));
}
if case_insensitive {
if let Some((k, entry)) = files.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
return Some((k.clone(), entry.clone()));
}
}
None
}
fn normalize_trailing_slash(config: &DeployConfig, path: &str) -> Option<String> {
match config.trailing_slash {
TrailingSlash::Preserve => None,
TrailingSlash::Always => {
if path != "/" && !path.ends_with('/') && !last_segment(path).contains('.') {
Some(format!("{path}/"))
} else {
None
}
}
TrailingSlash::Never => {
if path != "/" && path.ends_with('/') {
Some(path.trim_end_matches('/').to_string())
} else {
None
}
}
}
}
fn normalize_dot_segments(path: &str) -> String {
let trailing = path.ends_with('/')
|| path.ends_with("/.")
|| path.ends_with("/..")
|| path == "."
|| path == "..";
let mut out: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {} ".." => {
out.pop(); }
other => out.push(other),
}
}
let mut normalized = format!("/{}", out.join("/"));
if trailing && !normalized.ends_with('/') {
normalized.push('/');
}
normalized
}
fn last_segment(path: &str) -> &str {
path.trim_end_matches('/').rsplit('/').next().unwrap_or("")
}
fn is_absolute_url(target: &str) -> bool {
target.starts_with("http://") || target.starts_with("https://")
}
pub fn cache_control_default(
served_path: &str,
content_type: Option<&str>,
) -> Option<&'static str> {
if is_fingerprinted(served_path) {
Some("public, max-age=31536000, immutable")
} else if is_html(served_path, content_type) {
Some("public, max-age=0, must-revalidate")
} else {
None
}
}
fn is_fingerprinted(path: &str) -> bool {
let name = last_segment(path);
let Some((stem, ext)) = name.rsplit_once('.') else {
return false;
};
if stem.is_empty() || ext.is_empty() {
return false;
}
let token = stem.rsplit(['.', '-']).next().unwrap_or("");
token.len() >= 8
&& token.bytes().any(|b| b.is_ascii_alphabetic())
&& token.bytes().any(|b| b.is_ascii_digit())
&& token
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn is_html(path: &str, content_type: Option<&str>) -> bool {
if let Some(ct) = content_type {
if ct.split(';').next().map(str::trim) == Some("text/html") {
return true;
}
}
let name = last_segment(path);
name.ends_with(".html") || name.ends_with(".htm")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dot_segments_are_collapsed_and_cannot_escape_root() {
assert_eq!(normalize_dot_segments("/a/./b"), "/a/b");
assert_eq!(normalize_dot_segments("/a//b"), "/a/b");
assert_eq!(normalize_dot_segments("/a/../b"), "/b");
assert_eq!(normalize_dot_segments("/../../etc/passwd"), "/etc/passwd");
assert_eq!(normalize_dot_segments("/a/../../b"), "/b");
assert_eq!(normalize_dot_segments("/a/b/"), "/a/b/");
assert_eq!(normalize_dot_segments("/a/.."), "/");
assert_eq!(normalize_dot_segments("/"), "/");
}
#[test]
fn resolve_serves_through_dot_segments() {
let cfg = DeployConfig::default();
let f = files(&["dir/page.html"]);
match resolve(&cfg, &f, "/dir/sub/../page.html") {
Outcome::File { path, .. } => assert_eq!(path, "dir/page.html"),
other => panic!("expected file, got {other:?}"),
}
}
#[test]
fn cache_default_immutable_for_fingerprinted_assets() {
let immutable = Some("public, max-age=31536000, immutable");
assert_eq!(
cache_control_default("assets/app.4f3a2b2c.js", None),
immutable
);
assert_eq!(cache_control_default("index-a1b2c3d4.css", None), immutable);
assert_eq!(
cache_control_default("main.abcdef12.woff2", None),
immutable
);
assert_eq!(
cache_control_default("vendor.3f8a9c2e1b7d.js", None),
immutable
);
}
#[test]
fn cache_default_skips_non_fingerprinted() {
assert_eq!(cache_control_default("application.js", None), None);
assert_eq!(cache_control_default("app.js", None), None);
assert_eq!(cache_control_default("report-20240115.pdf", None), None);
assert_eq!(cache_control_default("style.min.css", None), None);
}
#[test]
fn cache_default_revalidate_for_html() {
let revalidate = Some("public, max-age=0, must-revalidate");
assert_eq!(cache_control_default("index.html", None), revalidate);
assert_eq!(cache_control_default("about.htm", None), revalidate);
assert_eq!(
cache_control_default("/blog/post", Some("text/html; charset=utf-8")),
revalidate
);
}
fn entry() -> FileEntry {
FileEntry {
hash: "h".into(),
size: 1,
content_type: None,
variants: Default::default(),
}
}
fn files(paths: &[&str]) -> BTreeMap<String, FileEntry> {
paths.iter().map(|p| (p.to_string(), entry())).collect()
}
#[test]
fn serves_exact_and_index() {
let files = files(&["index.html", "blog/index.html", "app.js"]);
let cfg = DeployConfig::default();
assert!(matches!(
resolve(&cfg, &files, "/index.html"),
Outcome::File { .. }
));
assert!(
matches!(resolve(&cfg, &files, "/"), Outcome::File { path, .. } if path == "index.html")
);
assert!(
matches!(resolve(&cfg, &files, "/blog"), Outcome::File { path, .. } if path == "blog/index.html")
);
assert!(matches!(
resolve(&cfg, &files, "/app.js"),
Outcome::File { .. }
));
}
#[test]
fn clean_urls() {
let files = files(&["about.html"]);
let off = DeployConfig::default();
assert!(matches!(
resolve(&off, &files, "/about"),
Outcome::NotFound { .. }
));
let on = DeployConfig {
clean_urls: true,
..Default::default()
};
assert!(
matches!(resolve(&on, &files, "/about"), Outcome::File { path, .. } if path == "about.html")
);
}
#[test]
fn redirect_with_placeholder() {
let mut cfg = DeployConfig::default();
cfg.redirects.push(crate::config::Redirect {
from: "/old/:slug".into(),
to: "/new/:slug".into(),
status: 301,
when: None,
});
assert_eq!(
resolve(&cfg, &BTreeMap::new(), "/old/hi"),
Outcome::Redirect {
location: "/new/hi".into(),
status: 301
}
);
}
#[test]
fn conditional_redirect_honors_when_and_reports_vary() {
let mut cfg = DeployConfig::default();
cfg.redirects.push(crate::config::Redirect {
from: "/".into(),
to: "/fr/".into(),
status: 302,
when: Some("prefers_language(['fr','en']) == 'fr'".into()),
});
let files = files(&["index.html"]);
let fr = RequestContext {
accept_languages: vec!["fr".into()],
..Default::default()
};
let r = resolve_ctx(&cfg, &files, "/", &fr);
assert_eq!(
r.outcome,
Outcome::Redirect {
location: "/fr/".into(),
status: 302
}
);
assert_eq!(r.vary, vec!["accept-language".to_string()]);
let en = RequestContext {
accept_languages: vec!["en".into()],
..Default::default()
};
let r = resolve_ctx(&cfg, &files, "/", &en);
assert!(matches!(r.outcome, Outcome::File { path, .. } if path == "index.html"));
assert_eq!(r.vary, vec!["accept-language".to_string()]);
}
#[test]
fn conditional_redirect_on_missing_file() {
let mut cfg = DeployConfig::default();
cfg.redirects.push(crate::config::Redirect {
from: "/fr/only.html".into(),
to: "/en/only.html".into(),
status: 302,
when: Some("!file_exists(path)".into()),
});
let ctx = RequestContext::default();
let missing = files(&["en/only.html"]);
assert_eq!(
resolve_ctx(&cfg, &missing, "/fr/only.html", &ctx).outcome,
Outcome::Redirect {
location: "/en/only.html".into(),
status: 302
}
);
let present = files(&["fr/only.html", "en/only.html"]);
let r = resolve_ctx(&cfg, &present, "/fr/only.html", &ctx);
assert!(matches!(r.outcome, Outcome::File { path, .. } if path == "fr/only.html"));
assert!(r.vary.is_empty());
}
#[test]
fn conditional_redirect_to_negotiated_locale_in_one_rule() {
let mut cfg = DeployConfig::default();
cfg.redirects.push(crate::config::Redirect {
from: "/".into(),
to: "/${prefers_language(['fr','en','de'])}/".into(),
status: 302,
when: Some("prefers_language(['fr','en','de']) != ''".into()),
});
let files = files(&["index.html"]);
let de = RequestContext {
accept_languages: vec!["de".into()],
..Default::default()
};
let r = resolve_ctx(&cfg, &files, "/", &de);
assert_eq!(
r.outcome,
Outcome::Redirect {
location: "/de/".into(),
status: 302
}
);
assert_eq!(r.vary, vec!["accept-language".to_string()]);
let xx = RequestContext {
accept_languages: vec!["xx".into()],
..Default::default()
};
let r = resolve_ctx(&cfg, &files, "/", &xx);
assert!(matches!(r.outcome, Outcome::File { path, .. } if path == "index.html"));
}
#[test]
fn spa_fallback_via_rewrite() {
let files = files(&["index.html", "assets/app.js"]);
let mut cfg = DeployConfig::default();
cfg.rewrites.push(crate::config::Rewrite {
from: "/**".into(),
to: "/index.html".into(),
status: 200,
when: None,
});
assert!(
matches!(resolve(&cfg, &files, "/assets/app.js"), Outcome::File { path, .. } if path == "assets/app.js")
);
assert!(
matches!(resolve(&cfg, &files, "/deep/route"), Outcome::File { path, .. } if path == "index.html")
);
}
#[test]
fn case_insensitive_serves_static_redirects_and_misses_when_off() {
let files = files(&["assets/App.js", "About.html"]);
let off = DeployConfig::default();
assert!(matches!(
resolve(&off, &files, "/assets/app.js"),
Outcome::NotFound { .. }
));
let mut on = DeployConfig {
case_insensitive: true,
..Default::default()
};
assert!(
matches!(resolve(&on, &files, "/assets/app.js"), Outcome::File { path, .. } if path == "assets/App.js")
);
on.redirects.push(crate::config::Redirect {
from: "/Old/:slug".into(),
to: "/new/:slug".into(),
status: 301,
when: None,
});
assert_eq!(
resolve(&on, &files, "/old/hi"),
Outcome::Redirect {
location: "/new/hi".into(),
status: 301
}
);
}
#[test]
fn proxy_rewrite() {
let mut cfg = DeployConfig::default();
cfg.rewrites.push(crate::config::Rewrite {
from: "/api/**".into(),
to: "https://backend/:splat".into(),
status: 200,
when: None,
});
assert_eq!(
resolve(&cfg, &BTreeMap::new(), "/api/users/1"),
Outcome::Proxy {
url: "https://backend/users/1".into()
}
);
}
#[test]
fn custom_404() {
let files = files(&["404.html"]);
let mut cfg = DeployConfig::default();
cfg.error_documents.insert(404, "/404.html".into());
assert!(matches!(
resolve(&cfg, &files, "/missing"),
Outcome::NotFound { error: Some(_) }
));
}
#[test]
fn trailing_slash_never_redirects() {
let cfg = DeployConfig {
trailing_slash: TrailingSlash::Never,
..Default::default()
};
assert_eq!(
resolve(&cfg, &BTreeMap::new(), "/blog/"),
Outcome::Redirect {
location: "/blog".into(),
status: 308
}
);
}
#[test]
fn handler_matching_respects_route_and_methods() {
use crate::config::HandlerConfig;
let handler = |route: &str, methods: &[&str]| HandlerConfig {
route: route.into(),
methods: methods
.iter()
.map(std::string::ToString::to_string)
.collect(),
component: "h.wasm".into(),
imports: Vec::new(),
limits: None,
env: BTreeMap::new(),
invoke_targets: Vec::new(),
};
let handlers = vec![
handler("/api/orders/*", &["GET", "POST"]),
handler("/hooks/*", &[]),
];
assert_eq!(
match_handler(&handlers, "post", "/api/orders/42").map(|h| h.route.as_str()),
Some("/api/orders/*")
);
assert!(match_handler(&handlers, "DELETE", "/api/orders/42").is_none());
assert!(match_handler(&handlers, "PUT", "/hooks/x").is_some());
assert!(match_handler(&handlers, "GET", "/static/page").is_none());
}
}