#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::SystemTime;
use super::isr_coordinator::{IsrCoordinator, LocalIsrCoordinator, isr_window_key};
use super::{ManifestEntry, StaticManifest};
struct IsrRouteState {
in_flight: AtomicBool,
last_attempt: AtomicU64,
}
const REGEN_COOLDOWN_SECS: u64 = 30;
#[derive(Clone)]
pub struct StaticFileLayer {
dist_dir: PathBuf,
manifest: Arc<StaticManifest>,
isr_state: Arc<HashMap<String, IsrRouteState>>,
isr_router: Option<Arc<axum::Router>>,
isr_coordinator: Arc<dyn IsrCoordinator>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolvedStatic {
pub file_path: PathBuf,
pub content_type: http::HeaderValue,
}
#[must_use]
fn is_legal_content_type_byte(b: u8) -> bool {
b == b'\t' || (0x20..0x7f).contains(&b)
}
const GENERIC_RETURN_TYPE_DEFAULTS: [&str; 2] =
["text/plain; charset=utf-8", "application/octet-stream"];
pub(super) fn effective_content_type<'a>(declared: &'a str, route_path: &str) -> &'a str {
if GENERIC_RETURN_TYPE_DEFAULTS.contains(&declared)
&& let Some(from_extension) = crate::assets::content_type_for_opt(route_path)
{
return from_extension;
}
declared
}
#[must_use]
pub(super) fn usable_recorded_content_type(recorded: Option<&str>) -> Option<&str> {
recorded
.map(str::trim)
.filter(|value| !value.is_empty() && value.bytes().all(is_legal_content_type_byte))
}
#[must_use]
pub fn resolved_content_type(
recorded: Option<&str>,
route_path: &str,
file_path: &Path,
) -> http::HeaderValue {
if let Some(recorded) = usable_recorded_content_type(recorded)
&& let Ok(value) = http::HeaderValue::from_str(effective_content_type(recorded, route_path))
{
return value;
}
let derived = crate::assets::content_type_for_opt(route_path).unwrap_or_else(|| {
file_path
.file_name()
.and_then(|name| name.to_str())
.map_or("application/octet-stream", |name| {
crate::assets::content_type_for(name)
})
});
http::HeaderValue::from_static(derived)
}
#[must_use]
pub fn resolved_entry_content_type(
entry: &ManifestEntry,
route_path: &str,
file_path: &Path,
) -> http::HeaderValue {
if entry.content_type_explicit
&& let Some(declared) = usable_recorded_content_type(entry.content_type.as_deref())
&& let Ok(value) = http::HeaderValue::from_str(declared)
{
return value;
}
resolved_content_type(entry.content_type.as_deref(), route_path, file_path)
}
fn isr_expected_content_type(
entry: &ManifestEntry,
route_path: &str,
file_path: &Path,
) -> Option<String> {
let usable = usable_recorded_content_type(entry.content_type.as_deref());
if entry.content_type_explicit {
return match usable {
Some(_) => None,
None => resolved_content_type(None, route_path, file_path)
.to_str()
.ok()
.map(str::to_owned),
};
}
usable.map(|recorded| effective_content_type(recorded, route_path).to_owned())
}
impl StaticFileLayer {
pub fn new(dist_dir: impl Into<PathBuf>) -> Option<Self> {
let dist_dir = dist_dir.into();
let manifest_path = dist_dir.join("manifest.json");
let manifest = match StaticManifest::load(&manifest_path) {
Ok(manifest) => manifest,
Err(error) => {
if manifest_path.exists() {
tracing::warn!(
manifest = %manifest_path.display(),
%error,
"static manifest could not be parsed; serving every route \
dynamically — re-run `autumn build`"
);
}
return None;
}
};
let isr_state = build_isr_state(&manifest);
Some(Self {
dist_dir,
manifest: Arc::new(manifest),
isr_state: Arc::new(isr_state),
isr_router: None,
isr_coordinator: Arc::new(LocalIsrCoordinator::new()),
})
}
#[must_use]
pub fn with_router(mut self, router: axum::Router) -> Self {
self.isr_router = Some(Arc::new(router));
self
}
#[must_use]
pub fn with_isr_coordinator(mut self, coordinator: Arc<dyn IsrCoordinator>) -> Self {
self.isr_coordinator = coordinator;
self
}
#[must_use]
pub fn manifest(&self) -> &StaticManifest {
&self.manifest
}
#[must_use]
pub fn dist_dir(&self) -> &Path {
&self.dist_dir
}
#[must_use]
pub fn resolve(&self, request_path: &str) -> Option<PathBuf> {
self.resolve_entry(request_path).map(|hit| hit.file_path)
}
#[must_use]
pub fn resolve_entry(&self, request_path: &str) -> Option<ResolvedStatic> {
let entry = self.manifest.routes.get(request_path)?;
let file_path = self.dist_dir.join(&entry.file);
if let Some(revalidate) = entry.revalidate {
self.maybe_trigger_isr(
request_path,
&file_path,
revalidate,
isr_expected_content_type(entry, request_path, &file_path).as_deref(),
);
}
let content_type = resolved_entry_content_type(entry, request_path, &file_path);
Some(ResolvedStatic {
file_path,
content_type,
})
}
fn maybe_trigger_isr(
&self,
url_path: &str,
file_path: &Path,
revalidate_secs: u64,
recorded_content_type: Option<&str>,
) {
let is_stale = file_mtime_age_secs(file_path).is_none_or(|age| age > revalidate_secs);
if !is_stale {
return;
}
let Some(route_state) = self.isr_state.get(url_path) else {
return;
};
let Some(router) = &self.isr_router else {
return;
};
let now = unix_now();
let last = route_state.last_attempt.load(Ordering::Relaxed);
if last > 0 && now.saturating_sub(last) < REGEN_COOLDOWN_SECS {
return;
}
if route_state
.in_flight
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
.is_err()
{
return;
}
route_state.last_attempt.store(now, Ordering::Relaxed);
let window_key = isr_window_key(url_path, revalidate_secs, now);
let router = Arc::clone(router);
let url = url_path.to_owned();
let dest = file_path.to_owned();
let in_flight = Arc::clone(&self.isr_state);
let coordinator = Arc::clone(&self.isr_coordinator);
let expected_content_type = recorded_content_type.map(str::to_owned);
tokio::spawn(async move {
let _guard = InFlightReset {
state: &in_flight,
url: &url,
};
let acquired = coordinator.try_acquire(&url, &window_key).await;
if !acquired {
tracing::debug!(
"ISR: another replica holds the lock for this window, skipping (route: {}, backend: {})",
url,
coordinator.backend()
);
return; }
let result =
regenerate_page(&router, &url, &dest, expected_content_type.as_deref()).await;
coordinator.release(&url, &window_key).await;
match result {
Ok(()) => {
tracing::info!("ISR: page regenerated for route: {}", url);
}
Err(e) => {
tracing::warn!("ISR: regeneration failed for route: {}, error: {}", url, e);
}
}
});
}
}
struct InFlightReset<'a> {
state: &'a HashMap<String, IsrRouteState>,
url: &'a str,
}
impl Drop for InFlightReset<'_> {
fn drop(&mut self) {
if let Some(s) = self.state.get(self.url) {
s.in_flight.store(false, Ordering::Release);
}
}
}
fn build_isr_state(manifest: &StaticManifest) -> HashMap<String, IsrRouteState> {
let mut state = HashMap::new();
for (path, entry) in &manifest.routes {
if entry.revalidate.is_some() {
state.insert(
path.clone(),
IsrRouteState {
in_flight: AtomicBool::new(false),
last_attempt: AtomicU64::new(0),
},
);
}
}
state
}
fn content_type_equivalent(a: &str, b: &str) -> bool {
normalize_content_type(a) == normalize_content_type(b)
}
fn split_unquoted_semicolons(value: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut escaped = false;
for (index, byte) in value.bytes().enumerate() {
if escaped {
escaped = false;
} else if byte == b'\\' && in_quotes {
escaped = true;
} else if byte == b'"' {
in_quotes = !in_quotes;
} else if byte == b';' && !in_quotes {
parts.push(&value[start..index]);
start = index + 1;
}
}
parts.push(&value[start..]);
parts
}
fn decode_quoted_pairs(raw_value: &str) -> Cow<'_, str> {
let Some(inner) = raw_value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
else {
return Cow::Borrowed(raw_value);
};
if !inner.contains('\\') {
return Cow::Borrowed(inner);
}
let mut decoded = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(escaped) = chars.next() {
decoded.push(escaped);
}
} else {
decoded.push(c);
}
}
Cow::Owned(decoded)
}
fn parameter_value_case_insensitive(media_type: &str, name: &str) -> bool {
if name == "charset" {
return true;
}
match name {
"type" => media_type == "multipart/related",
"protocol" => matches!(media_type, "multipart/signed" | "multipart/encrypted"),
_ => false,
}
}
fn normalize_content_type(value: &str) -> Vec<String> {
let mut segments = split_unquoted_semicolons(value).into_iter();
let media_type = segments.next().unwrap_or("").trim().to_ascii_lowercase();
let mut parameters: Vec<String> = segments
.filter(|segment| !segment.trim().is_empty())
.map(|segment| {
let segment = segment.trim();
let Some((name, raw_value)) = segment.split_once('=') else {
return segment.to_ascii_lowercase();
};
let name = name.trim().to_ascii_lowercase();
let raw_value = raw_value.trim();
let unquoted = decode_quoted_pairs(raw_value);
if parameter_value_case_insensitive(&media_type, &name) {
format!("{name}={}", unquoted.to_ascii_lowercase())
} else {
format!("{name}={unquoted}")
}
})
.collect();
parameters.sort();
let mut normalized = Vec::with_capacity(parameters.len() + 1);
normalized.push(media_type);
normalized.extend(parameters);
normalized
}
async fn regenerate_page(
router: &axum::Router,
url: &str,
dest: &Path,
expected_content_type: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
let response = router
.clone()
.oneshot(
Request::builder()
.uri(url)
.extension(super::RenderDeadlineExempt)
.body(Body::empty())
.expect("valid request"),
)
.await
.expect("router infallible");
if !response.status().is_success() {
return Err(format!("Handler returned HTTP {} for {}", response.status(), url).into());
}
if let Some(expected) = expected_content_type {
let actual = response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| effective_content_type(v.trim(), url));
if !actual.is_some_and(|actual| content_type_equivalent(actual, expected)) {
tracing::error!(
route = url,
manifest_content_type = expected,
handler_content_type = actual.unwrap_or("<none>"),
"ISR: refusing to regenerate — the handler's Content-Type no longer matches \
the one recorded in the manifest, and the manifest is not rewritten at \
runtime, so the new body would be served under the old header. The existing \
page is still served. Re-run `autumn build` to refresh the manifest."
);
return Err(format!(
"Content-Type changed for {url}: manifest records {expected:?} but the handler \
now declares {actual:?}; refusing to write a body the recorded type would \
mislabel — re-run `autumn build` to refresh the manifest"
)
.into());
}
}
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let temp_path = dest.with_extension("tmp");
std::fs::write(&temp_path, &body_bytes)?;
std::fs::rename(&temp_path, dest)?;
Ok(())
}
#[allow(
clippy::disallowed_methods,
reason = "the OS stamps the file mtime with real time, so compare it with real time"
)]
fn file_mtime_age_secs(path: &Path) -> Option<u64> {
let metadata = std::fs::metadata(path).ok()?;
let mtime = metadata.modified().ok()?;
let elapsed = SystemTime::now().duration_since(mtime).ok()?;
Some(elapsed.as_secs())
}
fn unix_now() -> u64 {
crate::time::clock_unix_secs(&crate::time::AmbientClock)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::static_gen::{ManifestEntry, StaticManifest};
use std::collections::HashMap;
fn create_test_dist() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
std::fs::write(dist.join("index.html"), "<h1>Home</h1>").expect("write index");
std::fs::write(dist.join("about/index.html"), "<h1>About</h1>").expect("write about");
let mut routes = HashMap::new();
routes.insert("/".to_owned(), ManifestEntry::new("index.html".to_owned()));
routes.insert(
"/about".to_owned(),
ManifestEntry::new("about/index.html".to_owned()).with_revalidate(Some(3600)),
);
let manifest = StaticManifest {
generated_at: "2026-03-27T12:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
fn create_parameterized_dist() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("posts/hello")).expect("mkdir posts/hello");
std::fs::create_dir_all(dist.join("posts/world")).expect("mkdir posts/world");
std::fs::write(dist.join("posts/hello/index.html"), "<h1>Hello</h1>").expect("write hello");
std::fs::write(dist.join("posts/world/index.html"), "<h1>World</h1>").expect("write world");
let mut routes = HashMap::new();
routes.insert(
"/posts/hello".to_owned(),
ManifestEntry::new("posts/hello/index.html".to_owned()),
);
routes.insert(
"/posts/world".to_owned(),
ManifestEntry::new("posts/world/index.html".to_owned()),
);
let manifest = StaticManifest {
generated_at: "2026-03-29T12:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
fn create_isr_dist(revalidate: u64) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
std::fs::write(dist.join("about/index.html"), "<h1>About (stale)</h1>")
.expect("write about");
let mut routes = HashMap::new();
routes.insert(
"/about".to_owned(),
ManifestEntry::new("about/index.html".to_owned()).with_revalidate(Some(revalidate)),
);
let manifest = StaticManifest {
generated_at: "2026-03-29T12:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
#[test]
fn layer_loads_from_valid_dist() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist);
assert!(layer.is_some(), "should load from valid dist dir");
}
#[test]
fn layer_returns_none_without_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
let layer = StaticFileLayer::new(tmp.path());
assert!(layer.is_none(), "should return None without manifest.json");
}
#[test]
fn layer_returns_none_for_an_unparseable_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("manifest.json"), "{ not json").expect("write");
assert!(
StaticFileLayer::new(tmp.path()).is_none(),
"an unparseable manifest must disable static serving, not panic"
);
std::fs::write(
tmp.path().join("manifest.json"),
r#"{"generated_at":"1","autumn_version":"0.0.0","routes":{
"/about":{"file":"about/index.html","revalidate":null,
"content_type":"text/html","future_field":42}}}"#,
)
.expect("write");
let layer = StaticFileLayer::new(tmp.path()).expect("unknown keys must be ignored");
assert_eq!(
layer
.resolve_entry("/about")
.expect("resolves")
.content_type,
"text/html"
);
}
#[test]
fn resolve_finds_known_route() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/about");
assert!(resolved.is_some(), "/about should resolve");
assert!(
resolved.unwrap().ends_with("about/index.html"),
"should point to about/index.html"
);
}
#[test]
fn resolve_finds_root() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/");
assert!(resolved.is_some(), "/ should resolve");
assert!(
resolved.unwrap().ends_with("index.html"),
"should point to index.html"
);
}
#[test]
fn resolve_returns_none_for_unknown_route() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/admin");
assert!(resolved.is_none(), "/admin should not resolve");
}
#[test]
fn manifest_accessor() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
assert_eq!(layer.manifest().routes.len(), 2);
}
#[test]
fn resolve_finds_parameterized_routes() {
let tmp = create_parameterized_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let hello = layer.resolve("/posts/hello");
assert!(hello.is_some(), "/posts/hello should resolve");
assert!(hello.unwrap().ends_with("posts/hello/index.html"));
let world = layer.resolve("/posts/world");
assert!(world.is_some(), "/posts/world should resolve");
assert!(world.unwrap().ends_with("posts/world/index.html"));
}
#[test]
fn resolve_returns_none_for_non_generated_param() {
let tmp = create_parameterized_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
let resolved = layer.resolve("/posts/unknown");
assert!(
resolved.is_none(),
"/posts/unknown should not resolve (not pre-rendered)"
);
}
#[test]
fn isr_state_built_for_revalidate_routes() {
let tmp = create_test_dist();
let dist = tmp.path().join("dist");
let layer = StaticFileLayer::new(&dist).expect("layer");
assert!(layer.isr_state.contains_key("/about"));
assert!(!layer.isr_state.contains_key("/"));
}
#[test]
fn file_mtime_age_fresh_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("test.html");
std::fs::write(&file, "test").expect("write");
let age = file_mtime_age_secs(&file).expect("mtime");
assert!(age < 5, "Fresh file should be < 5 seconds old, got {age}");
}
#[test]
fn file_mtime_age_missing_file() {
let age = file_mtime_age_secs(Path::new("/nonexistent/file.html"));
assert!(age.is_none(), "Missing file should return None");
}
#[tokio::test]
async fn isr_triggers_regeneration_for_stale_page() {
let tmp = create_isr_dist(1);
let dist = tmp.path().join("dist");
let file = dist.join("about/index.html");
let old_time = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old_time))
.unwrap_or(());
let router =
axum::Router::new().fallback(axum::routing::get(|| async { "<h1>About (fresh)</h1>" }));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(router);
let resolved = layer.resolve("/about");
assert!(resolved.is_some());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let content = std::fs::read_to_string(&file).unwrap();
assert!(
content == "<h1>About (fresh)</h1>" || content == "<h1>About (stale)</h1>",
"unexpected content: {content}"
);
}
#[tokio::test]
async fn isr_does_not_retrigger_while_in_flight() {
let tmp = create_isr_dist(1);
let dist = tmp.path().join("dist");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
"<h1>Slow</h1>"
}));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(router);
let file = dist.join("about/index.html");
let old_time = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
let _ = filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old_time));
let _ = layer.resolve("/about");
let state = layer.isr_state.get("/about").expect("isr state");
let _ = layer.resolve("/about");
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
assert!(
!state.in_flight.load(Ordering::Relaxed),
"in_flight should be cleared after regeneration"
);
}
#[tokio::test]
async fn regenerate_page_writes_atomically() {
let tmp = tempfile::tempdir().expect("tempdir");
let dest = tmp.path().join("page.html");
std::fs::write(&dest, "old content").expect("write old");
let router = axum::Router::new().fallback(axum::routing::get(|| async { "new content" }));
let result = regenerate_page(&router, "/test", &dest, None).await;
assert!(result.is_ok(), "regeneration failed: {:?}", result.err());
let content = std::fs::read_to_string(&dest).unwrap();
assert_eq!(content, "new content");
assert!(!dest.with_extension("tmp").exists());
}
#[tokio::test]
async fn regenerate_page_fails_on_non_2xx() {
let tmp = tempfile::tempdir().expect("tempdir");
let dest = tmp.path().join("page.html");
std::fs::write(&dest, "old content").expect("write old");
let router = axum::Router::new()
.fallback(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error") });
let result = regenerate_page(&router, "/test", &dest, None).await;
assert!(result.is_err());
let content = std::fs::read_to_string(&dest).unwrap();
assert_eq!(content, "old content");
}
#[tokio::test]
async fn isr_coordinator_deny_clears_in_flight_and_skips_regen() {
use crate::static_gen::isr_coordinator::IsrFuture;
struct DenyCoordinator;
impl IsrCoordinator for DenyCoordinator {
fn backend(&self) -> &'static str {
"deny"
}
fn try_acquire<'a>(&'a self, _: &'a str, _: &'a str) -> IsrFuture<'a, bool> {
Box::pin(async { false })
}
fn release<'a>(&'a self, _: &'a str, _: &'a str) -> IsrFuture<'a, ()> {
Box::pin(async {})
}
}
let tmp = create_isr_dist(1);
let dist = tmp.path().join("dist");
let file = dist.join("about/index.html");
let old_time = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
let _ = filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old_time));
let router =
axum::Router::new().fallback(axum::routing::get(|| async { "should not appear" }));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(router)
.with_isr_coordinator(Arc::new(DenyCoordinator));
let _ = layer.resolve("/about");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let state = layer.isr_state.get("/about").unwrap();
assert!(
!state.in_flight.load(Ordering::Relaxed),
"in_flight must be cleared when coordinator denies acquisition"
);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"<h1>About (stale)</h1>"
);
}
#[test]
fn resolved_content_type_prefers_recorded_value() {
let ct = resolved_content_type(
Some("application/rss+xml"),
"/feed",
Path::new("feed/index.html"),
);
assert_eq!(ct, "application/rss+xml");
}
#[test]
fn resolved_content_type_recorded_overrides_route_extension() {
let ct = resolved_content_type(
Some("application/manifest+json"),
"/feed.xml",
Path::new("feed.xml/index.html"),
);
assert_eq!(
ct, "application/manifest+json",
"`.xml` is a recognized asset extension deriving `application/xml`; \
the recorded value must beat it"
);
}
#[test]
fn resolved_content_type_ignores_empty_recorded_value() {
let ct = resolved_content_type(Some(""), "/about", Path::new("about/index.html"));
assert_eq!(ct, "text/html; charset=utf-8");
}
#[test]
fn resolved_content_type_rejects_blank_and_non_ascii_recorded_values() {
for bad in [" ", "\t", "text/htmlé", "text/html\u{80}"] {
assert!(
http::HeaderValue::from_str(bad).is_ok(),
"{bad:?} is supposed to be a value from_str accepts — otherwise \
this test is not exercising the extra check"
);
assert_eq!(
resolved_content_type(Some(bad), "/about", Path::new("about/index.html")),
"text/html; charset=utf-8",
"{bad:?} must fall back rather than becoming a Content-Type"
);
}
}
#[test]
fn resolved_content_type_trims_recorded_value() {
assert_eq!(
resolved_content_type(
Some(" application/rss+xml "),
"/feed",
Path::new("f.html")
),
"application/rss+xml"
);
}
#[test]
fn content_type_equivalent_ignores_spacing_and_media_type_case() {
assert!(content_type_equivalent(
"text/html;charset=utf-8",
"text/html; charset=utf-8"
));
assert!(content_type_equivalent("TEXT/HTML", " text/html "));
assert!(content_type_equivalent(
"multipart/mixed; BOUNDARY=xyz; charset=utf-8",
"multipart/mixed; charset=utf-8; boundary=xyz"
));
assert!(content_type_equivalent(
"text/html; charset=UTF-8",
"text/html; charset=utf-8"
));
assert!(content_type_equivalent(
r#"multipart/mixed; boundary="xyz""#,
"multipart/mixed; boundary=xyz"
));
assert!(!content_type_equivalent("text/html", "application/json"));
assert!(!content_type_equivalent(
"text/html; charset=utf-8",
"text/html; charset=iso-8859-1"
));
}
#[test]
fn content_type_equivalent_treats_media_type_valued_type_parameter_case_insensitively() {
assert!(content_type_equivalent(
r#"multipart/related; type="TEXT/HTML""#,
r#"multipart/related; type="text/html""#
));
assert!(content_type_equivalent(
"multipart/related; TYPE=Text/Html",
"multipart/related; type=text/html"
));
assert!(content_type_equivalent(
r#"multipart/signed; protocol="APPLICATION/PKCS7-SIGNATURE""#,
r#"multipart/signed; protocol="application/pkcs7-signature""#
));
assert!(content_type_equivalent(
r#"multipart/encrypted; protocol="APPLICATION/PGP-ENCRYPTED""#,
r#"multipart/encrypted; protocol="application/pgp-encrypted""#
));
assert!(!content_type_equivalent(
"multipart/signed; type=BAR",
"multipart/signed; type=bar"
));
assert!(!content_type_equivalent(
"multipart/related; protocol=BAR",
"multipart/related; protocol=bar"
));
assert!(!content_type_equivalent(
r#"multipart/related; type="text/html""#,
r#"multipart/related; type="application/json""#
));
assert!(!content_type_equivalent(
"application/example; type=BAR",
"application/example; type=bar"
));
assert!(!content_type_equivalent(
"multipart/related; type=text/html; boundary=Aa",
"multipart/related; type=text/html; boundary=aa"
));
}
#[test]
fn content_type_equivalent_ignores_empty_parameter_segments() {
assert!(content_type_equivalent(
"text/html; charset=utf-8",
"text/html; charset=utf-8;"
));
assert!(content_type_equivalent(
"text/html; charset=utf-8",
"text/html;;charset=utf-8"
));
assert!(content_type_equivalent("text/html", "text/html; "));
assert!(!content_type_equivalent(
"text/html;",
"text/html; charset=utf-8"
));
}
#[test]
fn content_type_equivalent_preserves_parameter_value_case_and_spacing() {
assert!(!content_type_equivalent(
"multipart/form-data; boundary=Aa",
"multipart/form-data; boundary=aa"
));
assert!(!content_type_equivalent(
r#"multipart/form-data; boundary="a b""#,
r#"multipart/form-data; boundary="ab""#
));
assert!(!content_type_equivalent(
r#"multipart/form-data; boundary="a;b""#,
r#"multipart/form-data; boundary="a;c""#
));
assert!(content_type_equivalent(
r#"multipart/form-data; boundary="a;b""#,
r#"MULTIPART/FORM-DATA; boundary="a;b""#
));
}
#[test]
fn content_type_equivalent_tracks_quotes_and_escapes_in_parameter_values() {
assert!(
!content_type_equivalent(
r#"multipart/mixed; boundary="a;b""#,
r#"multipart/mixed; boundary="a;B""#
),
"case inside a quoted value is significant even across a quoted `;`"
);
assert!(
!content_type_equivalent(
r#"multipart/mixed; boundary="a\";c""#,
r#"multipart/mixed; boundary="a\";C""#
),
"an escaped quote does not end the quoted string, so the `;` after \
it is still part of the value"
);
assert!(content_type_equivalent("text/html; FOO", "text/html; foo"));
}
#[test]
fn content_type_equivalent_decodes_quoted_pairs_in_parameter_values() {
assert!(content_type_equivalent(
r#"multipart/mixed; boundary="a\b""#,
"multipart/mixed; boundary=ab"
));
assert!(!content_type_equivalent(
r#"multipart/mixed; boundary="a\"b""#,
"multipart/mixed; boundary=ab"
));
assert!(!content_type_equivalent(
r#"multipart/mixed; boundary="a\\b""#,
"multipart/mixed; boundary=ab"
));
assert!(content_type_equivalent(
r#"multipart/mixed; boundary="abc\""#,
"multipart/mixed; boundary=abc"
));
}
#[tokio::test]
async fn isr_ignores_unusable_recorded_content_type_and_still_regenerates() {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir");
let file = dist.join("about/index.html");
std::fs::write(&file, "<h1>stale</h1>").expect("write");
let mut routes = HashMap::new();
routes.insert(
"/about".to_owned(),
ManifestEntry::new("about/index.html")
.with_revalidate(Some(1))
.with_content_type(Some(" ".to_owned())),
);
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&StaticManifest::new(routes)).expect("serialize"),
)
.expect("write manifest");
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
let _ = filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old));
let fresh = axum::Router::new().fallback(axum::routing::get(|| async {
axum::response::Html("<h1>fresh</h1>")
}));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(fresh);
let hit = layer.resolve_entry("/about").expect("resolves");
assert_eq!(hit.content_type, "text/html; charset=utf-8");
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"<h1>fresh</h1>",
"an unusable recorded type must not freeze regeneration"
);
}
#[test]
fn usable_recorded_content_type_matches_what_the_serve_path_honours() {
assert_eq!(
usable_recorded_content_type(Some(" application/rss+xml ")),
Some("application/rss+xml")
);
assert_eq!(
usable_recorded_content_type(Some("application/rss+xml;\tprofile=\"x\"")),
Some("application/rss+xml;\tprofile=\"x\"")
);
assert_eq!(
resolved_content_type(
Some("application/rss+xml;\tprofile=\"x\""),
"/feed",
Path::new("feed/index.html"),
),
"application/rss+xml;\tprofile=\"x\"",
"a tab-bearing type must be served, not derived around"
);
for unusable in [None, Some(""), Some(" "), Some("\t"), Some("text/htmlé")] {
assert!(
usable_recorded_content_type(unusable).is_none(),
"{unusable:?} must be treated as nothing recorded"
);
}
}
#[tokio::test]
async fn regenerate_page_accepts_equivalent_content_type_spelling() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("page.html");
std::fs::write(&dest, "<h1>original</h1>").expect("write");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "TEXT/HTML;charset=UTF-8")],
"<h1>fresh</h1>",
)
}));
regenerate_page(&router, "/page", &dest, Some("text/html; charset=utf-8"))
.await
.expect("an equivalent spelling must not freeze the route");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "<h1>fresh</h1>");
}
#[test]
fn resolved_content_type_rejects_header_illegal_recorded_value() {
for bad in [
"text/html\r\nX-Injected: yes",
"text/html\nX-Injected: yes",
"text/html\u{7f}",
"text/html\0",
] {
let ct = resolved_content_type(Some(bad), "/about", Path::new("about/index.html"));
assert_eq!(
ct, "text/html; charset=utf-8",
"header-illegal recorded value {bad:?} must fall back"
);
}
}
#[test]
fn resolved_content_type_falls_back_to_route_extension() {
assert_eq!(
resolved_content_type(None, "/robots.txt", Path::new("robots.txt/index.html")),
"text/plain; charset=utf-8"
);
assert_eq!(
resolved_content_type(None, "/sitemap.xml", Path::new("sitemap.xml/index.html")),
"application/xml"
);
}
#[test]
fn resolved_content_type_falls_back_to_file_name_for_dotted_slugs() {
assert_eq!(
resolved_content_type(
None,
"/posts/release.v1",
Path::new("posts/release.v1/index.html")
),
"text/html; charset=utf-8"
);
assert_eq!(
resolved_content_type(
None,
"/users/alice@example.com",
Path::new("users/alice@example.com/index.html")
),
"text/html; charset=utf-8"
);
}
#[test]
fn resolved_content_type_falls_back_to_file_name_for_extensionless_routes() {
assert_eq!(
resolved_content_type(None, "/about", Path::new("about/index.html")),
"text/html; charset=utf-8"
);
assert_eq!(
resolved_content_type(None, "/inter", Path::new("fonts/inter.woff2")),
"font/woff2"
);
}
#[test]
fn resolved_content_type_last_resort_is_octet_stream() {
assert_eq!(
resolved_content_type(None, "/data", Path::new("data.bin")),
"application/octet-stream"
);
assert_eq!(
resolved_content_type(None, "/data", Path::new("")),
"application/octet-stream"
);
}
#[test]
fn resolve_entry_decides_content_type() {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(&dist).expect("mkdir");
std::fs::write(dist.join("index.html"), "<h1>Home</h1>").expect("write");
let mut routes = HashMap::new();
routes.insert(
"/".to_owned(),
ManifestEntry::new("index.html").with_content_type(Some("text/html".to_owned())),
);
routes.insert("/bare".to_owned(), ManifestEntry::new("bare/index.html"));
let manifest = StaticManifest {
generated_at: "2026-09-01T00:00:00Z".to_owned(),
autumn_version: "0.6.0".to_owned(),
routes,
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).expect("serialize"),
)
.expect("write manifest");
let layer = StaticFileLayer::new(&dist).expect("layer");
let hit = layer.resolve_entry("/").expect("root resolves");
assert_eq!(hit.file_path, dist.join("index.html"));
assert_eq!(hit.content_type, "text/html");
let bare = layer.resolve_entry("/bare").expect("bare resolves");
assert_eq!(bare.content_type, "text/html; charset=utf-8");
assert!(layer.resolve_entry("/missing").is_none());
}
#[tokio::test]
async fn regenerate_page_refuses_when_content_type_changed() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("page.html");
std::fs::write(&dest, "<h1>original</h1>").expect("write");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"now":"json"}"#,
)
}));
let result =
regenerate_page(&router, "/page", &dest, Some("text/html; charset=utf-8")).await;
let err = result.expect_err("a changed Content-Type must fail regeneration");
assert!(
err.to_string().contains("Content-Type changed"),
"unexpected error: {err}"
);
assert_eq!(
std::fs::read_to_string(&dest).unwrap(),
"<h1>original</h1>",
"the stale-but-correctly-typed file must survive a refused regeneration"
);
}
#[tokio::test]
async fn regenerate_page_refuses_when_content_type_disappears() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("page.html");
std::fs::write(&dest, "<h1>original</h1>").expect("write");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
axum::response::Response::builder()
.status(axum::http::StatusCode::OK)
.body(axum::body::Body::from("bare"))
.unwrap()
}));
let result =
regenerate_page(&router, "/page", &dest, Some("text/html; charset=utf-8")).await;
let err = result.expect_err("a vanished Content-Type must fail regeneration");
assert!(
err.to_string().contains("Content-Type changed"),
"unexpected error: {err}"
);
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "<h1>original</h1>");
}
#[tokio::test]
async fn regenerate_page_writes_when_content_type_matches() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("page.html");
std::fs::write(&dest, "<h1>original</h1>").expect("write");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
axum::response::Html("<h1>fresh</h1>")
}));
regenerate_page(&router, "/page", &dest, Some("text/html; charset=utf-8"))
.await
.expect("matching Content-Type regenerates");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "<h1>fresh</h1>");
}
#[tokio::test]
async fn regenerate_page_accepts_generic_default_normalized_to_extension_type() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("theme.css");
std::fs::write(&dest, "a{}").expect("write");
let router =
axum::Router::new().fallback(axum::routing::get(|| async { "b{}".to_owned() }));
regenerate_page(
&router,
"/theme.css",
&dest,
Some("text/css; charset=utf-8"),
)
.await
.expect("generic default normalizes to the recorded extension type");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "b{}");
}
#[tokio::test]
async fn regenerate_page_refuses_json_over_recorded_extension_type() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("theme.css");
std::fs::write(&dest, "a{}").expect("write");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"x":1}"#,
)
}));
let err = regenerate_page(
&router,
"/theme.css",
&dest,
Some("text/css; charset=utf-8"),
)
.await
.expect_err("JSON must not replace a text/css file");
assert!(err.to_string().contains("Content-Type changed"), "{err}");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "a{}");
}
#[test]
fn resolved_content_type_normalizes_recorded_generic_default() {
let value = resolved_content_type(
Some("text/plain; charset=utf-8"),
"/theme.css",
Path::new("theme.css/index.html"),
);
assert_eq!(value, "text/css; charset=utf-8");
}
#[tokio::test]
async fn regenerate_page_accepts_trimmed_generic_default_header() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("theme.css");
std::fs::write(&dest, "a{}").expect("write");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
(
[(
axum::http::header::CONTENT_TYPE,
" text/plain; charset=utf-8",
)],
"b{}",
)
}));
regenerate_page(
&router,
"/theme.css",
&dest,
Some("text/css; charset=utf-8"),
)
.await
.expect("padded generic default normalizes");
}
#[tokio::test]
async fn isr_guards_extensioned_string_route_built_by_render_static_routes() {
let dir = tempfile::tempdir().expect("tempdir");
let dist_dir = dir.path().join("dist");
let string_router =
|| axum::Router::new().fallback(axum::routing::get(|| async { "a{}".to_owned() }));
let meta = crate::static_gen::StaticRouteMeta {
path: "/theme.css",
name: "theme",
revalidate: Some(1),
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
crate::static_gen::render_static_routes(string_router(), &[meta], &dist_dir)
.await
.expect("render");
let manifest = StaticManifest::load(&dist_dir.join("manifest.json")).expect("manifest");
let recorded = manifest.routes["/theme.css"].content_type.clone();
assert_eq!(recorded.as_deref(), Some("text/css; charset=utf-8"));
let dest = dist_dir.join(&manifest.routes["/theme.css"].file);
regenerate_page(&string_router(), "/theme.css", &dest, recorded.as_deref())
.await
.expect("String handler still regenerates");
let json_router = axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
"{}",
)
}));
regenerate_page(&json_router, "/theme.css", &dest, recorded.as_deref())
.await
.expect_err("JSON must not overwrite the stylesheet");
}
fn explicit_entry(content_type: &str, revalidate: Option<u64>) -> ManifestEntry {
ManifestEntry::new("logo.png/index.html")
.with_revalidate(revalidate)
.with_content_type(Some(content_type.to_owned()))
.with_content_type_explicit(true)
}
#[test]
fn explicit_generic_type_is_served_as_declared() {
let entry = explicit_entry("application/octet-stream", None);
let value =
resolved_entry_content_type(&entry, "/logo.png", Path::new("logo.png/index.html"));
assert_eq!(value, "application/octet-stream");
}
#[test]
fn explicit_entry_with_an_unusable_type_still_falls_back() {
let entry = explicit_entry(" ", None);
let value =
resolved_entry_content_type(&entry, "/logo.png", Path::new("logo.png/index.html"));
assert_eq!(value, "image/png");
}
#[test]
fn explicit_entry_with_a_non_ascii_type_falls_back() {
let entry = explicit_entry("image/p\u{e9}ng", None);
let value =
resolved_entry_content_type(&entry, "/logo.png", Path::new("logo.png/index.html"));
assert_eq!(value, "image/png");
}
#[test]
fn legal_content_type_bytes_match_the_macro_rule() {
for b in 0..=255u8 {
let expected = b == b'\t' || (0x20..0x7f).contains(&b);
assert_eq!(is_legal_content_type_byte(b), expected, "byte {b:#x}");
}
}
#[tokio::test]
async fn declared_type_survives_serving_and_isr() {
let dir = tempfile::tempdir().expect("tempdir");
let dist_dir = dir.path().join("dist");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
([(axum::http::header::CONTENT_TYPE, "text/plain")], "b")
}));
let meta = crate::static_gen::StaticRouteMeta {
path: "/logo.png",
name: "logo",
revalidate: Some(3600),
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: Some("application/octet-stream"),
};
crate::static_gen::render_static_routes(router.clone(), &[meta], &dist_dir)
.await
.expect("render");
let layer = StaticFileLayer::new(&dist_dir).expect("layer");
let hit = layer.resolve_entry("/logo.png").expect("hit");
assert_eq!(hit.content_type, "application/octet-stream");
let manifest = StaticManifest::load(&dist_dir.join("manifest.json")).expect("manifest");
let entry = &manifest.routes["/logo.png"];
let expected = isr_expected_content_type(entry, "/logo.png", &hit.file_path);
regenerate_page(&router, "/logo.png", &hit.file_path, expected.as_deref())
.await
.expect("an explicit entry regenerates whatever the handler header says");
}
#[test]
fn inferred_generic_type_is_still_remapped() {
let value = resolved_content_type(
Some("application/octet-stream"),
"/logo.png",
Path::new("logo.png/index.html"),
);
assert_eq!(value, "image/png");
}
#[test]
fn isr_does_not_guard_the_type_of_an_explicit_entry() {
let entry = explicit_entry("application/octet-stream", Some(1));
let file = Path::new("logo.png/index.html");
assert_eq!(isr_expected_content_type(&entry, "/logo.png", file), None);
}
#[test]
fn isr_guards_the_fallback_type_of_an_unusable_explicit_entry() {
let file = Path::new("logo.png/index.html");
for bad in [None, Some(""), Some(" "), Some("image/p\u{e9}ng")] {
let entry = ManifestEntry::new("logo.png/index.html")
.with_content_type(bad.map(str::to_owned))
.with_content_type_explicit(true);
assert_eq!(
isr_expected_content_type(&entry, "/logo.png", file).as_deref(),
Some("image/png"),
"{bad:?}"
);
}
}
#[test]
fn isr_still_guards_the_type_of_an_inferred_entry() {
let entry = ManifestEntry::new("theme.css/index.html")
.with_content_type(Some("text/plain; charset=utf-8".to_owned()));
assert_eq!(
isr_expected_content_type(&entry, "/theme.css", Path::new("theme.css/index.html"))
.as_deref(),
Some("text/css; charset=utf-8")
);
}
#[test]
fn effective_content_type_maps_generic_default_to_extension_type() {
assert_eq!(
effective_content_type("text/plain; charset=utf-8", "/theme.css"),
"text/css; charset=utf-8"
);
assert_eq!(
effective_content_type("application/octet-stream", "/logo.png"),
"image/png"
);
assert_eq!(
effective_content_type("application/json", "/n.txt"),
"application/json"
);
assert_eq!(
effective_content_type("text/plain; charset=utf-8", "/n.txt"),
"text/plain; charset=utf-8"
);
assert_eq!(
effective_content_type("application/octet-stream", "/r.pdf"),
"application/octet-stream"
);
assert_eq!(
effective_content_type("text/plain", "/theme.css"),
"text/plain"
);
}
#[tokio::test]
async fn regenerate_page_unconstrained_when_nothing_recorded() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("page.html");
std::fs::write(&dest, "<h1>original</h1>").expect("write");
let router = axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"any":"type"}"#,
)
}));
regenerate_page(&router, "/page", &dest, None)
.await
.expect("no recorded type means no constraint");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), r#"{"any":"type"}"#);
}
#[tokio::test]
async fn resolve_entry_keeps_recorded_content_type_for_isr_route() {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("feed")).expect("mkdir");
let file = dist.join("feed/index.html");
std::fs::write(&file, "<rss/>").expect("write");
let mut routes = HashMap::new();
routes.insert(
"/feed".to_owned(),
ManifestEntry::new("feed/index.html")
.with_revalidate(Some(1))
.with_content_type(Some("application/rss+xml".to_owned())),
);
let manifest = StaticManifest {
generated_at: "2026-09-01T00:00:00Z".to_owned(),
autumn_version: "0.7.0".to_owned(),
routes,
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).expect("serialize"),
)
.expect("write manifest");
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
let _ = filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old));
let layer = StaticFileLayer::new(&dist).expect("layer");
let hit = layer.resolve_entry("/feed").expect("resolves");
assert_eq!(hit.content_type, "application/rss+xml");
}
#[tokio::test]
async fn isr_refused_regeneration_keeps_file_and_clears_in_flight() {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir");
let file = dist.join("about/index.html");
std::fs::write(&file, "<h1>About (stale)</h1>").expect("write");
let mut routes = HashMap::new();
routes.insert(
"/about".to_owned(),
ManifestEntry::new("about/index.html")
.with_revalidate(Some(1))
.with_content_type(Some("text/html; charset=utf-8".to_owned())),
);
let manifest = StaticManifest {
generated_at: "2026-09-01T00:00:00Z".to_owned(),
autumn_version: "0.7.0".to_owned(),
routes,
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).expect("serialize"),
)
.expect("write manifest");
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
let _ = filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old));
let drifted_handler = axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"drifted":true}"#,
)
}));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(drifted_handler);
let _ = layer.resolve("/about");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"<h1>About (stale)</h1>",
"a refused regeneration must not overwrite the correctly-typed file"
);
let state = layer.isr_state.get("/about").expect("isr state");
assert!(
!state.in_flight.load(Ordering::Relaxed),
"a refused regeneration must clear in_flight, not wedge the route"
);
}
#[tokio::test]
async fn isr_regenerates_page_built_by_render_static_routes() {
let router = axum::Router::new().route(
"/about",
axum::routing::get(|| async { axum::response::Html("<h1>fresh</h1>") }),
);
let tmp = tempfile::tempdir().expect("tempdir");
let dist = tmp.path().join("dist");
crate::static_gen::render_static_routes(
router.clone(),
&[crate::static_gen::StaticRouteMeta {
path: "/about",
name: "about",
revalidate: Some(1),
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
}],
&dist,
)
.await
.expect("static build");
let manifest = StaticManifest::load(&dist.join("manifest.json")).expect("manifest");
assert_eq!(
manifest.routes["/about"].content_type.as_deref(),
Some("text/html; charset=utf-8")
);
let file = dist.join("about/index.html");
std::fs::write(&file, "<h1>stale</h1>").expect("write");
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(100);
let _ = filetime::set_file_mtime(&file, filetime::FileTime::from_system_time(old));
let layer = StaticFileLayer::new(&dist)
.expect("layer")
.with_router(router);
let _ = layer.resolve("/about");
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"<h1>fresh</h1>",
"a page built by render_static_routes must still be regenerable by ISR — \
a build/ISR disagreement on the recorded Content-Type would freeze it"
);
}
#[tokio::test]
async fn isr_accepts_regeneration_when_a_user_layer_rewrites_content_type() {
use axum::http::header::{CONTENT_TYPE, HeaderValue};
let rewrite = axum::middleware::from_fn(
|req: axum::extract::Request, next: axum::middleware::Next| async move {
let mut response = next.run(req).await;
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/x-rewritten"),
);
response
},
);
let registration = crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<()>(),
type_name: "content_type_rewrite",
layer: tower::util::BoxCloneSyncServiceLayer::new(rewrite.clone()),
};
let (pre_layer, drained) =
crate::router::partition_custom_layers_for_static_render(vec![registration]);
assert!(
pre_layer.is_empty(),
"a plain user layer must not survive the build-time drain"
);
assert_eq!(
drained.len(),
1,
"the drained set carries the rewriting layer"
);
let base = axum::Router::new().route(
"/page",
axum::routing::get(|| async { axum::response::Html("<h1>v1</h1>") }),
);
let layered = base.clone().layer(rewrite);
let meta = || crate::static_gen::StaticRouteMeta {
path: "/page",
name: "page",
revalidate: Some(1),
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
let tmp_old = tempfile::tempdir().expect("tempdir");
let dist_old = tmp_old.path().join("dist");
crate::static_gen::render_static_routes(layered, &[meta()], &dist_old)
.await
.expect("static build");
let manifest_old = StaticManifest::load(&dist_old.join("manifest.json")).expect("manifest");
assert_eq!(
manifest_old.routes["/page"].content_type.as_deref(),
Some("application/x-rewritten"),
"rendering through the layer records the rewritten type"
);
let refused = regenerate_page(
&base,
"/page",
&dist_old.join("page/index.html"),
manifest_old.routes["/page"].content_type.as_deref(),
)
.await;
assert!(
refused.is_err(),
"the old build/ISR asymmetry must refuse the refresh — this is the freeze #2405 fixes"
);
let tmp_new = tempfile::tempdir().expect("tempdir");
let dist_new = tmp_new.path().join("dist");
crate::static_gen::render_static_routes(base.clone(), &[meta()], &dist_new)
.await
.expect("static build");
let manifest_new = StaticManifest::load(&dist_new.join("manifest.json")).expect("manifest");
assert_eq!(
manifest_new.routes["/page"].content_type.as_deref(),
Some("text/html; charset=utf-8"),
"the build must record the handler's pre-layer type, not the layer's rewrite"
);
let page_new = dist_new.join("page/index.html");
std::fs::write(&page_new, "<h1>stale</h1>").expect("write stale");
regenerate_page(
&base,
"/page",
&page_new,
manifest_new.routes["/page"].content_type.as_deref(),
)
.await
.expect("ISR regeneration must accept the pre-layer recording");
assert_eq!(
std::fs::read_to_string(&page_new).unwrap(),
"<h1>v1</h1>",
"the regenerated body is the handler's output"
);
}
}