mini-static 0.19.1

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use crate::reload::find_subsequence;

/// The `CustomEvent` name the injected spa-mode script dispatches on
/// `window` after every client-side navigation (not the initial page load).
///
/// Site scripts listen for this to re-run per-page initialization that would
/// otherwise only execute once: content swapped in via `innerHTML` (see
/// [`spa_script_tag`]) never executes any `<script>` tags it contains.
const SPA_NAVIGATE_EVENT: &str = "mini-static:navigate";

/// The attribute an `<a>` element can carry to opt out of spa-mode
/// interception, falling through to a normal full-page navigation.
const SPA_OPT_OUT_ATTR: &str = "data-no-spa";

/// The class the injected script adds to `document.documentElement` — after
/// removing [`SPA_NAV_BACK_CLASS`] — immediately before starting a view
/// transition for a navigation that moved to a *higher* history position
/// (an intercepted link click, or the browser Forward button).
///
/// CSS can key off it to give forward and back navigations distinct
/// transitions, e.g. `html.{SPA_NAV_FORWARD_CLASS}::view-transition-old(root)`.
const SPA_NAV_FORWARD_CLASS: &str = "mini-static-nav-forward";

/// The class the injected script adds to `document.documentElement` — after
/// removing [`SPA_NAV_FORWARD_CLASS`] — immediately before starting a view
/// transition for a navigation that moved to a *lower* history position (the
/// browser Back button). See [`SPA_NAV_FORWARD_CLASS`].
const SPA_NAV_BACK_CLASS: &str = "mini-static-nav-back";

