1use serde::Deserialize;
2use std::collections::HashMap;
3
4#[derive(Debug)]
7pub struct PageConfig<'a> {
8 pub title: &'a str,
10 pub route_pattern: &'a str,
13 pub manifest: &'a AssetManifest,
15 pub config_json: Option<&'a serde_json::Value>,
22 pub body_class: Option<&'a str>,
24 pub personality_css: Option<&'a str>,
30 pub body_prefix: Option<&'a crate::RawHtml>,
45}
46
47pub const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
50
51pub fn document_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
66 use axum::http::{HeaderName, HeaderValue};
67 static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
68 std::sync::OnceLock::new();
69 HEADERS.get_or_init(|| {
70 vec![
71 (
72 HeaderName::from_static("x-content-type-options"),
73 HeaderValue::from_static("nosniff"),
74 ),
75 (
76 HeaderName::from_static("referrer-policy"),
77 HeaderValue::from_static("strict-origin-when-cross-origin"),
78 ),
79 (
80 HeaderName::from_static("permissions-policy"),
85 HeaderValue::from_static("geolocation=(), camera=(), microphone=()"),
86 ),
87 (
88 HeaderName::from_static("cross-origin-opener-policy"),
89 HeaderValue::from_static("same-origin"),
90 ),
91 ]
92 })
93}
94
95pub fn asset_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
101 use axum::http::{HeaderName, HeaderValue};
102 static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
103 std::sync::OnceLock::new();
104 HEADERS.get_or_init(|| {
105 vec![
106 (
107 HeaderName::from_static("x-content-type-options"),
108 HeaderValue::from_static("nosniff"),
109 ),
110 (
111 HeaderName::from_static("cross-origin-resource-policy"),
112 HeaderValue::from_static("same-origin"),
113 ),
114 ]
115 })
116}
117
118#[derive(Debug, Clone)]
120pub struct AssetManifest {
121 pub version: u32,
123 pub writer: String,
125 pub app: String,
127 pub build: String,
133 pub aliases: HashMap<String, String>,
135 pub assets: HashMap<String, AssetMetadata>,
137 pub routes: HashMap<String, RouteAssets>,
139}
140
141#[derive(Debug, Clone, Deserialize)]
143pub struct AssetMetadata {
144 pub immutable: bool,
146 pub integrity: String,
148 #[serde(default)]
152 pub retained: bool,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
157#[serde(rename_all = "lowercase")]
158pub enum RouteMode {
159 Ssr,
161 Browser,
163}
164
165#[derive(Debug, Clone, Deserialize)]
167pub struct RouteAssets {
168 #[cfg(feature = "navigation")]
170 #[serde(default)]
171 pub navigation: Option<RouteNavigation>,
172 pub mode: RouteMode,
174 pub js: Vec<String>,
177 pub css: Vec<String>,
179 pub fonts: Vec<String>,
181 #[serde(default)]
183 pub modulepreload: Vec<String>,
184 pub total_size_br: u64,
186 pub budget_warn_threshold: u64,
188 #[serde(default)]
190 pub ir: Option<String>,
191}
192
193#[cfg(feature = "navigation")]
195#[derive(Debug, Clone, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct RouteNavigation {
198 pub group: String,
200 pub owner: String,
202 pub outlet: Option<String>,
204}
205
206impl RouteAssets {
207 pub(crate) fn is_static_ssr(&self) -> bool {
209 self.mode == RouteMode::Ssr && self.js.is_empty()
210 }
211}
212
213impl<'de> Deserialize<'de> for AssetManifest {
214 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
215 let value = serde_json::Value::deserialize(deserializer)?;
216 if value.get("version").and_then(|v| v.as_u64()) != Some(2) {
218 return Err(serde::de::Error::custom(format!(
219 "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
220 value.get("version").unwrap_or(&serde_json::Value::Null)
221 )));
222 }
223 if value.get("app").is_none() {
224 return Err(serde::de::Error::custom("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2"));
225 }
226 if let Some(assets) = value.get("assets").and_then(serde_json::Value::as_object) {
227 for (name, metadata) in assets {
228 if metadata
229 .get("integrity")
230 .and_then(serde_json::Value::as_str)
231 .is_none()
232 {
233 return Err(serde::de::Error::custom(format!(
234 "MANIFEST-103 missing or non-string SHA-384 integrity for {name:?}"
235 )));
236 }
237 }
238 }
239 #[cfg(not(feature = "navigation"))]
243 if let Some(routes) = value.get("routes").and_then(serde_json::Value::as_object) {
244 for (pattern, route) in routes {
245 if route.get("navigation").is_some() {
246 return Err(serde::de::Error::custom(format!(
247 "MANIFEST-103 route {pattern}: native navigation capability requires the forma-server navigation feature"
248 )));
249 }
250 }
251 }
252 #[derive(Deserialize)]
253 struct Raw {
254 version: u32,
255 writer: String,
256 app: String,
257 build: String,
258 aliases: HashMap<String, String>,
259 assets: HashMap<String, AssetMetadata>,
260 routes: HashMap<String, RouteAssets>,
261 }
262 let raw: Raw = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
263 let manifest = AssetManifest {
264 version: raw.version,
265 writer: raw.writer,
266 app: raw.app,
267 build: raw.build,
268 aliases: raw.aliases,
269 assets: raw.assets,
270 routes: raw.routes,
271 };
272 manifest.validate().map_err(serde::de::Error::custom)?;
273 Ok(manifest)
274 }
275}
276
277impl AssetManifest {
278 pub fn from_json(json: &str) -> Result<Self, String> {
280 serde_json::from_str(json).map_err(|e| format!("manifest.json: {e}"))
281 }
282
283 pub fn validate(&self) -> Result<(), String> {
285 if self.version != 2 {
286 return Err(format!(
287 "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
288 self.version
289 ));
290 }
291 if self.app.is_empty() {
292 return Err("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2".into());
293 }
294 crate::sw::ServiceWorker::for_app(&self.app).map_err(|e| format!("MANIFEST-103 {e}"))?;
295 let parts = self.writer.split('.').collect::<Vec<_>>();
296 if parts.len() != 3
297 || parts.iter().any(|p| {
298 p.is_empty()
299 || !p.bytes().all(|b| b.is_ascii_digit())
300 || (p.len() > 1 && p.starts_with('0'))
301 })
302 {
303 return Err(format!(
304 "MANIFEST-102 writer {:?} must be a numeric base X.Y.Z without a prerelease suffix",
305 self.writer
306 ));
307 }
308 let mut writer = [0u16; 3];
309 for (i, part) in parts.iter().enumerate() {
310 writer[i] = part.parse().map_err(|_| {
311 format!(
312 "MANIFEST-102 writer {:?} exceeds the u16 version range",
313 self.writer
314 )
315 })?;
316 }
317 let reader = forma_ir::format::reader_version();
318 if !forma_ir::format::accepts_writer(reader, writer) {
319 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]));
320 }
321 if self.build.is_empty() {
322 return Err("MANIFEST-103 build must not be empty".into());
323 }
324 fn filename(name: &str) -> bool {
325 !name.is_empty()
326 && !name.starts_with('/')
327 && !name.contains(['\\', '?', '#', ':'])
328 && !name.chars().any(char::is_control)
329 && name
330 .split('/')
331 .all(|p| !p.is_empty() && p != "." && p != "..")
332 }
333 for (name, meta) in &self.assets {
334 if !filename(name) {
335 return Err(format!("MANIFEST-103 invalid asset filename {name:?}"));
336 }
337 if !valid_integrity(&meta.integrity) {
338 return Err(format!(
339 "MANIFEST-103 invalid SHA-384 integrity for {name:?}"
340 ));
341 }
342 if meta.retained && !meta.immutable {
343 return Err(format!(
344 "MANIFEST-103 retained asset {name:?} must be immutable: a retained file is the previous release's, served as a content-named asset"
345 ));
346 }
347 }
348 for (alias, file) in &self.aliases {
349 if !filename(alias) || !self.assets.contains_key(file) {
350 return Err(format!(
351 "MANIFEST-103 alias {alias:?} names unlisted asset {file:?}"
352 ));
353 }
354 }
355 for (pattern, route) in &self.routes {
356 if !pattern.starts_with('/') {
357 return Err(format!("MANIFEST-103 route {pattern:?} must begin with /"));
358 }
359 match (route.mode,route.ir.as_deref()) {
360 (RouteMode::Ssr,Some(file)) if file.ends_with(".ir")=>{},
361 (RouteMode::Browser,None)=>{},
362 _=>return Err(format!("MANIFEST-103 route {pattern}: ssr requires an .ir module; browser must not declare one")),
363 }
364 if route.is_static_ssr() && !route.modulepreload.is_empty() {
365 return Err(format!("MANIFEST-103 route {pattern}: static SSR must not declare module preloads without a JavaScript entry"));
366 }
367 #[cfg(feature = "navigation")]
368 if let Some(nav) = &route.navigation {
369 if !crate::navigation::identifier(&nav.group)
370 || !crate::navigation::identifier(&nav.owner)
371 || nav
372 .outlet
373 .as_deref()
374 .is_some_and(|name| !crate::navigation::identifier(name))
375 || route.mode != RouteMode::Ssr
376 || route.js.len() != 1
377 || !route.modulepreload.is_empty()
378 {
379 return Err(format!(
380 "MANIFEST-103 route {pattern}: invalid native navigation owner declaration"
381 ));
382 }
383 }
384 for file in route
385 .js
386 .iter()
387 .chain(&route.css)
388 .chain(&route.fonts)
389 .chain(&route.modulepreload)
390 .chain(route.ir.iter())
391 {
392 if !self.assets.contains_key(file) {
393 return Err(format!("MANIFEST-103 route {pattern} references {file:?}, which is not one of the build's files (the manifest's assets)"));
394 }
395 }
396 }
397 crate::sw::validate_manifest_app(self)?;
398 Ok(())
399 }
400
401 pub fn resolve<'a>(&'a self, logical_name: &'a str) -> &'a str {
403 self.aliases
404 .get(logical_name)
405 .map(String::as_str)
406 .unwrap_or(logical_name)
407 }
408
409 pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
411 self.routes.get(pattern)
412 }
413}
414
415pub(crate) fn valid_integrity(integrity: &str) -> bool {
417 use base64::Engine;
418 let Some(encoded) = integrity.strip_prefix("sha384-") else {
419 return false;
420 };
421 if encoded.len() != 64 {
422 return false;
423 }
424 let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
425 return false;
426 };
427 bytes.len() == 48 && base64::engine::general_purpose::STANDARD.encode(bytes) == encoded
428}