diff options
-rw-r--r-- | askama_escape/Cargo.toml | 3 | ||||
-rw-r--r-- | askama_escape/src/lib.rs | 56 | ||||
-rw-r--r-- | askama_shared/Cargo.toml | 2 | ||||
-rw-r--r-- | askama_shared/src/filters/json.rs | 39 | ||||
-rw-r--r-- | askama_shared/src/generator.rs | 2 | ||||
-rw-r--r-- | book/src/filters.md | 72 | ||||
-rw-r--r-- | testing/templates/json.html | 2 | ||||
-rw-r--r-- | testing/tests/filters.rs | 60 | ||||
-rw-r--r-- | testing/tests/whitespace.rs | 4 |
9 files changed, 191 insertions, 49 deletions
diff --git a/askama_escape/Cargo.toml b/askama_escape/Cargo.toml index ed7148c..643f81a 100644 --- a/askama_escape/Cargo.toml +++ b/askama_escape/Cargo.toml @@ -17,6 +17,9 @@ maintenance = { status = "actively-developed" } [dev-dependencies] criterion = "0.3" +[features] +json = [] + [[bench]] name = "all" harness = false diff --git a/askama_escape/src/lib.rs b/askama_escape/src/lib.rs index ad08e55..1788843 100644 --- a/askama_escape/src/lib.rs +++ b/askama_escape/src/lib.rs @@ -1,10 +1,7 @@ -#![no_std] +#![cfg_attr(not(any(feature = "json", test)), no_std)] #![deny(elided_lifetimes_in_paths)] #![deny(unreachable_pub)] -#[cfg(test)] -extern crate std; - use core::fmt::{self, Display, Formatter, Write}; use core::str; @@ -175,6 +172,57 @@ pub trait Escaper { const FLAG: u8 = b'>' - b'"'; +/// Escape chevrons, ampersand and apostrophes for use in JSON +#[cfg(feature = "json")] +#[derive(Debug, Clone, Default)] +pub struct JsonEscapeBuffer(Vec<u8>); + +#[cfg(feature = "json")] +impl JsonEscapeBuffer { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn finish(self) -> String { + unsafe { String::from_utf8_unchecked(self.0) } + } +} + +#[cfg(feature = "json")] +impl std::io::Write for JsonEscapeBuffer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { + macro_rules! push_esc_sequence { + ($start:ident, $i:ident, $self:ident, $bytes:ident, $quote:expr) => {{ + if $start < $i { + $self.0.extend_from_slice(&$bytes[$start..$i]); + } + $self.0.extend_from_slice($quote); + $start = $i + 1; + }}; + } + + self.0.reserve(bytes.len()); + let mut start = 0; + for (i, b) in bytes.iter().enumerate() { + match *b { + b'&' => push_esc_sequence!(start, i, self, bytes, br#"\u0026"#), + b'\'' => push_esc_sequence!(start, i, self, bytes, br#"\u0027"#), + b'<' => push_esc_sequence!(start, i, self, bytes, br#"\u003c"#), + b'>' => push_esc_sequence!(start, i, self, bytes, br#"\u003e"#), + _ => (), + } + } + if start < bytes.len() { + self.0.extend_from_slice(&bytes[start..]); + } + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/askama_shared/Cargo.toml b/askama_shared/Cargo.toml index e19ad35..ea9e968 100644 --- a/askama_shared/Cargo.toml +++ b/askama_shared/Cargo.toml @@ -12,7 +12,7 @@ edition = "2018" [features] default = ["config", "humansize", "num-traits", "percent-encoding"] config = ["serde", "toml"] -json = ["serde", "serde_json"] +json = ["serde", "serde_json", "askama_escape/json"] markdown = ["comrak"] yaml = ["serde", "serde_yaml"] diff --git a/askama_shared/src/filters/json.rs b/askama_shared/src/filters/json.rs index c0df707..e94e50c 100644 --- a/askama_shared/src/filters/json.rs +++ b/askama_shared/src/filters/json.rs @@ -1,33 +1,40 @@ use crate::error::{Error, Result}; -use askama_escape::{Escaper, MarkupDisplay}; +use askama_escape::JsonEscapeBuffer; use serde::Serialize; +use serde_json::to_writer_pretty; -/// Serialize to JSON (requires `serde_json` feature) +/// Serialize to JSON (requires `json` feature) /// -/// ## Errors +/// The generated string does not contain ampersands `&`, chevrons `< >`, or apostrophes `'`. +/// To use it in a `<script>` you can combine it with the safe filter: /// -/// This will panic if `S`'s implementation of `Serialize` decides to fail, -/// or if `T` contains a map with non-string keys. -pub fn json<E: Escaper, S: Serialize>(e: E, s: S) -> Result<MarkupDisplay<E, String>> { - match serde_json::to_string_pretty(&s) { - Ok(s) => Ok(MarkupDisplay::new_safe(s, e)), - Err(e) => Err(Error::from(e)), - } +/// ``` html +/// <script> +/// var data = {{data|json|safe}}; +/// </script> +/// ``` +/// +/// To use it in HTML attributes, you can either use it in quotation marks `"{{data|json}}"` as is, +/// or in apostrophes with the (optional) safe filter `'{{data|json|safe}}'`. +/// In HTML texts the output of e.g. `<pre>{{data|json|safe}}</pre>` is safe, too. +pub fn json<S: Serialize>(s: S) -> Result<String> { + let mut writer = JsonEscapeBuffer::new(); + to_writer_pretty(&mut writer, &s).map_err(Error::from)?; + Ok(writer.finish()) } #[cfg(test)] mod tests { use super::*; - use askama_escape::Html; #[test] fn test_json() { - assert_eq!(json(Html, true).unwrap().to_string(), "true"); - assert_eq!(json(Html, "foo").unwrap().to_string(), r#""foo""#); - assert_eq!(json(Html, &true).unwrap().to_string(), "true"); - assert_eq!(json(Html, &"foo").unwrap().to_string(), r#""foo""#); + assert_eq!(json(true).unwrap(), "true"); + assert_eq!(json("foo").unwrap(), r#""foo""#); + assert_eq!(json(&true).unwrap(), "true"); + assert_eq!(json(&"foo").unwrap(), r#""foo""#); assert_eq!( - json(Html, &vec!["foo", "bar"]).unwrap().to_string(), + json(&vec!["foo", "bar"]).unwrap(), r#"[ "foo", "bar" diff --git a/askama_shared/src/generator.rs b/askama_shared/src/generator.rs index 6024436..ea22a83 100644 --- a/askama_shared/src/generator.rs +++ b/askama_shared/src/generator.rs @@ -1171,7 +1171,7 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { return Err("the `yaml` filter requires the `serde-yaml` feature to be enabled".into()); } - const FILTERS: [&str; 3] = ["safe", "json", "yaml"]; + const FILTERS: [&str; 2] = ["safe", "yaml"]; if FILTERS.contains(&name) { buf.write(&format!( "::askama::filters::{}({}, ", diff --git a/book/src/filters.md b/book/src/filters.md index d00d778..c24a94c 100644 --- a/book/src/filters.md +++ b/book/src/filters.md @@ -287,6 +287,53 @@ Output: 5 ``` +## Optional / feature gated filters + +The following filters can be enabled by requesting the respective feature in the Cargo.toml +[dependencies section](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html), e.g. + +``` +[dependencies] +askama = { version = "0.11.0", features = "serde-json" } +``` + +### `json` | `tojson` + +Enabling the `serde-json` feature will enable the use of the `json` filter. +This will output formatted JSON for any value that implements the required +[`Serialize`](https://docs.rs/serde/1.*/serde/trait.Serialize.html) trait. +The generated string does not contain ampersands `&`, chevrons `< >`, or apostrophes `'`. + +To use it in a `<script>` you can combine it with the safe filter. +In HTML attributes, you can either use it in quotation marks `"{{data|json}}"` as is, +or in apostrophes with the (optional) safe filter `'{{data|json|safe}}'`. +In HTML texts the output of e.g. `<pre>{{data|json|safe}}</pre>` is safe, too. + +``` +Good: <li data-extra="{{data|json}}">…</li> +Good: <li data-extra='{{data|json|safe}}'>…</li> +Good: <pre>{{data|json|safe}}</pre> +Good: <script>var data = {{data|json|safe}};</script> + +Bad: <li data-extra="{{data|json|safe}}">…</li> +Bad: <script>var data = {{data|json}};</script> +Bad: <script>var data = "{{data|json|safe}}";</script> + +Ugly: <script>var data = "{{data|json}}";</script> +Ugly: <script>var data = '{{data|json|safe}}';</script> +``` + +### `yaml` + +Enabling the `serde-yaml` feature will enable the use of the `yaml` filter. +This will output formatted YAML for any value that implements the required +[`Serialize`](https://docs.rs/serde/1.*/serde/trait.Serialize.html) trait. + +```jinja +{{ foo|yaml }} +``` + + ## Custom Filters To define your own filters, simply have a module named filters in scope of the context deriving a `Template` impl. @@ -311,28 +358,3 @@ fn main() { assert_eq!(t.render().unwrap(), "faa"); } ``` - -## The `json` filter - -Enabling the `serde-json` feature will enable the use of the `json` filter. -This will output formatted JSON for any value that implements the required -`Serialize` trait. - -```jinja -{ - "foo": "{{ foo }}", - "bar": {{ bar|json }} -} -``` - -For compatibility with Jinja, `tojson` can be used in place of `json`. - -## The `yaml` filter - -Enabling the `serde-yaml` feature will enable the use of the `yaml` filter. -This will output formatted JSON for any value that implements the required -`Serialize` trait. - -``` -{{ foo|yaml }} -``` diff --git a/testing/templates/json.html b/testing/templates/json.html index 250b7be..a1855ca 100644 --- a/testing/templates/json.html +++ b/testing/templates/json.html @@ -1,4 +1,4 @@ { "foo": "{{ foo }}", - "bar": {{ bar|json }} + "bar": {{ bar|json|safe }} } diff --git a/testing/tests/filters.rs b/testing/tests/filters.rs index be3e0ab..7973f45 100644 --- a/testing/tests/filters.rs +++ b/testing/tests/filters.rs @@ -250,3 +250,63 @@ fn test_filter_truncate() { }; assert_eq!(t.render().unwrap(), "alpha baralpha..."); } + +#[cfg(feature = "serde-json")] +#[derive(Template)] +#[template(source = r#"<li data-name="{{name|json}}"></li>"#, ext = "html")] +struct JsonAttributeTemplate<'a> { + name: &'a str, +} + +#[cfg(feature = "serde-json")] +#[test] +fn test_json_attribute() { + let t = JsonAttributeTemplate { + name: r#""><button>Hacked!</button>"#, + }; + assert_eq!( + t.render().unwrap(), + r#"<li data-name=""\"\u003e\u003cbutton\u003eHacked!\u003c/button\u003e""></li>"# + ); +} + +#[cfg(feature = "serde-json")] +#[derive(Template)] +#[template(source = r#"<li data-name='{{name|json|safe}}'></li>"#, ext = "html")] +struct JsonAttribute2Template<'a> { + name: &'a str, +} + +#[cfg(feature = "serde-json")] +#[test] +fn test_json_attribute2() { + let t = JsonAttribute2Template { + name: r#"'><button>Hacked!</button>"#, + }; + assert_eq!( + t.render().unwrap(), + r#"<li data-name='"\u0027\u003e\u003cbutton\u003eHacked!\u003c/button\u003e"'></li>"# + ); +} + +#[cfg(feature = "serde-json")] +#[derive(Template)] +#[template( + source = r#"<script>var user = {{name|json|safe}}</script>"#, + ext = "html" +)] +struct JsonScriptTemplate<'a> { + name: &'a str, +} + +#[cfg(feature = "serde-json")] +#[test] +fn test_json_script() { + let t = JsonScriptTemplate { + name: r#"</script><button>Hacked!</button>"#, + }; + assert_eq!( + t.render().unwrap(), + r#"<script>var user = "\u003c/script\u003e\u003cbutton\u003eHacked!\u003c/button\u003e"</script>"# + ); +} diff --git a/testing/tests/whitespace.rs b/testing/tests/whitespace.rs index ca72b23..cbcddd7 100644 --- a/testing/tests/whitespace.rs +++ b/testing/tests/whitespace.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "serde-json")] + use askama::Template; #[derive(askama::Template, Default)] @@ -37,5 +39,5 @@ fn test_extra_whitespace() { let mut template = AllowWhitespaces::default(); template.nested_1.nested_2.array = &["a0", "a1", "a2", "a3"]; template.nested_1.nested_2.hash.insert("key", "value"); - assert_eq!(template.render().unwrap(), "\n0\n0\n0\n0\n\n\n\n0\n0\n0\n0\n0\n\na0\na1\nvalue\n\n\n\n\n\n[\n \"a0\",\n \"a1\",\n \"a2\",\n \"a3\"\n]\n[\n \"a0\",\n \"a1\",\n \"a2\",\n \"a3\"\n][\n \"a0\",\n \"a1\",\n \"a2\",\n \"a3\"\n]\n[\n \"a1\"\n][\n \"a1\"\n]\n[\n \"a1\",\n \"a2\"\n][\n \"a1\",\n \"a2\"\n]\n[\n \"a1\"\n][\n \"a1\"\n]1-1-1\n3333 3\n2222 2\n0000 0\n3333 3\n\ntruefalse\nfalsefalsefalse\n\n\n\n\n\n\n\n\n\n\n\n\n\n"); + assert_eq!(template.render().unwrap(), "\n0\n0\n0\n0\n\n\n\n0\n0\n0\n0\n0\n\na0\na1\nvalue\n\n\n\n\n\n[\n "a0",\n "a1",\n "a2",\n "a3"\n]\n[\n "a0",\n "a1",\n "a2",\n "a3"\n][\n "a0",\n "a1",\n "a2",\n "a3"\n]\n[\n "a1"\n][\n "a1"\n]\n[\n "a1",\n "a2"\n][\n "a1",\n "a2"\n]\n[\n "a1"\n][\n "a1"\n]1-1-1\n3333 3\n2222 2\n0000 0\n3333 3\n\ntruefalse\nfalsefalsefalse\n\n\n\n\n\n\n\n\n\n\n\n\n\n"); } |