Skip to main content

forma_server/
raw.rs

1//! Raw HTML: markup forma-server writes into the page exactly as it is given.
2//!
3//! Every other value a page carries is escaped for its context. The one place
4//! a handler's own markup reaches the page unescaped is
5//! [`PageConfig::body_prefix`](crate::PageConfig::body_prefix), and the one
6//! way to say that a string is such markup is [`RawHtml::trusted`]. The name
7//! is the control: a reviewer, or a consumer's CI (`forma-raw-lint`), finds
8//! every raw entry in an application by searching for `RawHtml::trusted`, and
9//! nothing else in this crate makes a `RawHtml`.
10//!
11//! # The 0.2.2 bridge
12//!
13//! In 0.2.x `body_prefix` is still an `Option<&str>`, so every 0.2.1 call keeps
14//! compiling. A `RawHtml` goes into it as `Some(&raw)`: it dereferences to the
15//! markup, so the field takes it as it is, and the same line compiles in 3.0.0,
16//! where the field takes only a `RawHtml`. Bind the value first
17//! (`let raw = RawHtml::trusted(..);`): written inline,
18//! `Some(&RawHtml::trusted(..))` borrows a temporary that Rust 1.85,
19//! forma-server's MSRV, drops at the end of the `let config = PageConfig {
20//! .. };` statement, so it does not compile there (E0716); newer compilers
21//! extend the temporary, which hides the problem. A plain string still works and is
22//! written exactly as before; in a debug build the first one a process renders
23//! logs one warning, code `RAW-101`, naming the route:
24//!
25//! | Code | Where | Cause |
26//! |---|---|---|
27//! | RAW-101 | `PageConfig::body_prefix` | A plain string, not a `RawHtml`, in a debug build |
28//!
29//! To tell the two apart, a debug build keeps the address and length of every
30//! live `RawHtml`'s markup, and a `body_prefix` that points at exactly one of
31//! them is that `RawHtml`. A release build keeps no record and checks nothing:
32//! it writes the field exactly as 0.2.1 did.
33
34use std::fmt;
35use std::ops::Deref;
36
37/// Markup a handler vouches for, written into the page unescaped.
38///
39/// Made only by [`RawHtml::trusted`]: there is no `From`, `Default` or
40/// `FromStr`, and its field is private, so the constructor's name is in every
41/// line that makes one.
42///
43/// ```
44/// use forma_server::RawHtml;
45///
46/// // GateWASM's tenant logo, escaped by the handler before it is vouched for.
47/// let logo = RawHtml::trusted(format!("<img src=\"{}\" alt=\"\">", "/_assets/logo.svg"));
48/// assert_eq!(&*logo, "<img src=\"/_assets/logo.svg\" alt=\"\">");
49/// ```
50///
51/// Nothing turns a string into one by conversion:
52///
53/// ```compile_fail,E0277
54/// let raw: forma_server::RawHtml = String::from("<b>x</b>").into();
55/// ```
56///
57/// ```compile_fail,E0277
58/// let raw: forma_server::RawHtml = "<b>x</b>".into();
59/// ```
60///
61/// ```compile_fail,E0277
62/// let raw: forma_server::RawHtml = "<b>x</b>".parse().unwrap();
63/// ```
64///
65/// ```compile_fail,E0599
66/// let raw = forma_server::RawHtml::default();
67/// ```
68///
69/// ```compile_fail,E0451
70/// let raw = forma_server::RawHtml { html: "<b>x</b>".into() };
71/// ```
72///
73/// Passing it to `body_prefix`, which in 0.2.x is an `Option<&str>`:
74///
75/// ```
76/// use forma_server::{render_browser_shell, AssetManifest, PageConfig, RawHtml, RenderCtx, RenderMode};
77///
78/// # fn page(manifest: &AssetManifest) {
79/// let shell = RawHtml::trusted("<main id=\"studio\"></main>");
80/// # #[allow(deprecated)]
81/// let config = PageConfig {
82///     title: "Studio",
83///     route_pattern: "/",
84///     manifest,
85///     config_script: None,
86///     config_json: None,
87///     body_class: None,
88///     personality_css: None,
89///     body_prefix: Some(&shell), // compiles in 0.2.2 and in 3.0.0
90///     render_mode: RenderMode::Phase1ClientMount,
91///     ir_module: None,
92///     slots: None,
93/// };
94/// let page = render_browser_shell(&config, &RenderCtx::new());
95/// # let _ = page;
96/// # }
97/// ```
98///
99/// Verified by: crates/forma-server/tests/raw_html.rs > "a_search_finds_one_raw_constructor"
100pub struct RawHtml {
101    html: Box<str>,
102}
103
104impl RawHtml {
105    /// Vouch for `html`: forma-server writes it into the page as it is.
106    ///
107    /// Whatever reaches the markup is trusted. Escape any value from a
108    /// request, a database or a tenant before it goes in, or render it
109    /// through a component instead, where the walker escapes it for you.
110    /// Each call belongs on the application's raw-entry allowlist
111    /// (`forma-raw-lint`) with an owner.
112    pub fn trusted(html: impl Into<String>) -> RawHtml {
113        let html = html.into().into_boxed_str();
114        provenance::remember(&html);
115        RawHtml { html }
116    }
117
118    /// The markup.
119    pub fn as_str(&self) -> &str {
120        &self.html
121    }
122}
123
124impl Deref for RawHtml {
125    type Target = str;
126
127    fn deref(&self) -> &str {
128        &self.html
129    }
130}
131
132impl Clone for RawHtml {
133    fn clone(&self) -> Self {
134        // Through the constructor, so the copy is remembered too.
135        RawHtml::trusted(&*self.html)
136    }
137}
138
139impl Drop for RawHtml {
140    fn drop(&mut self) {
141        provenance::forget(&self.html);
142    }
143}
144
145impl PartialEq for RawHtml {
146    fn eq(&self, other: &Self) -> bool {
147        self.html == other.html
148    }
149}
150
151impl Eq for RawHtml {}
152
153impl fmt::Debug for RawHtml {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.debug_tuple("RawHtml").field(&&*self.html).finish()
156    }
157}
158
159/// The runtime half of the string form's deprecation: a `body_prefix` that is
160/// not a `RawHtml` logs `RAW-101` once per process, in debug builds only, and
161/// is written as it is either way.
162///
163/// Verified by: crates/forma-server/tests/body_prefix_string_warning.rs > "a_string_body_prefix_warns_once_per_process_in_debug_builds_and_a_raw_html_never_does"
164pub(crate) fn check_body_prefix(body_prefix: &str, route: &str) {
165    #[cfg(debug_assertions)]
166    {
167        use std::sync::atomic::{AtomicBool, Ordering};
168        static WARNED: AtomicBool = AtomicBool::new(false);
169        if WARNED.load(Ordering::Relaxed)
170            || body_prefix.is_empty()
171            || provenance::is_raw(body_prefix)
172        {
173            return;
174        }
175        if !WARNED.swap(true, Ordering::Relaxed) {
176            tracing::warn!(
177                code = "RAW-101",
178                route,
179                "RAW-101 PageConfig::body_prefix was given a plain string on route {route}: \
180                 bind the markup as a RawHtml first (let markup = RawHtml::trusted(..);) and \
181                 pass body_prefix: Some(&markup), so review and CI can find it; 3.0.0 accepts \
182                 only a RawHtml. Logged once per process, in debug builds."
183            );
184        }
185    }
186    #[cfg(not(debug_assertions))]
187    let _ = (body_prefix, route);
188}
189
190/// Which strings are a live `RawHtml`'s markup, by address and length: kept
191/// in debug builds only, for [`check_body_prefix`].
192///
193/// A `RawHtml`'s markup is its own heap allocation, which does not move when
194/// the `RawHtml` does, so `&*raw` always has the address it had when it was
195/// made, and no other live string shares it. Empty markup has no allocation
196/// to tell apart and writes nothing, so it is neither remembered nor checked.
197#[cfg(debug_assertions)]
198mod provenance {
199    use std::collections::BTreeSet;
200    use std::sync::{Mutex, MutexGuard};
201
202    static LIVE: Mutex<BTreeSet<(usize, usize)>> = Mutex::new(BTreeSet::new());
203
204    fn key(s: &str) -> (usize, usize) {
205        (s.as_ptr() as usize, s.len())
206    }
207
208    fn live() -> MutexGuard<'static, BTreeSet<(usize, usize)>> {
209        // Nothing panics while holding the lock; a poisoned set is still a
210        // set of addresses.
211        LIVE.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
212    }
213
214    pub(super) fn remember(s: &str) {
215        if !s.is_empty() {
216            live().insert(key(s));
217        }
218    }
219
220    pub(super) fn forget(s: &str) {
221        if !s.is_empty() {
222            live().remove(&key(s));
223        }
224    }
225
226    pub(super) fn is_raw(s: &str) -> bool {
227        !s.is_empty() && live().contains(&key(s))
228    }
229
230    #[cfg(test)]
231    mod tests {
232        use super::is_raw;
233        use crate::RawHtml;
234
235        #[test]
236        fn a_raw_html_is_known_by_its_markup_and_a_string_with_the_same_text_is_not() {
237            let raw = RawHtml::trusted("<nav>n</nav>");
238            let same_text = String::from("<nav>n</nav>");
239            assert!(is_raw(&raw));
240            assert!(is_raw(raw.as_str()));
241            assert!(!is_raw(&same_text));
242            // A part of it is not the vouched-for value.
243            assert!(!is_raw(&raw[1..]));
244            assert!(!is_raw(&raw[..raw.len() - 1]));
245
246            // A clone is its own allocation, remembered too.
247            let copy = raw.clone();
248            assert_eq!(copy, raw);
249            assert!(is_raw(&copy));
250
251            // Moving a RawHtml keeps its markup where it was.
252            let at = raw.as_ptr();
253            let moved = Box::new(raw);
254            assert_eq!(moved.as_ptr(), at);
255            assert!(is_raw(&moved));
256        }
257
258        #[test]
259        fn a_dropped_raw_html_is_forgotten() {
260            // A length no other test uses, so no other thread's RawHtml can
261            // take this address and length between the drop and the check.
262            let raw = RawHtml::trusted("<aside>".repeat(41));
263            let (at, len) = (raw.as_ptr() as usize, raw.len());
264            assert!(super::live().contains(&(at, len)));
265            drop(raw);
266            assert!(!super::live().contains(&(at, len)));
267        }
268
269        #[test]
270        fn empty_markup_is_never_raw() {
271            let raw = RawHtml::trusted("");
272            assert!(!is_raw(&raw));
273            assert!(!is_raw(""));
274        }
275    }
276}
277
278/// Release builds keep no record.
279#[cfg(not(debug_assertions))]
280mod provenance {
281    pub(super) fn remember(_: &str) {}
282    pub(super) fn forget(_: &str) {}
283}