/// Escape `input` for embedding as the contents of a double-quoted JS string
/// literal that itself sits inside an HTML `<script>` element.
///
/// Beyond the usual JS string escapes (backslash, double quote, control
/// characters), every `/` is escaped to `\/`. This is what keeps a selector
/// containing `</script` (or any casing/spacing HTML's tokenizer would
/// recognize as a script end tag) from prematurely closing the surrounding
/// `<script>` element and corrupting the rest of the served page — the
/// literal three-byte sequence `</s` never survives escaping, since the `/`
/// immediately after `<` is always turned into `\/`.
fn escape_js_string(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for ch in input.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '/' => out.push_str("\\/"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out
}

/// Render `root_selector` as the JS expression `spa_script_tag`'s generated
/// `ROOT_SELECTOR` constant is assigned: an escaped, double-quoted string
/// literal for `Some`, or the bare `null` for `None` (swap target is
/// `document.body`).
fn root_selector_literal(root_selector: Option<&str>) -> String {
    match root_selector {
        Some(selector) => format!("\"{}\"", escape_js_string(selector)),
        None => "null".to_string(),
    }
}

/// The `<script>` tag `Server` injects into served HTML pages when spa-mode
/// is enabled (see [`crate::Server::with_spa_mode`] /
/// [`crate::Server::with_spa_root`]).
///
/// Intercepts left-clicks on same-origin `<a href>` elements (skipping ones
/// with a non-`_self` `target`, a `download` attribute, `rel="external"`,
/// [`SPA_OPT_OUT_ATTR`], or a same-page hash-only href), fetches the target
/// URL, and — if the response is a successful `text/html` document — swaps
/// the configured root element's `innerHTML` for the fetched document's
/// corresponding content, updates the title, and pushes the new URL via
/// `history.pushState`, instead of letting the browser navigate normally.
///
/// The fetch always runs to completion *before* any view transition starts:
/// `document.startViewTransition()` (used when supported, with a plain
/// synchronous swap as the fallback) wraps only the synchronous DOM mutation
/// and history/scroll/event-dispatch step, never the network round trip —
/// the View Transitions API expects its update callback to resolve
/// immediately, not after an awaited fetch.
///
/// A non-OK response, a non-`text/html` response, or a fetch error all fall
/// back to a real `location.href` navigation — spa-mode degrades to normal
/// navigation, it never renders a broken page.
///
/// `curRoot.innerHTML = newRootHtml` is used to perform the swap — rather
/// than replacing the root node itself — so that event listeners and
/// attributes bound to the root element persist across navigations; this
/// matters most for [`crate::Server::with_spa_root`], where the root is
/// expected to be a long-lived container.
///
/// A monotonic position counter is stored in `history.state` (seeded from
/// the existing entry's state on script load, so it survives a mid-session
/// reload). A link click always advances it and pushes a new entry; a
/// `popstate` reads the entry's own stored position and compares it to the
/// last-known one to tell a Back navigation (lower position) from a Forward
/// one (higher) — the two browser buttons both fire the same `popstate`
/// event, so this is the only reliable way to distinguish them. Immediately
/// before starting the view transition, [`SPA_NAV_FORWARD_CLASS`] or
/// [`SPA_NAV_BACK_CLASS`] is set on `document.documentElement` (the other
/// removed) so page CSS can give the two directions distinct animations via
/// `::view-transition-old(root)` / `::view-transition-new(root)`.
///
/// # Panics
///
/// Never — the returned string is a fixed template with `root_selector`
/// embedded through [`escape_js_string`].
fn spa_script_tag(root_selector: Option<&str>) -> String {
    let root_selector_literal = root_selector_literal(root_selector);

    format!(
        "<script>(function(){{\
			var ROOT_SELECTOR={root_selector_literal};\
			var navToken=0;\
			var historyPos=(history.state&&typeof history.state.pos===\"number\")?history.state.pos:0;\
			history.replaceState({{pos:historyPos}},\"\",location.href);\
			function root(doc){{return ROOT_SELECTOR?doc.querySelector(ROOT_SELECTOR):doc.body;}}\
			function sameOrigin(url){{try{{return new URL(url,location.href).origin===location.origin;}}catch(e){{return false;}}}}\
			function isHashOnly(a){{var u=new URL(a.href,location.href);return u.pathname===location.pathname&&u.search===location.search&&u.hash!==\"\";}}\
			function shouldIntercept(a){{\
				if(!a||!a.href)return false;\
				if(a.hasAttribute(\"{SPA_OPT_OUT_ATTR}\"))return false;\
				if(a.target&&a.target!==\"_self\")return false;\
				if(a.hasAttribute(\"download\"))return false;\
				if(a.getAttribute(\"rel\")===\"external\")return false;\
				if(!sameOrigin(a.href))return false;\
				if(isHashOnly(a))return false;\
				return true;\
			}}\
			function scrollForUrl(url){{\
				var hash=new URL(url,location.href).hash;\
				if(hash){{\
					var el=document.getElementById(hash.slice(1));\
					if(el){{el.scrollIntoView();return;}}\
				}}\
				window.scrollTo(0,0);\
			}}\
			function setNavDirectionClass(back){{\
				var html=document.documentElement;\
				html.classList.remove(\"{SPA_NAV_FORWARD_CLASS}\",\"{SPA_NAV_BACK_CLASS}\");\
				html.classList.add(back?\"{SPA_NAV_BACK_CLASS}\":\"{SPA_NAV_FORWARD_CLASS}\");\
			}}\
			function navigate(url,push,newPos,back){{\
				var token=++navToken;\
				fetch(url).then(function(res){{\
					var ct=res.headers.get(\"content-type\")||\"\";\
					if(!res.ok||ct.indexOf(\"text/html\")===-1){{location.href=url;return null;}}\
					return res.text().then(function(text){{return {{text:text,url:res.url}};}});\
				}}).then(function(result){{\
					if(!result||token!==navToken)return;\
					var doc=new DOMParser().parseFromString(result.text,\"text/html\");\
					var newRoot=root(doc);\
					var curRoot=root(document);\
					if(!newRoot||!curRoot){{location.href=result.url;return;}}\
					var newRootHtml=newRoot.innerHTML;\
					var newTitle=doc.title;\
					function swap(){{\
						curRoot.innerHTML=newRootHtml;\
						document.title=newTitle;\
						historyPos=newPos;\
						if(push)history.pushState({{pos:newPos}},\"\",result.url);\
						scrollForUrl(result.url);\
						window.dispatchEvent(new CustomEvent(\"{SPA_NAVIGATE_EVENT}\",{{detail:{{url:result.url}}}}));\
					}}\
					setNavDirectionClass(back);\
					if(document.startViewTransition){{document.startViewTransition(swap);}}else{{swap();}}\
				}}).catch(function(){{location.href=url;}});\
			}}\
			document.addEventListener(\"click\",function(e){{\
				if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;\
				var a=e.target&&e.target.closest?e.target.closest(\"a[href]\"):null;\
				if(!shouldIntercept(a))return;\
				e.preventDefault();\
				navigate(a.href,true,historyPos+1,false);\
			}});\
			window.addEventListener(\"popstate\",function(){{\
				var newPos=(history.state&&typeof history.state.pos===\"number\")?history.state.pos:0;\
				navigate(location.href,false,newPos,newPos<historyPos);\
			}});\
		}})();</script>"
    )
}

/// Insert the spa-mode client script (see [`spa_script_tag`]) into an HTML
/// document, immediately before the closing `</body>` tag if one is found
/// (checking both `</body>` and `</BODY>`), otherwise appended at the end of
/// the document.
///
/// Operates on raw bytes, mirroring [`crate::reload::inject_reload_script`]
/// exactly (both share [`find_subsequence`]) — `mini-static` has no HTML
/// parser and does not need one for a single fixed-string insertion.
pub(crate) fn inject_spa_script(html: &mut Vec<u8>, root_selector: Option<&str>) {
    let script = spa_script_tag(root_selector);

    let pos = find_subsequence(html, b"</body>").or_else(|| find_subsequence(html, b"</BODY>"));

    match pos {
        Some(pos) => {
            html.splice(pos..pos, script.into_bytes());
        }
        None => html.extend_from_slice(script.as_bytes()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn escape_js_string_escapes_backslash_quote_and_slash() {
        assert_eq!(escape_js_string("a\\b"), "a\\\\b");
        assert_eq!(escape_js_string("a\"b"), "a\\\"b");
        assert_eq!(escape_js_string("a/b"), "a\\/b");
    }

    #[test]
    fn escape_js_string_escapes_control_characters() {
        assert_eq!(escape_js_string("a\u{0007}b"), "a\\u0007b");
    }

    #[test]
    fn escape_js_string_breaks_up_a_closing_script_sequence() {
        // The invariant that matters: no unescaped `</script` (case-insensitive)
        // byte sequence survives -- that's the only sequence HTML's tokenizer
        // treats specially while already inside a <script> element's raw text.
        // An unescaped, slash-free `<script>` elsewhere is inert text there,
        // not a second tag -- the tokenizer isn't scanning for tag-opens in
        // that state, only for its own closing sequence.
        let escaped = escape_js_string("</script>");
        assert_eq!(escaped, "<\\/script>");
        assert!(!escaped.to_lowercase().contains("</script"));
    }

    #[test]
    fn spa_script_tag_embeds_the_navigate_event_name() {
        assert!(spa_script_tag(None).contains(SPA_NAVIGATE_EVENT));
        assert!(spa_script_tag(Some("#app")).contains(SPA_NAVIGATE_EVENT));
    }

    #[test]
    fn spa_script_tag_embeds_both_nav_direction_classes() {
        let script = spa_script_tag(None);
        assert!(script.contains(SPA_NAV_FORWARD_CLASS));
        assert!(script.contains(SPA_NAV_BACK_CLASS));
    }

    #[test]
    fn spa_script_tag_marks_link_clicks_as_forward() {
        // A click always calls navigate(..., back=false) -- the literal
        // `false` immediately following `historyPos+1,` in the click
        // handler's `navigate(...)` call.
        let script = spa_script_tag(None);
        assert!(script.contains("navigate(a.href,true,historyPos+1,false);"));
    }

    #[test]
    fn spa_script_tag_derives_popstate_direction_from_stored_position() {
        // popstate must compare the new entry's stored position against the
        // last-known one -- neither browser button is otherwise distinguishable,
        // since both fire the same event.
        let script = spa_script_tag(None);
        assert!(script.contains("newPos<historyPos"));
        assert!(script.contains("navigate(location.href,false,newPos,newPos<historyPos);"));
    }

    #[test]
    fn spa_script_tag_embeds_the_configured_root_selector() {
        let script = spa_script_tag(Some("#app"));
        assert!(script.contains("#app"));
    }

    #[test]
    fn spa_script_tag_falls_back_to_document_body_when_no_root_configured() {
        let script = spa_script_tag(None);
        assert!(script.contains("doc.body"));
        assert!(script.contains("ROOT_SELECTOR=null;"));
    }

    #[test]
    fn spa_script_tag_embeds_the_selector_with_its_quote_escaped() {
        let script = spa_script_tag(Some(r#"[data-x="y"]"#));
        // Pins the exact generated assignment: a bare `"` here would terminate
        // the string literal early and corrupt the rest of the script.
        assert!(script.contains(r#"ROOT_SELECTOR="[data-x=\"y\"]";"#));
    }

    #[test]
    fn spa_script_tag_neutralizes_a_closing_script_sequence_in_the_selector() {
        let malicious = "</script><script>alert(1)</script>";
        let script = spa_script_tag(Some(malicious));

        // The only `</script` (case-insensitive) sequence allowed to survive
        // is the genuine wrapper's own closing tag -- exactly one.
        let lower = script.to_lowercase();
        assert_eq!(lower.matches("</script").count(), 1);
    }

    #[test]
    fn inject_spa_script_inserts_before_closing_body_tag() {
        let mut html = b"<html><body><h1>hi</h1></body></html>".to_vec();
        inject_spa_script(&mut html, None);
        let s = String::from_utf8(html).unwrap();

        assert!(s.starts_with("<html><body><h1>hi</h1>"));
        assert!(s.ends_with("</body></html>"));
        assert!(s.contains(SPA_NAVIGATE_EVENT));
        assert!(s.find("<script>").unwrap() < s.find("</body>").unwrap());
    }

    #[test]
    fn inject_spa_script_handles_uppercase_closing_tag() {
        let mut html = b"<HTML><BODY>hi</BODY></HTML>".to_vec();
        inject_spa_script(&mut html, None);
        let s = String::from_utf8(html).unwrap();

        assert!(s.find("<script>").unwrap() < s.find("</BODY>").unwrap());
    }

    #[test]
    fn inject_spa_script_appends_when_no_body_tag_present() {
        let mut html = b"<h1>fragment, no body tag</h1>".to_vec();
        inject_spa_script(&mut html, None);
        let s = String::from_utf8(html).unwrap();

        assert!(s.starts_with("<h1>fragment, no body tag</h1>"));
        assert!(s.ends_with("</script>"));
    }

    #[test]
    fn inject_spa_script_is_safe_on_empty_input() {
        let mut html: Vec<u8> = Vec::new();
        inject_spa_script(&mut html, Some("#app"));
        let s = String::from_utf8(html).unwrap();

        assert!(s.starts_with("<script>"));
        assert!(s.ends_with("</script>"));
    }
}