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 pub mode: RouteMode,
170 pub js: Vec<String>,
173 pub css: Vec<String>,
175 pub fonts: Vec<String>,
177 #[serde(default)]
179 pub modulepreload: Vec<String>,
180 pub total_size_br: u64,
182 pub budget_warn_threshold: u64,
184 #[serde(default)]
186 pub ir: Option<String>,
187}
188
189impl RouteAssets {
190 pub(crate) fn is_static_ssr(&self) -> bool {
192 self.mode == RouteMode::Ssr && self.js.is_empty()
193 }
194}
195
196impl<'de> Deserialize<'de> for AssetManifest {
197 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
198 let value = serde_json::Value::deserialize(deserializer)?;
199 if value.get("version").and_then(|v| v.as_u64()) != Some(2) {
201 return Err(serde::de::Error::custom(format!(
202 "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
203 value.get("version").unwrap_or(&serde_json::Value::Null)
204 )));
205 }
206 if value.get("app").is_none() {
207 return Err(serde::de::Error::custom("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2"));
208 }
209 if let Some(assets) = value.get("assets").and_then(serde_json::Value::as_object) {
210 for (name, metadata) in assets {
211 if metadata
212 .get("integrity")
213 .and_then(serde_json::Value::as_str)
214 .is_none()
215 {
216 return Err(serde::de::Error::custom(format!(
217 "MANIFEST-103 missing or non-string SHA-384 integrity for {name:?}"
218 )));
219 }
220 }
221 }
222 #[derive(Deserialize)]
223 struct Raw {
224 version: u32,
225 writer: String,
226 app: String,
227 build: String,
228 aliases: HashMap<String, String>,
229 assets: HashMap<String, AssetMetadata>,
230 routes: HashMap<String, RouteAssets>,
231 }
232 let raw: Raw = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
233 let manifest = AssetManifest {
234 version: raw.version,
235 writer: raw.writer,
236 app: raw.app,
237 build: raw.build,
238 aliases: raw.aliases,
239 assets: raw.assets,
240 routes: raw.routes,
241 };
242 manifest.validate().map_err(serde::de::Error::custom)?;
243 Ok(manifest)
244 }
245}
246
247impl AssetManifest {
248 pub fn from_json(json: &str) -> Result<Self, String> {
250 serde_json::from_str(json).map_err(|e| format!("manifest.json: {e}"))
251 }
252
253 pub fn validate(&self) -> Result<(), String> {
255 if self.version != 2 {
256 return Err(format!(
257 "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
258 self.version
259 ));
260 }
261 if self.app.is_empty() {
262 return Err("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2".into());
263 }
264 crate::sw::ServiceWorker::for_app(&self.app).map_err(|e| format!("MANIFEST-103 {e}"))?;
265 let parts = self.writer.split('.').collect::<Vec<_>>();
266 if parts.len() != 3
267 || parts.iter().any(|p| {
268 p.is_empty()
269 || !p.bytes().all(|b| b.is_ascii_digit())
270 || (p.len() > 1 && p.starts_with('0'))
271 })
272 {
273 return Err(format!(
274 "MANIFEST-102 writer {:?} must be a numeric base X.Y.Z without a prerelease suffix",
275 self.writer
276 ));
277 }
278 let mut writer = [0u16; 3];
279 for (i, part) in parts.iter().enumerate() {
280 writer[i] = part.parse().map_err(|_| {
281 format!(
282 "MANIFEST-102 writer {:?} exceeds the u16 version range",
283 self.writer
284 )
285 })?;
286 }
287 let reader = forma_ir::format::reader_version();
288 if !forma_ir::format::accepts_writer(reader, writer) {
289 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]));
290 }
291 if self.build.is_empty() {
292 return Err("MANIFEST-103 build must not be empty".into());
293 }
294 fn filename(name: &str) -> bool {
295 !name.is_empty()
296 && !name.starts_with('/')
297 && !name.contains(['\\', '?', '#', ':'])
298 && !name.chars().any(char::is_control)
299 && name
300 .split('/')
301 .all(|p| !p.is_empty() && p != "." && p != "..")
302 }
303 for (name, meta) in &self.assets {
304 if !filename(name) {
305 return Err(format!("MANIFEST-103 invalid asset filename {name:?}"));
306 }
307 if !valid_integrity(&meta.integrity) {
308 return Err(format!(
309 "MANIFEST-103 invalid SHA-384 integrity for {name:?}"
310 ));
311 }
312 if meta.retained && !meta.immutable {
313 return Err(format!(
314 "MANIFEST-103 retained asset {name:?} must be immutable: a retained file is the previous release's, served as a content-named asset"
315 ));
316 }
317 }
318 for (alias, file) in &self.aliases {
319 if !filename(alias) || !self.assets.contains_key(file) {
320 return Err(format!(
321 "MANIFEST-103 alias {alias:?} names unlisted asset {file:?}"
322 ));
323 }
324 }
325 for (pattern, route) in &self.routes {
326 if !pattern.starts_with('/') {
327 return Err(format!("MANIFEST-103 route {pattern:?} must begin with /"));
328 }
329 match (route.mode,route.ir.as_deref()) {
330 (RouteMode::Ssr,Some(file)) if file.ends_with(".ir")=>{},
331 (RouteMode::Browser,None)=>{},
332 _=>return Err(format!("MANIFEST-103 route {pattern}: ssr requires an .ir module; browser must not declare one")),
333 }
334 if route.is_static_ssr() && !route.modulepreload.is_empty() {
335 return Err(format!("MANIFEST-103 route {pattern}: static SSR must not declare module preloads without a JavaScript entry"));
336 }
337 for file in route
338 .js
339 .iter()
340 .chain(&route.css)
341 .chain(&route.fonts)
342 .chain(&route.modulepreload)
343 .chain(route.ir.iter())
344 {
345 if !self.assets.contains_key(file) {
346 return Err(format!("MANIFEST-103 route {pattern} references {file:?}, which is not one of the build's files (the manifest's assets)"));
347 }
348 }
349 }
350 crate::sw::validate_manifest_app(self)?;
351 Ok(())
352 }
353
354 pub fn resolve<'a>(&'a self, logical_name: &'a str) -> &'a str {
356 self.aliases
357 .get(logical_name)
358 .map(String::as_str)
359 .unwrap_or(logical_name)
360 }
361
362 pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
364 self.routes.get(pattern)
365 }
366}
367
368pub(crate) fn valid_integrity(integrity: &str) -> bool {
370 use base64::Engine;
371 let Some(encoded) = integrity.strip_prefix("sha384-") else {
372 return false;
373 };
374 if encoded.len() != 64 {
375 return false;
376 }
377 let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
378 return false;
379 };
380 bytes.len() == 48 && base64::engine::general_purpose::STANDARD.encode(bytes) == encoded
381}