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(©));
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}