use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug)]
pub struct PageConfig<'a> {
pub title: &'a str,
pub route_pattern: &'a str,
pub manifest: &'a AssetManifest,
pub config_json: Option<&'a serde_json::Value>,
pub body_class: Option<&'a str>,
pub personality_css: Option<&'a str>,
pub body_prefix: Option<&'a crate::RawHtml>,
}
pub const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
pub fn document_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
use axum::http::{HeaderName, HeaderValue};
static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
std::sync::OnceLock::new();
HEADERS.get_or_init(|| {
vec![
(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
),
(
HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
),
(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("geolocation=(), camera=(), microphone=()"),
),
(
HeaderName::from_static("cross-origin-opener-policy"),
HeaderValue::from_static("same-origin"),
),
]
})
}
pub fn asset_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
use axum::http::{HeaderName, HeaderValue};
static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
std::sync::OnceLock::new();
HEADERS.get_or_init(|| {
vec![
(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
),
(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
),
]
})
}
#[derive(Debug, Clone)]
pub struct AssetManifest {
pub version: u32,
pub writer: String,
pub app: String,
pub build: String,
pub aliases: HashMap<String, String>,
pub assets: HashMap<String, AssetMetadata>,
pub routes: HashMap<String, RouteAssets>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AssetMetadata {
pub immutable: bool,
pub integrity: String,
#[serde(default)]
pub retained: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RouteMode {
Ssr,
Browser,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RouteAssets {
pub mode: RouteMode,
pub js: Vec<String>,
pub css: Vec<String>,
pub fonts: Vec<String>,
#[serde(default)]
pub modulepreload: Vec<String>,
pub total_size_br: u64,
pub budget_warn_threshold: u64,
#[serde(default)]
pub ir: Option<String>,
}
impl RouteAssets {
pub(crate) fn is_static_ssr(&self) -> bool {
self.mode == RouteMode::Ssr && self.js.is_empty()
}
}
impl<'de> Deserialize<'de> for AssetManifest {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
if value.get("version").and_then(|v| v.as_u64()) != Some(2) {
return Err(serde::de::Error::custom(format!(
"MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
value.get("version").unwrap_or(&serde_json::Value::Null)
)));
}
if value.get("app").is_none() {
return Err(serde::de::Error::custom("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2"));
}
if let Some(assets) = value.get("assets").and_then(serde_json::Value::as_object) {
for (name, metadata) in assets {
if metadata
.get("integrity")
.and_then(serde_json::Value::as_str)
.is_none()
{
return Err(serde::de::Error::custom(format!(
"MANIFEST-103 missing or non-string SHA-384 integrity for {name:?}"
)));
}
}
}
#[derive(Deserialize)]
struct Raw {
version: u32,
writer: String,
app: String,
build: String,
aliases: HashMap<String, String>,
assets: HashMap<String, AssetMetadata>,
routes: HashMap<String, RouteAssets>,
}
let raw: Raw = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
let manifest = AssetManifest {
version: raw.version,
writer: raw.writer,
app: raw.app,
build: raw.build,
aliases: raw.aliases,
assets: raw.assets,
routes: raw.routes,
};
manifest.validate().map_err(serde::de::Error::custom)?;
Ok(manifest)
}
}
impl AssetManifest {
pub fn from_json(json: &str) -> Result<Self, String> {
serde_json::from_str(json).map_err(|e| format!("manifest.json: {e}"))
}
pub fn validate(&self) -> Result<(), String> {
if self.version != 2 {
return Err(format!(
"MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
self.version
));
}
if self.app.is_empty() {
return Err("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2".into());
}
crate::sw::ServiceWorker::for_app(&self.app).map_err(|e| format!("MANIFEST-103 {e}"))?;
let parts = self.writer.split('.').collect::<Vec<_>>();
if parts.len() != 3
|| parts.iter().any(|p| {
p.is_empty()
|| !p.bytes().all(|b| b.is_ascii_digit())
|| (p.len() > 1 && p.starts_with('0'))
})
{
return Err(format!(
"MANIFEST-102 writer {:?} must be a numeric base X.Y.Z without a prerelease suffix",
self.writer
));
}
let mut writer = [0u16; 3];
for (i, part) in parts.iter().enumerate() {
writer[i] = part.parse().map_err(|_| {
format!(
"MANIFEST-102 writer {:?} exceeds the u16 version range",
self.writer
)
})?;
}
let reader = forma_ir::format::reader_version();
if !forma_ir::format::accepts_writer(reader, writer) {
return Err(format!("MANIFEST-102 writer {} is incompatible with reader {}.{}.{}; upgrade the reader within major {} to at least minor {}",self.writer,reader[0],reader[1],reader[2],writer[0],writer[1]));
}
if self.build.is_empty() {
return Err("MANIFEST-103 build must not be empty".into());
}
fn filename(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('/')
&& !name.contains(['\\', '?', '#', ':'])
&& !name.chars().any(char::is_control)
&& name
.split('/')
.all(|p| !p.is_empty() && p != "." && p != "..")
}
for (name, meta) in &self.assets {
if !filename(name) {
return Err(format!("MANIFEST-103 invalid asset filename {name:?}"));
}
if !valid_integrity(&meta.integrity) {
return Err(format!(
"MANIFEST-103 invalid SHA-384 integrity for {name:?}"
));
}
if meta.retained && !meta.immutable {
return Err(format!(
"MANIFEST-103 retained asset {name:?} must be immutable: a retained file is the previous release's, served as a content-named asset"
));
}
}
for (alias, file) in &self.aliases {
if !filename(alias) || !self.assets.contains_key(file) {
return Err(format!(
"MANIFEST-103 alias {alias:?} names unlisted asset {file:?}"
));
}
}
for (pattern, route) in &self.routes {
if !pattern.starts_with('/') {
return Err(format!("MANIFEST-103 route {pattern:?} must begin with /"));
}
match (route.mode,route.ir.as_deref()) {
(RouteMode::Ssr,Some(file)) if file.ends_with(".ir")=>{},
(RouteMode::Browser,None)=>{},
_=>return Err(format!("MANIFEST-103 route {pattern}: ssr requires an .ir module; browser must not declare one")),
}
if route.is_static_ssr() && !route.modulepreload.is_empty() {
return Err(format!("MANIFEST-103 route {pattern}: static SSR must not declare module preloads without a JavaScript entry"));
}
for file in route
.js
.iter()
.chain(&route.css)
.chain(&route.fonts)
.chain(&route.modulepreload)
.chain(route.ir.iter())
{
if !self.assets.contains_key(file) {
return Err(format!("MANIFEST-103 route {pattern} references {file:?}, which is not one of the build's files (the manifest's assets)"));
}
}
}
crate::sw::validate_manifest_app(self)?;
Ok(())
}
pub fn resolve<'a>(&'a self, logical_name: &'a str) -> &'a str {
self.aliases
.get(logical_name)
.map(String::as_str)
.unwrap_or(logical_name)
}
pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
self.routes.get(pattern)
}
}
pub(crate) fn valid_integrity(integrity: &str) -> bool {
use base64::Engine;
let Some(encoded) = integrity.strip_prefix("sha384-") else {
return false;
};
if encoded.len() != 64 {
return false;
}
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
return false;
};
bytes.len() == 48 && base64::engine::general_purpose::STANDARD.encode(bytes) == encoded
}