Skip to main content

forma_server/
response.rs

1//! The page response: HTML that never leaves without its policy.
2
3use crate::csp::{self, CspNonce, PagePolicy, PolicyError, PolicyExtensions};
4use axum::http::{header, HeaderName, HeaderValue};
5use axum::response::{IntoResponse, Response};
6use std::time::Duration;
7
8/// A rendered page, ready to send.
9///
10/// As axum's [`IntoResponse`] it always writes, together with the HTML:
11///
12/// - `Cache-Control: no-store`, because the HTML embeds a nonce minted for
13///   this one response; a cached copy would hand that nonce to every later
14///   reader, and another user's page with it;
15/// - `Content-Security-Policy`, the strict policy for that nonce
16///   ([`csp::build_csp_header`]) with only the handler's typed
17///   [`PolicyExtensions`] added;
18/// - `Content-Security-Policy-Report-Only`: Trusted Types,
19///   `require-trusted-types-for 'script'; trusted-types forma forma-runtime`,
20///   reported, not enforced, in 0.2.x, when the
21///   extensions name a reporting endpoint ([`PolicyExtensions::report_to`],
22///   [`PolicyExtensions::report_uri`]), with the reporting directives in both
23///   policies (and, for `report_to`, a `Reporting-Endpoints` header), or, with
24///   no endpoint, in a development build only (forma-server's own
25///   `debug_assertions`: a release profile or package override with debug
26///   assertions on counts as one), so a check that Trusted Types raise no
27///   violation must name an endpoint or run a debug build;
28/// - `Cross-Origin-Opener-Policy: same-origin` (or `same-origin-allow-popups`
29///   when the extensions ask for it), `X-Content-Type-Options`,
30///   `Referrer-Policy` and `Permissions-Policy`;
31/// - `Content-Type: text/html; charset=utf-8`.
32///
33/// A handler cannot take the HTML out and send it some other way except
34/// through [`PageResponse::into_parts_unchecked`], the one named exit, which
35/// a consumer's CI can search for:
36///
37/// ```compile_fail,E0616
38/// # fn page() -> forma_server::PageResponse { unimplemented!() }
39/// let html = page().html; // private: the HTML leaves only with its headers
40/// ```
41///
42/// ```no_run
43/// # fn page() -> forma_server::PageResponse { unimplemented!() }
44/// let parts = page().into_parts_unchecked(); // the named, searchable exit
45/// # let _ = parts.html;
46/// ```
47///
48/// Verified by: crates/forma-server/tests/page_response.rs > "page_response_writes_no_store_the_policy_and_the_document_headers"
49#[derive(Debug)]
50pub struct PageResponse {
51    html: String,
52    nonce: CspNonce,
53    policy: PagePolicy,
54    counts: RenderCounts,
55}
56
57/// What one render did, for the application's own logs and metrics.
58///
59/// On a [`crate::RenderError`] for a budget or a fault, the same counts say
60/// how far the walk got. For a page served without a walk (a browser
61/// shell, or the deprecated `render_page`'s fallback) every count is zero.
62/// `nodes` and `list_iterations` are counted by [`crate::try_render`]; a page
63/// from the deprecated `render_page` reports them as zero.
64///
65/// The walk time is the number to watch for moving a page off an async
66/// worker: see [`crate::try_render`]'s `spawn_blocking` recipe.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct RenderCounts {
70    /// Elements and text nodes the walk wrote (hydration markers and
71    /// comments are not nodes).
72    pub nodes: usize,
73    /// Bytes of HTML the walk wrote into the page's root, the quantity the
74    /// walker's output budget (`WalkLimits::max_output_bytes`) bounds; the
75    /// document around it is not counted.
76    pub bytes: usize,
77    /// List rows the walk wrote, across every nesting depth, the quantity
78    /// `WalkLimits::max_list_iterations` bounds.
79    pub list_iterations: usize,
80    /// Time spent in the walk.
81    pub walk_time: Duration,
82}
83
84impl RenderCounts {
85    /// The walker's counts, with the time the walk took.
86    pub(crate) fn from_walk(counts: forma_ir::WalkCounts, walk_time: Duration) -> Self {
87        RenderCounts {
88            nodes: counts.nodes,
89            bytes: counts.bytes,
90            list_iterations: counts.list_iterations,
91            walk_time,
92        }
93    }
94}
95
96/// A page's raw parts, from [`PageResponse::into_parts_unchecked`].
97///
98/// Whoever sends these owns what [`PageResponse`] guaranteed: every header
99/// in `headers` goes out with `html`, and `html` is never cached or sent to a
100/// second request.
101#[derive(Debug)]
102#[non_exhaustive]
103pub struct PageParts {
104    /// The complete HTML document.
105    pub html: String,
106    /// The nonce `html` carries and the policy in `headers` blesses.
107    pub nonce: CspNonce,
108    /// Every header the response would have written, in its order.
109    pub headers: Vec<(HeaderName, HeaderValue)>,
110    /// What the render did.
111    pub counts: RenderCounts,
112}
113
114impl PageResponse {
115    /// Wrap a rendered document: the policy is built here, from the nonce
116    /// the HTML embeds, so the two cannot disagree.
117    pub(crate) fn new(
118        html: String,
119        nonce: CspNonce,
120        extensions: &PolicyExtensions,
121        counts: RenderCounts,
122    ) -> Result<Self, PolicyError> {
123        let policy = csp::page_policy(&nonce, extensions)?;
124        if let Some(reason) = policy.relaxed() {
125            csp::log_relaxation(None, reason);
126        }
127        Ok(Self::with_policy(html, nonce, policy, counts))
128    }
129
130    /// Wrap a rendered document whose policy was built, from the same nonce,
131    /// before the walk (so an invalid extension costs no walk).
132    pub(crate) fn with_policy(
133        html: String,
134        nonce: CspNonce,
135        policy: PagePolicy,
136        counts: RenderCounts,
137    ) -> Self {
138        PageResponse {
139            html,
140            nonce,
141            policy,
142            counts,
143        }
144    }
145
146    /// What the render did: nodes, bytes and list rows walked, and time spent.
147    pub fn counts(&self) -> RenderCounts {
148        self.counts
149    }
150
151    /// The HTML, its nonce and every header, apart.
152    ///
153    /// The one way to reach the HTML without sending it as this response. It
154    /// exists for the rare handler that must assemble its own response (a
155    /// streaming body, a framework other than axum); each call site belongs
156    /// on the application's raw-entry allowlist (`forma-raw-lint`) with an
157    /// owner.
158    pub fn into_parts_unchecked(self) -> PageParts {
159        let headers = self.headers();
160        PageParts {
161            html: self.html,
162            nonce: self.nonce,
163            headers,
164            counts: self.counts,
165        }
166    }
167
168    /// The headers this response writes, in order.
169    fn headers(&self) -> Vec<(HeaderName, HeaderValue)> {
170        let mut headers = Vec::with_capacity(9);
171        headers.push((
172            header::CONTENT_TYPE,
173            HeaderValue::from_static("text/html; charset=utf-8"),
174        ));
175        headers.push((header::CACHE_CONTROL, HeaderValue::from_static("no-store")));
176        headers.push((header::CONTENT_SECURITY_POLICY, self.policy.csp().clone()));
177        if let Some(report_only) = self.policy.report_only() {
178            headers.push((
179                header::CONTENT_SECURITY_POLICY_REPORT_ONLY,
180                report_only.clone(),
181            ));
182        }
183        if let Some(endpoints) = self.policy.reporting_endpoints() {
184            headers.push((
185                HeaderName::from_static("reporting-endpoints"),
186                endpoints.clone(),
187            ));
188        }
189        for (name, value) in crate::types::document_security_headers() {
190            // The opener policy is the page's own: the extensions may ask for
191            // `same-origin-allow-popups`, and a second COOP header would leave
192            // the browser to pick one.
193            if name.as_str() != "cross-origin-opener-policy" {
194                headers.push((name.clone(), value.clone()));
195            }
196        }
197        headers.push((
198            HeaderName::from_static("cross-origin-opener-policy"),
199            self.policy.opener().clone(),
200        ));
201        headers
202    }
203}
204
205impl IntoResponse for PageResponse {
206    fn into_response(self) -> Response {
207        let headers = self.headers();
208        let mut response = Response::new(axum::body::Body::from(self.html));
209        let map = response.headers_mut();
210        for (name, value) in headers {
211            map.append(name, value);
212        }
213        response
214    }
215}