Skip to main content

forma_server/
types.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4/// Document metadata for an explicitly browser-rendered page.
5/// Server-rendered pages use a bound [`crate::PageHandle`].
6#[derive(Debug)]
7pub struct PageConfig<'a> {
8    /// Text for the page's `<title>`, HTML-escaped on the way out.
9    pub title: &'a str,
10    /// The route this page was matched by, used to look up its assets in the
11    /// manifest (e.g. `/users/:id`).
12    pub route_pattern: &'a str,
13    /// Build manifest naming the content-hashed JS/CSS/font/IR files.
14    pub manifest: &'a AssetManifest,
15    /// Optional server data to expose to the client as
16    /// `window.__FORMA_CONFIG__`.
17    ///
18    /// This is the safe path: the value is serialized by `serde_json` with
19    /// every `<` escaped, which cannot be misused no matter what the data
20    /// contains. Application code belongs in the page bundle.
21    pub config_json: Option<&'a serde_json::Value>,
22    /// Optional `class` attribute for `<body>`, HTML-escaped.
23    pub body_class: Option<&'a str>,
24    /// Optional inline `<style>` for personality/theme overrides.
25    ///
26    /// Emitted inside a `<style>` element. `</` is neutralized (as a CSS
27    /// escape, losslessly) so the value cannot break out; everything else is
28    /// passed through as CSS.
29    pub personality_css: Option<&'a str>,
30    /// Optional extra HTML before `<div id="app">`.
31    ///
32    /// **RAW, TRUSTED HTML.** Interpolated verbatim with no escaping — that is
33    /// the point of the field (callers use it to emit their own markup, such
34    /// as a hand-written shell or a logo). Never build it from untrusted data.
35    ///
36    /// Construct reviewed markup with [`RawHtml::trusted`](crate::RawHtml::trusted)
37    /// and borrow it here. Plain strings do not satisfy this field's type.
38    ///
39    /// What is not the application's own markup has a typed home instead:
40    /// `modulepreload` links come from the build's manifest
41    /// ([`RenderCtx::with_module_preloads`](crate::RenderCtx::with_module_preloads)),
42    /// head links from [`RenderCtx::with_head_link`](crate::RenderCtx::with_head_link),
43    /// and data from [`PageConfig::config_json`].
44    pub body_prefix: Option<&'a crate::RawHtml>,
45}
46
47/// `X-Content-Type-Options: nosniff` — belongs on every response, not just
48/// documents.
49pub const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
50
51/// Security headers that only make sense on an HTML **document** response.
52///
53/// Deliberately not one flat set applied as a blanket layer: a browser ignores
54/// `Cross-Origin-Opener-Policy`, `Referrer-Policy` and `Permissions-Policy` on
55/// a JSON/CSS/JS response, so a global layer would put ~150 dead bytes on
56/// every asset and every API poll. Use [`asset_security_headers`] there.
57///
58/// HSTS is deliberately absent: it is the TLS terminator's responsibility, not
59/// the application's.
60///
61/// Caveat on `Cross-Origin-Opener-Policy: same-origin` — it applies only to
62/// top-level documents in a secure context (a no-op over plain HTTP on a
63/// non-localhost host) and it severs `window.opener`, which breaks popup-based
64/// OAuth flows.
65pub 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                // `interest-cohort=()` is deliberately omitted: FLoC was
81                // withdrawn in 2022 and the token is not a registered feature,
82                // so Chrome logs an "Unrecognized feature" warning for it on
83                // every page load.
84                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
95/// Security headers for subresource responses (`/_assets/*`, `/sw.js`).
96///
97/// `Cross-Origin-Resource-Policy` belongs here rather than on the document.
98/// `same-origin` is correct while assets are served from the app's own origin;
99/// an app that fronts `/_assets` with a separate CDN origin must relax it.
100pub 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/// Validated schema-2 build manifest. Deserialize through the shared validator.
119#[derive(Debug, Clone)]
120pub struct AssetManifest {
121    /// Manifest schema, currently 2.
122    pub version: u32,
123    /// Writer's numeric base version, without package prerelease metadata.
124    pub writer: String,
125    /// Application identity; the same grammar as the worker namespace.
126    pub app: String,
127    /// Opaque build identifier. Deployment identity is owned by FORMA-186.
128    pub build: String,
129    /// Logical names mapped to emitted filenames.
130    pub aliases: HashMap<String, String>,
131    /// Metadata keyed by emitted filenames, including retained files.
132    pub assets: HashMap<String, AssetMetadata>,
133    /// Explicit rendering mode and assets for each route.
134    pub routes: HashMap<String, RouteAssets>,
135}
136
137/// Metadata for one emitted file, including its full uncompressed-byte digest.
138#[derive(Debug, Clone, Deserialize)]
139pub struct AssetMetadata {
140    /// Whether this URL may be cached immutably; never inferred from its name.
141    pub immutable: bool,
142    /// Canonical SHA-384 of uncompressed final bytes (`sha384-` and 64 base64 characters).
143    pub integrity: String,
144    /// Metadata for a retained asset; retention discovery belongs to FORMA-186.
145    #[serde(default)]
146    pub retained: bool,
147}
148
149/// The manifest's explicit route mode.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum RouteMode {
153    /// A required compiled module is rendered on the server.
154    Ssr,
155    /// An intentionally browser-rendered route, with no compiled module.
156    Browser,
157}
158
159/// Per-route asset references.
160#[derive(Debug, Clone, Deserialize)]
161pub struct RouteAssets {
162    /// Explicit browser or server rendering, independent of file availability.
163    pub mode: RouteMode,
164    /// JS modules for this route, in load order.
165    pub js: Vec<String>,
166    /// Stylesheets for this route.
167    pub css: Vec<String>,
168    /// Fonts to preload for this route.
169    pub fonts: Vec<String>,
170    /// Static module dependencies, excluding duplicates and route entrypoints at render.
171    #[serde(default)]
172    pub modulepreload: Vec<String>,
173    /// Total brotli-compressed size of the route's assets, in bytes.
174    pub total_size_br: u64,
175    /// Size above which the build warns about this route's payload.
176    pub budget_warn_threshold: u64,
177    /// Required for SSR, absent for an intentional browser route.
178    #[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        // Diagnose old schemas before their missing schema-2 fields.
186        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    /// Read and validate schema, versions, modes and references at startup.
235    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    /// Revalidate even a manifest constructed by the caller before loading modules.
240    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    /// Resolve a logical name through the manifest's explicit aliases.
333    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    /// Get assets and the declared mode for a route.
341    pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
342        self.routes.get(pattern)
343    }
344}
345
346/// Full SHA-384, in the single canonical SRI spelling emitted by the build.
347pub(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}