diff --git a/docs/Makefile b/docs/Makefile index 5c108fbc..7686dbc1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,4 +1,4 @@ -slugs := index getting-started text-escaping elements-attributes splices-toggles control-structures partials render-trait web-frameworks faq +slugs := index getting-started text-escaping elements-attributes splices-toggles control-structures partials web-frameworks faq slug_to_md = content/$(1).md slug_to_html = site/$(1).html diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index 751ec546..dc979216 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -24,19 +24,19 @@ use maud::html; fn main() { let name = "Lyra"; - let markup = html! { + let hello = html! { p { "Hi, " (name) "!" } }; - println!("{}", markup.into_string()); + println!("{}", hello.into_string()); } ``` `html!` takes a single argument: a template using Maud's custom syntax. -This call expands to an expression of type [`Markup`][Markup], +This call expands to an expression of type [`Html`][Html], which can then be converted to a `String` using `.into_string()`. -[Markup]: https://docs.rs/maud/*/maud/type.Markup.html +[Html]: https://docs.rs/maud/*/maud/struct.Html.html Run this program with `cargo run`, and you should get the following: diff --git a/docs/content/partials.md b/docs/content/partials.md index 8c402a9a..9ce7afae 100644 --- a/docs/content/partials.md +++ b/docs/content/partials.md @@ -2,16 +2,16 @@ Maud does not have a built-in concept of partials or sub-templates. Instead, -you can compose your markup with any function that returns `Markup`. +you can compose your markup with any function that returns `Html`. The following example defines a `header` and `footer` function. These functions are combined to form the final `page`. ```rust -use maud::{DOCTYPE, html, Markup}; +use maud::{DOCTYPE, html, Html}; /// A basic header with a dynamic `page_title`. -fn header(page_title: &str) -> Markup { +fn header(page_title: &str) -> Html { html! { (DOCTYPE) meta charset="utf-8"; @@ -20,7 +20,7 @@ fn header(page_title: &str) -> Markup { } /// A static footer. -fn footer() -> Markup { +fn footer() -> Html { html! { footer { a href="rss.atom" { "RSS Feed" } @@ -28,12 +28,11 @@ fn footer() -> Markup { } } -/// The final Markup, including `header` and `footer`. +/// The final page, including `header` and `footer`. /// -/// Additionally takes a `greeting_box` that's `Markup`, not `&str`. -pub fn page(title: &str, greeting_box: Markup) -> Markup { +/// Additionally takes a `greeting_box` that's `Html`, not `&str`. +pub fn page(title: &str, greeting_box: Html) -> Html { html! { - // Add the header markup to the page (header(title)) h1 { (title) } (greeting_box) @@ -42,12 +41,12 @@ pub fn page(title: &str, greeting_box: Markup) -> Markup { } ``` -Using the `page` function will return the markup for the whole page. +Using the `page` function will return the HTML for the whole page. Here's an example: ```rust -# use maud::{html, Markup}; -# fn page(title: &str, greeting_box: Markup) -> Markup { greeting_box } +# use maud::{html, Html}; +# fn page(title: &str, greeting_box: Html) -> Html { greeting_box } page("Hello!", html! { div { "Greetings, Maud." } }); diff --git a/docs/content/render-trait.md b/docs/content/render-trait.md deleted file mode 100644 index e49b73ab..00000000 --- a/docs/content/render-trait.md +++ /dev/null @@ -1,91 +0,0 @@ -# The `Render` trait - -Maud uses the [`Render`][Render] trait to convert [`(spliced)`](splices-toggles.md) values to HTML. -This is implemented for many Rust primitive types (`&str`, `i32`) by default, but you can implement it for your own types as well. - -Below are some examples of implementing `Render`. -Feel free to use these snippets in your own project! - -## Example: a shorthand for including CSS stylesheets - -When writing a web page, -it can be annoying to write `link rel="stylesheet"` over and over again. -This example provides a shorthand for linking to CSS stylesheets. - -```rust -use maud::{html, Markup, Render}; - -/// Links to a CSS stylesheet at the given path. -struct Css(&'static str); - -impl Render for Css { - fn render(&self) -> Markup { - html! { - link rel="stylesheet" type="text/css" href=(self.0); - } - } -} -``` - -## Example: a wrapper that calls `std::fmt::Debug` - -When debugging an application, -it can be useful to see its internal state. -But these internal data types often don't implement `Display`. -This wrapper lets us use the [`Debug`][Debug] trait instead. - -To avoid extra allocation, -we override the `.render_to()` method instead of `.render()`. -This doesn't do any escaping by default, -so we wrap the output in an `Escaper` as well. - -```rust -use maud::{Escaper, html, Render}; -use std::fmt; -use std::fmt::Write as _; - -/// Renders the given value using its `Debug` implementation. -struct Debug(T); - -impl Render for Debug { - fn render_to(&self, output: &mut String) { - let mut escaper = Escaper::new(output); - write!(escaper, "{:?}", self.0).unwrap(); - } -} -``` - -## Example: rendering Markdown using `pulldown-cmark` and `ammonia` - -[`pulldown-cmark`][pulldown-cmark] is a popular library -for converting Markdown to HTML. - -We also use the [`ammonia`][ammonia] library, -which sanitizes the resulting markup. - -```rust -use ammonia; -use maud::{Markup, PreEscaped, Render}; -use pulldown_cmark::{Parser, html}; - -/// Renders a block of Markdown using `pulldown-cmark`. -struct Markdown>(T); - -impl> Render for Markdown { - fn render(&self) -> Markup { - // Generate raw HTML - let mut unsafe_html = String::new(); - let parser = Parser::new(self.0.as_ref()); - html::push_html(&mut unsafe_html, parser); - // Sanitize it with ammonia - let safe_html = ammonia::clean(&unsafe_html); - PreEscaped(safe_html) - } -} -``` - -[Debug]: https://doc.rust-lang.org/std/fmt/trait.Debug.html -[Display]: https://doc.rust-lang.org/std/fmt/trait.Display.html -[Render]: https://docs.rs/maud/*/maud/trait.Render.html -[pulldown-cmark]: https://docs.rs/pulldown-cmark/0.0.8/pulldown_cmark/index.html -[ammonia]: https://github.com/notriddle/ammonia diff --git a/docs/content/splices-toggles.md b/docs/content/splices-toggles.md index a5fbabd1..3d4e5523 100644 --- a/docs/content/splices-toggles.md +++ b/docs/content/splices-toggles.md @@ -94,30 +94,44 @@ html! { ### What can be spliced? -You can splice any value that implements [`Render`][Render]. +You can splice any value that implements [`ToHtml`][ToHtml]. Most primitive types (such as `str` and `i32`) implement this trait, so they should work out of the box. To get this behavior for a custom type, -you can implement the [`Render`][Render] trait by hand. -The [`PreEscaped`][PreEscaped] wrapper type, -which outputs its argument without escaping, -works this way. -See the [traits](render-trait.md) section for details. +you can implement the [`ToHtml`][ToHtml] trait by hand. ```rust -use maud::PreEscaped; -let post = "

Pre-escaped

"; -# let _ = maud:: -html! { - h1 { "My super duper blog post" } - (PreEscaped(post)) +use maud::{Html, ToHtml, html}; + +struct Pony { + name: String, + cuteness: i32, } -# ; + +impl ToHtml for Pony { + fn to_html(&self) -> Html { + html! { + p { + "Pony " (self.name) " is " (self.cuteness) " cute!" + } + } + } +} + +let sweetie_belle = Pony { + name: "Sweetie Belle".into(), + cuteness: 99, +}; + +let example = html! { + (sweetie_belle) +}; + +assert_eq!(example.into_string(), "

Pony Sweetie Belle is 99 cute!

"); ``` -[Render]: https://docs.rs/maud/*/maud/trait.Render.html -[PreEscaped]: https://docs.rs/maud/*/maud/struct.PreEscaped.html +[ToHtml]: https://docs.rs/maud/*/maud/trait.ToHtml.html ## Toggles: `[foo]` diff --git a/docs/content/text-escaping.md b/docs/content/text-escaping.md index da673137..b5af1d96 100644 --- a/docs/content/text-escaping.md +++ b/docs/content/text-escaping.md @@ -39,24 +39,33 @@ html! { [raw strings]: https://doc.rust-lang.org/reference/tokens.html#raw-string-literals -## Escaping and `PreEscaped` +## Escaping By default, HTML special characters are escaped automatically. -Wrap the string in `(PreEscaped())` to disable this escaping. -(See the section on [splices](splices-toggles.md) to -learn more about how this works.) ```rust -use maud::PreEscaped; -# let _ = maud:: -html! { - "" // <script>... - (PreEscaped("")) // "; +let markup = html! { + (unsafe_input) +}; +assert_eq!(markup.into_string(), "<script>alert('Bwahahaha!')</script>"); ``` +[xss]: https://www.cloudflare.com/en-au/learning/security/threats/cross-site-scripting/ + ## The `DOCTYPE` constant If you want to add a `` declaration to your page, @@ -71,3 +80,53 @@ html! { } # ; ``` + +## Inline `")) + } + # ; + ``` + +[from_const_unchecked]: https://docs.rs/maud/*/maud/struct.Html.html#method.from_const_unchecked + +When Maud implements [context-aware escaping], +these workarounds will no longer be needed. + +[context-aware escaping]: https://github.com/lambda-fairy/maud/issues/181 + +## Custom escaping + +If your use case isn't covered by these examples, +check out the [advanced API]. + +[advanced API]: https://docs.rs/maud/*/maud/struct.Html.html diff --git a/docs/content/web-frameworks.md b/docs/content/web-frameworks.md index 8c52fb09..a9992cd8 100644 --- a/docs/content/web-frameworks.md +++ b/docs/content/web-frameworks.md @@ -24,11 +24,11 @@ that implements the `actix_web::Responder` trait. ```rust,no_run use actix_web::{get, App, HttpServer, Result as AwResult}; -use maud::{html, Markup}; +use maud::{html, Html}; use std::io; #[get("/")] -async fn index() -> AwResult { +async fn index() -> AwResult { Ok(html! { html { body { @@ -59,18 +59,18 @@ maud = { version = "*", features = ["rocket"] } # ... ``` -This adds a `Responder` implementation for the `Markup` type, +This adds a `Responder` implementation for the `Html` type, so you can return the result directly: ```rust,no_run #![feature(decl_macro)] -use maud::{html, Markup}; +use maud::{html, Html}; use rocket::{get, routes}; use std::borrow::Cow; #[get("/")] -fn hello<'a>(name: Cow<'a, str>) -> Markup { +fn hello<'a>(name: Cow<'a, str>) -> Html { html! { h1 { "Hello, " (name) "!" } p { "Nice to meet you!" } @@ -86,7 +86,7 @@ fn main() { Unlike with the other frameworks, Rouille doesn't need any extra features at all! -Calling `Response::html` on the rendered `Markup` will Just Work®. +Calling `Response::html` on the rendered `Html` will Just Work®. ```rust,no_run use maud::html; @@ -118,7 +118,7 @@ maud = { version = "*", features = ["tide"] } # ... ``` -This adds an implementation of `From>` +This adds an implementation of `From` for the `Response` struct. Once provided, callers may return results of `html!` directly as responses: @@ -154,14 +154,14 @@ maud = { version = "*", features = ["axum"] } # ... ``` -This adds an implementation of `IntoResponse` for `Markup`/`PreEscaped`. +This adds an implementation of `IntoResponse` for `Html`. This then allows you to use it directly as a response! ```rust,no_run -use maud::{html, Markup}; +use maud::{html, Html}; use axum::{Router, routing::get}; -async fn hello_world() -> Markup { +async fn hello_world() -> Html { html! { h1 { "Hello, World!" } } diff --git a/docs/src/bin/build_nav.rs b/docs/src/bin/build_nav.rs index 13a93d5f..9bd11fd3 100644 --- a/docs/src/bin/build_nav.rs +++ b/docs/src/bin/build_nav.rs @@ -1,7 +1,7 @@ use comrak::{self, nodes::AstNode, Arena}; use docs::{ page::{Page, COMRAK_OPTIONS}, - string_writer::StringWriter, + text_writer::TextWriter, }; use std::{env, error::Error, fs, io, path::Path, str}; @@ -51,7 +51,7 @@ fn load_page_title<'a>( let page = Page::load(arena, path)?; let title = page.title.map(|title| { let mut buffer = String::new(); - comrak::format_commonmark(title, &COMRAK_OPTIONS, &mut StringWriter(&mut buffer)).unwrap(); + comrak::format_commonmark(title, &COMRAK_OPTIONS, &mut TextWriter(&mut buffer)).unwrap(); buffer }); Ok(title) diff --git a/docs/src/lib.rs b/docs/src/lib.rs index ac61d5b3..02155c06 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -1,5 +1,5 @@ #![feature(once_cell)] pub mod page; -pub mod string_writer; +pub mod text_writer; pub mod views; diff --git a/docs/src/string_writer.rs b/docs/src/string_writer.rs deleted file mode 100644 index ea7291ab..00000000 --- a/docs/src/string_writer.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::{io, str}; - -pub struct StringWriter<'a>(pub &'a mut String); - -impl<'a> io::Write for StringWriter<'a> { - fn write(&mut self, buf: &[u8]) -> io::Result { - str::from_utf8(buf) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) - .map(|s| { - self.0.push_str(s); - buf.len() - }) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} diff --git a/docs/src/text_writer.rs b/docs/src/text_writer.rs new file mode 100644 index 00000000..8edeb62c --- /dev/null +++ b/docs/src/text_writer.rs @@ -0,0 +1,18 @@ +use std::{fmt, io, str}; + +pub struct TextWriter(pub T); + +impl io::Write for TextWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let s = + str::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + self.0 + .write_str(s) + .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/docs/src/views.rs b/docs/src/views.rs index 47bdc59a..a46ef115 100644 --- a/docs/src/views.rs +++ b/docs/src/views.rs @@ -1,17 +1,23 @@ use comrak::nodes::AstNode; -use maud::{html, Markup, PreEscaped, Render, DOCTYPE}; +use maud::{html, Html, HtmlBuilder, ToHtml, DOCTYPE}; use std::str; use crate::{ page::{Page, COMRAK_OPTIONS}, - string_writer::StringWriter, + text_writer::TextWriter, }; struct Comrak<'a>(&'a AstNode<'a>); -impl<'a> Render for Comrak<'a> { - fn render_to(&self, buffer: &mut String) { - comrak::format_html(self.0, &COMRAK_OPTIONS, &mut StringWriter(buffer)).unwrap(); +impl<'a> ToHtml for Comrak<'a> { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + // XSS-Safety: The input Markdown comes from docs, which are trusted. + comrak::format_html( + self.0, + &COMRAK_OPTIONS, + &mut TextWriter(builder.as_mut_string_unchecked()), + ) + .unwrap(); } } @@ -19,12 +25,13 @@ impl<'a> Render for Comrak<'a> { /// general but not suitable for links in the navigation bar. struct ComrakRemovePTags<'a>(&'a AstNode<'a>); -impl<'a> Render for ComrakRemovePTags<'a> { - fn render(&self) -> Markup { +impl<'a> ToHtml for ComrakRemovePTags<'a> { + fn to_html(&self) -> Html { let mut buffer = String::new(); - comrak::format_html(self.0, &COMRAK_OPTIONS, &mut StringWriter(&mut buffer)).unwrap(); + comrak::format_html(self.0, &COMRAK_OPTIONS, &mut TextWriter(&mut buffer)).unwrap(); assert!(buffer.starts_with("

") && buffer.ends_with("

\n")); - PreEscaped( + // XSS-Safety: The input Markdown comes from docs, which are trusted. + Html::from_unchecked( buffer .trim_start_matches("

") .trim_end_matches("

\n") @@ -35,9 +42,9 @@ impl<'a> Render for ComrakRemovePTags<'a> { struct ComrakText<'a>(&'a AstNode<'a>); -impl<'a> Render for ComrakText<'a> { - fn render_to(&self, buffer: &mut String) { - comrak::format_commonmark(self.0, &COMRAK_OPTIONS, &mut StringWriter(buffer)).unwrap(); +impl<'a> ToHtml for ComrakText<'a> { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + comrak::format_commonmark(self.0, &COMRAK_OPTIONS, &mut TextWriter(builder)).unwrap(); } } @@ -47,7 +54,7 @@ pub fn main<'a>( nav: &[(&str, &'a AstNode<'a>)], version: &str, hash: &str, -) -> Markup { +) -> Html { html! { (DOCTYPE) meta charset="utf-8"; diff --git a/maud/Cargo.toml b/maud/Cargo.toml index 45c9ef94..94cedb49 100644 --- a/maud/Cargo.toml +++ b/maud/Cargo.toml @@ -14,9 +14,8 @@ edition = "2021" [features] default = [] axum = ["axum-core", "http"] - -# Web framework integrations actix-web = ["actix-web-dep", "futures-util"] +sanitize = ["ammonia"] [dependencies] maud_macros = { version = "0.23.0", path = "../maud_macros" } @@ -27,6 +26,7 @@ actix-web-dep = { package = "actix-web", version = ">= 2, < 4", optional = true, tide = { version = "0.16.0", optional = true, default-features = false } axum-core = { version = "0.1", optional = true } http = { version = "0.2", optional = true } +ammonia = { version = "3.1.2", optional = true } [dev-dependencies] trybuild = { version = "1.0.33", features = ["diff"] } diff --git a/maud/benches/complicated_maud.rs b/maud/benches/complicated_maud.rs index d0cf0958..1732bdf9 100644 --- a/maud/benches/complicated_maud.rs +++ b/maud/benches/complicated_maud.rs @@ -2,7 +2,7 @@ extern crate test; -use maud::{html, Markup}; +use maud::{html, Html}; #[derive(Debug)] struct Entry { @@ -11,7 +11,7 @@ struct Entry { } mod btn { - use maud::{html, Markup, Render}; + use maud::{html, Html, ToHtml}; #[derive(Copy, Clone)] pub enum RequestMethod { @@ -41,8 +41,8 @@ mod btn { } } - impl<'a> Render for Button<'a> { - fn render(&self) -> Markup { + impl<'a> ToHtml for Button<'a> { + fn to_html(&self) -> Html { match self.req_meth { RequestMethod::Get => { html! { a.btn href=(self.path) { (self.label) } } @@ -59,7 +59,7 @@ mod btn { } } -fn layout>(title: S, inner: Markup) -> Markup { +fn layout>(title: S, inner: Html) -> Html { html! { html { head { diff --git a/maud/src/lib.rs b/maud/src/lib.rs index c2c0beaa..08a8aed6 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -18,196 +18,283 @@ pub use maud_macros::{html, html_debug}; mod escape; -/// An adapter that escapes HTML special characters. -/// -/// The following characters are escaped: -/// -/// * `&` is escaped as `&` -/// * `<` is escaped as `<` -/// * `>` is escaped as `>` -/// * `"` is escaped as `"` -/// -/// All other characters are passed through unchanged. -/// -/// **Note:** In versions prior to 0.13, the single quote (`'`) was -/// escaped as well. -/// -/// # Example -/// -/// ```rust -/// use maud::Escaper; -/// use std::fmt::Write; -/// let mut s = String::new(); -/// write!(Escaper::new(&mut s), "").unwrap(); -/// assert_eq!(s, "<script>launchMissiles()</script>"); -/// ``` -pub struct Escaper<'a>(&'a mut String); - -impl<'a> Escaper<'a> { - /// Creates an `Escaper` from a `String`. - pub fn new(buffer: &'a mut String) -> Escaper<'a> { - Escaper(buffer) - } -} - -impl<'a> fmt::Write for Escaper<'a> { - fn write_str(&mut self, s: &str) -> fmt::Result { - escape::escape_to_string(s, self.0); - Ok(()) - } -} - /// Represents a type that can be rendered as HTML. /// -/// To implement this for your own type, override either the `.render()` -/// or `.render_to()` methods; since each is defined in terms of the -/// other, you only need to implement one of them. See the example below. -/// /// # Minimal implementation /// /// An implementation of this trait must override at least one of -/// `.render()` or `.render_to()`. Since the default definitions of -/// these methods call each other, not doing this will result in -/// infinite recursion. +/// `.to_html()` or `.push_html_to()`. Since the default definitions of +/// these methods call each other, not doing this will result in infinite +/// recursion. /// /// # Example /// /// ```rust -/// use maud::{html, Markup, Render}; +/// use maud::{html, Html, ToHtml}; /// /// /// Provides a shorthand for linking to a CSS stylesheet. /// pub struct Stylesheet(&'static str); /// -/// impl Render for Stylesheet { -/// fn render(&self) -> Markup { +/// impl ToHtml for Stylesheet { +/// fn to_html(&self) -> Html { /// html! { /// link rel="stylesheet" type="text/css" href=(self.0); /// } /// } /// } /// ``` -pub trait Render { - /// Renders `self` as a block of `Markup`. - fn render(&self) -> Markup { - let mut buffer = String::new(); - self.render_to(&mut buffer); - PreEscaped(buffer) +pub trait ToHtml { + /// Creates an HTML representation of `self`. + fn to_html(&self) -> Html { + let mut builder = HtmlBuilder::new(); + self.push_html_to(&mut builder); + builder.finalize() } - /// Appends a representation of `self` to the given buffer. + /// Appends an HTML representation of `self` to the given buffer. /// - /// Its default implementation just calls `.render()`, but you may + /// Its default implementation just calls `.to_html()`, but you may /// override it with something more efficient. - /// - /// Note that no further escaping is performed on data written to - /// the buffer. If you override this method, you must make sure that - /// any data written is properly escaped, whether by hand or using - /// the [`Escaper`](struct.Escaper.html) wrapper struct. - fn render_to(&self, buffer: &mut String) { - buffer.push_str(&self.render().into_string()); + fn push_html_to(&self, builder: &mut HtmlBuilder) { + self.to_html().push_html_to(builder) } } -impl Render for str { - fn render_to(&self, w: &mut String) { - escape::escape_to_string(self, w); +impl ToHtml for str { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + // XSS-Safety: Special characters will be escaped by `escape_to_string`. + escape::escape_to_string(self, builder.as_mut_string_unchecked()); } } -impl Render for String { - fn render_to(&self, w: &mut String) { - str::render_to(self, w); +impl ToHtml for String { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + str::push_html_to(self, builder); } } -impl<'a> Render for Cow<'a, str> { - fn render_to(&self, w: &mut String) { - str::render_to(self, w); +impl<'a> ToHtml for Cow<'a, str> { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + str::push_html_to(self, builder); } } -impl<'a> Render for Arguments<'a> { - fn render_to(&self, w: &mut String) { - let _ = Escaper::new(w).write_fmt(*self); +impl<'a> ToHtml for Arguments<'a> { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + let _ = builder.write_fmt(*self); } } -impl<'a, T: Render + ?Sized> Render for &'a T { - fn render_to(&self, w: &mut String) { - T::render_to(self, w); +impl<'a, T: ToHtml + ?Sized> ToHtml for &'a T { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + T::push_html_to(self, builder); } } -impl<'a, T: Render + ?Sized> Render for &'a mut T { - fn render_to(&self, w: &mut String) { - T::render_to(self, w); +impl<'a, T: ToHtml + ?Sized> ToHtml for &'a mut T { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + T::push_html_to(self, builder); } } -impl Render for Box { - fn render_to(&self, w: &mut String) { - T::render_to(self, w); +impl ToHtml for Box { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + T::push_html_to(self, builder); } } -macro_rules! impl_render_with_display { +macro_rules! impl_to_html_with_display { ($($ty:ty)*) => { $( - impl Render for $ty { - fn render_to(&self, w: &mut String) { - format_args!("{self}").render_to(w); + impl ToHtml for $ty { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + let _ = write!(builder, "{self}"); } } )* }; } -impl_render_with_display! { +impl_to_html_with_display! { char f32 f64 } -macro_rules! impl_render_with_itoa { +macro_rules! impl_to_html_with_itoa { ($($ty:ty)*) => { $( - impl Render for $ty { - fn render_to(&self, w: &mut String) { - let _ = itoa::fmt(w, *self); + impl ToHtml for $ty { + fn push_html_to(&self, builder: &mut HtmlBuilder) { + // XSS-Safety: The characters '0' through '9', and '-', are HTML safe. + let _ = itoa::fmt(builder.as_mut_string_unchecked(), *self); } } )* }; } -impl_render_with_itoa! { +impl_to_html_with_itoa! { i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize } -/// A wrapper that renders the inner value without escaping. -#[derive(Debug, Clone, Copy)] -pub struct PreEscaped>(pub T); +/// A fragment of HTML. +/// +/// This is the type that's returned by the [`html!`] macro. +/// +/// # Security +/// +/// All instances of `Html` must be: +/// +/// 1. **Trusted.** Any embedded scripts (in a `

"; + /// + /// let clean_html = Html::sanitize(untrusted_html); + /// + /// assert_eq!(clean_html.into_string(), "

"); + /// ``` + pub fn sanitize(untrusted_html_string: &str) -> Self { + // XSS-Safety: Ammonia sanitizes the input. + Self::from_unchecked(ammonia::clean(untrusted_html_string)) } -} -/// A block of markup is a string that does not need to be escaped. -/// -/// The `html!` macro expands to an expression of this type. -pub type Markup = PreEscaped; + /// Creates an HTML fragment from a string, without escaping it. + /// + /// # Example + /// + /// ```rust + /// # fn load_header_from_config() -> String { String::new() } + /// use maud::Html; + /// + /// // XSS-Safety: The config can only be edited by an admin. + /// let header = Html::from_unchecked(load_header_from_config()); + /// ``` + /// + /// # Security + /// + /// It is strongly recommended to include a `// XSS-Safety:` comment + /// that explains why this call is safe. + /// + /// If your organization has a security team, consider asking them + /// for review. + pub fn from_unchecked(html_string: impl Into>) -> Self { + Self { + inner: html_string.into(), + } + } -impl + Into> PreEscaped { - /// Converts the inner value to a string. + /// Creates an HTML fragment from a constant string. + /// + /// This is similar to [`Html::from_unchecked`], but can be called + /// in a `const` context. + /// + /// # Example + /// + /// ```rust + /// use maud::Html; + /// + /// const ANALYTICS_SCRIPT: Html = Html::from_const_unchecked( + /// "", + /// ); + /// ``` + /// + /// # Security + /// + /// As long as the string is a compile-time constant, it is + /// guaranteed to be as *trusted* as its surrounding code. + /// + /// However, this doesn't guarantee that it's *composable*: + /// + /// ```rust + /// use maud::Html; + /// + /// // BROKEN - DO NOT USE! + /// const UNCLOSED_SCRIPT: Html = Html::from_const_unchecked("