use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct ModulePreloads {
routes: Arc<HashMap<String, Vec<String>>>,
}
impl ModulePreloads {
pub(crate) fn from_manifest_json(json: &str) -> Result<Self, String> {
let manifest = crate::AssetManifest::from_json(json)?;
let built: std::collections::HashSet<&str> =
manifest.assets.keys().map(String::as_str).collect();
let mut routes = HashMap::new();
for (route, assets) in &manifest.routes {
let mut files: Vec<String> = Vec::new();
for file in &assets.modulepreload {
if !built.contains(file.as_str()) {
return Err(format!(
"manifest.json: route {route} lists modulepreload {file:?}, which is not \
one of the build's files (the manifest's assets)"
));
}
if !assets.js.contains(file) && !files.contains(file) {
files.push(file.clone());
}
}
if !files.is_empty() {
routes.insert(route.clone(), files);
}
}
Ok(ModulePreloads {
routes: Arc::new(routes),
})
}
pub fn route(&self, route_pattern: &str) -> &[String] {
self.routes.get(route_pattern).map_or(&[], Vec::as_slice)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadLink {
rel: String,
href: String,
mime: Option<String>,
sizes: Option<String>,
}
const LINK_TYPES: &[&str] = &[
"icon",
"apple-touch-icon",
"mask-icon",
"manifest",
"canonical",
"alternate",
"author",
"license",
"preconnect",
"dns-prefetch",
];
const LOADING_TYPES: &[&str] = &[
"stylesheet",
"modulepreload",
"preload",
"prefetch",
"prerender",
"import",
];
impl HeadLink {
pub fn new(rel: &str, href: &str) -> Result<HeadLink, HeadLinkError> {
let refuse = |code, reason| {
Err(HeadLinkError {
rel: rel.to_string(),
href: href.to_string(),
code,
reason,
})
};
let types: Vec<String> = rel
.split_ascii_whitespace()
.map(str::to_ascii_lowercase)
.collect();
if types.iter().any(|t| LOADING_TYPES.contains(&t.as_str())) {
return refuse(
"HEAD-101",
"rel names a link type that loads style or script; a page's stylesheets and \
module preloads come from the build's manifest",
);
}
if types.is_empty() || !types.iter().all(|t| LINK_TYPES.contains(&t.as_str())) {
return refuse(
"HEAD-102",
"rel is not one of the link types a head link carries: icon, apple-touch-icon, \
mask-icon, manifest, canonical, alternate, author, license, preconnect, \
dns-prefetch",
);
}
if is_script_url(href) {
return refuse("HEAD-103", "href is a script URL");
}
if href.trim().is_empty() {
return refuse("HEAD-104", "href is empty");
}
Ok(HeadLink {
rel: types.join(" "),
href: href.to_string(),
mime: None,
sizes: None,
})
}
#[must_use]
pub fn with_type(mut self, mime: &str) -> Self {
self.mime = Some(mime.to_string());
self
}
#[must_use]
pub fn with_sizes(mut self, sizes: &str) -> Self {
self.sizes = Some(sizes.to_string());
self
}
pub(crate) fn write(&self, head: &mut String) {
use crate::template::escape_dom_html;
head.push_str("<link rel=\"");
head.push_str(&escape_dom_html(&self.rel));
head.push_str("\" href=\"");
head.push_str(&escape_dom_html(&self.href));
head.push('"');
if let Some(mime) = &self.mime {
head.push_str(" type=\"");
head.push_str(&escape_dom_html(mime));
head.push('"');
}
if let Some(sizes) = &self.sizes {
head.push_str(" sizes=\"");
head.push_str(&escape_dom_html(sizes));
head.push('"');
}
head.push_str(">\n");
}
}
fn is_script_url(href: &str) -> bool {
let cleaned: String = href
.trim_start_matches(|c: char| c <= ' ')
.chars()
.filter(|c| !matches!(c, '\t' | '\n' | '\r'))
.collect();
let Some((scheme, _)) = cleaned.split_once(':') else {
return false;
};
scheme.eq_ignore_ascii_case("javascript") || scheme.eq_ignore_ascii_case("vbscript")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadLinkError {
rel: String,
href: String,
code: &'static str,
reason: &'static str,
}
impl HeadLinkError {
pub fn code(&self) -> &'static str {
self.code
}
pub fn reason(&self) -> &'static str {
self.reason
}
}
impl fmt::Display for HeadLinkError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} head link rel={:?} href={:?}: {}",
self.code, self.rel, self.href, self.reason
)
}
}
impl std::error::Error for HeadLinkError {}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_with_preloads(preloads: &[&str]) -> String {
serde_json::json!({
"version": 2, "writer":"3.0.0", "app":"ledger", "build":"h",
"aliases": {"home.js": "home.A1B2C3D4.js", "chunk.X.js": "chunk.X.js"},
"assets": {"home.A1B2C3D4.js":{"immutable":true,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}, "chunk.X.js":{"immutable":true,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}},
"routes": {"/": {
"mode":"browser", "js": ["home.A1B2C3D4.js"],
"modulepreload": preloads,
"css": [], "fonts": [], "total_size_br": 0, "budget_warn_threshold": 0
}}
})
.to_string()
}
#[test]
fn a_preload_must_be_one_of_the_builds_files() {
for file in [
"https://cdn.example/x.js",
"//cdn.example/x.js",
"../secret.js",
"\"><script>alert(1)</script>",
"home.js",
"chunk.Y.js",
"",
] {
let error =
ModulePreloads::from_manifest_json(&manifest_with_preloads(&["chunk.X.js", file]))
.expect_err(file);
assert!(error.contains(&format!("{file:?}")), "{error}");
}
let table = ModulePreloads::from_manifest_json(&manifest_with_preloads(&[
"chunk.X.js",
"home.A1B2C3D4.js",
"chunk.X.js",
]))
.expect("loads");
assert_eq!(table.route("/"), ["chunk.X.js"]);
let error = ModulePreloads::from_manifest_json("{").expect_err("not JSON");
assert!(error.starts_with("manifest.json:"), "{error}");
}
#[test]
fn script_urls_are_recognised_as_a_browser_reads_them() {
for href in [
"javascript:alert(1)",
"JavaScript:alert(1)",
" javascript:alert(1)",
"\u{1}javascript:alert(1)",
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"jav\rascript:alert(1)",
"vbscript:msgbox(1)",
"VBSCRIPT:msgbox(1)",
"javascript://host/%0aalert(1)",
] {
assert!(is_script_url(href), "{href:?} is a script URL");
}
for href in [
"/favicon.svg",
"/guides/javascript.svg",
"/javascript:not-a-scheme",
"https://cdn.example/javascript.png",
"data:image/svg+xml,%3Csvg%3E",
"javascripts:x",
"x-javascript:y",
"favicon.ico",
] {
assert!(!is_script_url(href), "{href:?} is not a script URL");
}
}
}