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