From bbcf5e5f017c687518d74b041c1ee48d2c0b82f0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 30 Aug 2022 17:14:36 +0200 Subject: [PATCH] Add support for JSON/YAML/JavaScript Escaping (#82) --- examples/generate-yaml/Cargo.toml | 9 ++++++ examples/generate-yaml/README.md | 9 ++++++ examples/generate-yaml/src/main.rs | 21 ++++++++++++ examples/generate-yaml/src/template.yaml | 5 +++ minijinja/src/context.rs | 4 +-- minijinja/src/environment.rs | 41 +++++++++++++++++++++--- minijinja/src/filters.rs | 26 ++++++++++----- minijinja/src/syntax.rs | 3 ++ minijinja/src/utils.rs | 19 +++++++++-- minijinja/src/vm.rs | 2 ++ minijinja/tests/test_templates.rs | 34 ++++++++++++++++++++ 11 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 examples/generate-yaml/Cargo.toml create mode 100644 examples/generate-yaml/README.md create mode 100644 examples/generate-yaml/src/main.rs create mode 100644 examples/generate-yaml/src/template.yaml diff --git a/examples/generate-yaml/Cargo.toml b/examples/generate-yaml/Cargo.toml new file mode 100644 index 00000000..666d73cf --- /dev/null +++ b/examples/generate-yaml/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "generate-yaml" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +minijinja = { path = "../../minijinja", features = ["json"] } diff --git a/examples/generate-yaml/README.md b/examples/generate-yaml/README.md new file mode 100644 index 00000000..6ec74658 --- /dev/null +++ b/examples/generate-yaml/README.md @@ -0,0 +1,9 @@ +# generate-yaml + +This demonstrates how MiniJinja can be used to generate YAML files with automatic escaping. +It renders a YAML template and fills in some values which are automatically formatted to +be valid JSON and YAML syntax. + +```console +$ cargo run +``` diff --git a/examples/generate-yaml/src/main.rs b/examples/generate-yaml/src/main.rs new file mode 100644 index 00000000..c5d4cda5 --- /dev/null +++ b/examples/generate-yaml/src/main.rs @@ -0,0 +1,21 @@ +//! This example dumps out YAML by using auto escaping. +use std::collections::BTreeMap; +use std::env; + +use minijinja::{context, Environment}; + +fn main() { + let mut env = Environment::new(); + env.add_template("template.yml", include_str!("template.yaml")) + .unwrap(); + let tmpl = env.get_template("template.yml").unwrap(); + println!( + "{}", + tmpl.render(context! { + env => env::vars().collect::>(), + title => "Hello World!", + yaml => "[1, 2, 3]", + }) + .unwrap() + ); +} diff --git a/examples/generate-yaml/src/template.yaml b/examples/generate-yaml/src/template.yaml new file mode 100644 index 00000000..69f29d42 --- /dev/null +++ b/examples/generate-yaml/src/template.yaml @@ -0,0 +1,5 @@ +env: {{ env }} +title: {{ title }} +skip: {{ true }} +run: {{ ["bash", "./script.sh"] }} +yaml_value: {{ yaml|safe }} diff --git a/minijinja/src/context.rs b/minijinja/src/context.rs index 86050f39..002e0367 100644 --- a/minijinja/src/context.rs +++ b/minijinja/src/context.rs @@ -1,7 +1,7 @@ #[cfg(test)] use similar_asserts::assert_eq; -/// Hidden utility module for the [`context!`] macro. +/// Hidden utility module for the [`context!`](crate::context!) macro. #[doc(hidden)] pub mod __context { use crate::key::Key; @@ -44,7 +44,7 @@ pub mod __context { /// /// The return value is a [`Value`](crate::value::Value). /// -/// Note that [`context!`] can also be used recursively if you need to +/// Note that [`context!`](crate::context!) can also be used recursively if you need to /// create nested objects: /// /// ```rust diff --git a/minijinja/src/environment.rs b/minijinja/src/environment.rs index 8192b504..59fdcd06 100644 --- a/minijinja/src/environment.rs +++ b/minijinja/src/environment.rs @@ -227,6 +227,8 @@ impl<'source> fmt::Debug for Environment<'source> { fn default_auto_escape(name: &str) -> AutoEscape { match name.rsplit('.').next() { Some("html") | Some("htm") | Some("xml") => AutoEscape::Html, + #[cfg(feature = "json")] + Some("json") | Some("js") | Some("yaml") | Some("yml") => AutoEscape::Json, _ => AutoEscape::None, } } @@ -334,7 +336,14 @@ impl<'source> Environment<'source> { /// to determine the default auto escaping behavior. The function is /// invoked with the name of the template and can make an initial auto /// escaping decision based on that. The default implementation is to - /// turn on escaping for templates ending with `.html`, `.htm` and `.xml`. + /// turn on escaping depending on the file extension: + /// + /// * [`Html`](AutoEscape::Html): `.html`, `.htm`, `.xml` + #[cfg_attr( + feature = "json", + doc = r" * [`Json`](AutoEscape::Json): `.json`, `.js`, `.yml`" + )] + /// * [`None`](AutoEscape::None): _all others_ pub fn set_auto_escape_callback AutoEscape + 'static + Sync + Send>( &mut self, f: F, @@ -446,7 +455,7 @@ impl<'source> Environment<'source> { Ok(Template { env: self, compiled, - initial_auto_escape: (self.default_auto_escape)(name), + initial_auto_escape: self.get_initial_auto_escape(name), }) } @@ -544,8 +553,11 @@ impl<'source> Environment<'source> { self.tests.get(name) } - /// Finalizes a value. - pub(crate) fn finalize( + pub(crate) fn get_initial_auto_escape(&self, name: &str) -> AutoEscape { + (self.default_auto_escape)(name) + } + + pub(crate) fn escape( &self, value: &Value, autoescape: AutoEscape, @@ -569,9 +581,30 @@ impl<'source> Environment<'source> { write!(out, "{}", HtmlEscape(&value.to_string())).unwrap() } } + #[cfg(feature = "json")] + AutoEscape::Json => { + let value = serde_json::to_string(&value).map_err(|err| { + Error::new( + crate::ErrorKind::BadSerialization, + "unable to format to JSON", + ) + .with_source(err) + })?; + write!(out, "{}", value).unwrap() + } } Ok(()) } + + /// Finalizes a value. + pub(crate) fn finalize( + &self, + value: &Value, + autoescape: AutoEscape, + out: &mut String, + ) -> Result<(), Error> { + self.escape(value, autoescape, out) + } } #[test] diff --git a/minijinja/src/filters.rs b/minijinja/src/filters.rs index 960f3edc..aa7eaf50 100644 --- a/minijinja/src/filters.rs +++ b/minijinja/src/filters.rs @@ -47,9 +47,9 @@ use std::collections::BTreeMap; use crate::error::Error; -use crate::utils::HtmlEscape; use crate::value::{ArgType, FunctionArgs, RcType, Value}; use crate::vm::State; +use crate::AutoEscape; type FilterFunc = dyn Fn(&State, Value, Vec) -> Result + Sync + Send + 'static; @@ -159,15 +159,24 @@ pub fn safe(_state: &State, v: String) -> Result { /// HTML escapes a string. /// /// By default this filter is also registered under the alias `e`. -pub fn escape(_state: &State, v: Value) -> Result { - // TODO: this ideally understands which type of escaping is in use +pub fn escape(state: &State, v: Value) -> Result { if v.is_safe() { - Ok(v) - } else { - Ok(Value::from_safe_string( - HtmlEscape(&v.to_string()).to_string(), - )) + return Ok(v); } + + // this tries to use the escaping flag of the current scope, then + // of the initial state and if that is also not set it falls back + // to HTML. + let auto_escape = match state.auto_escape() { + AutoEscape::None => match state.env().get_initial_auto_escape(state.name()) { + AutoEscape::None => AutoEscape::Html, + other => other, + }, + other => other, + }; + let mut out = String::new(); + state.env().escape(&v, auto_escape, &mut out)?; + Ok(Value::from_safe_string(out)) } #[cfg(feature = "builtins")] @@ -665,6 +674,7 @@ mod builtins { Error::new(ErrorKind::ImpossibleOperation, "cannot serialize to JSON").with_source(err) }) .map(|s| { + // When this filter is used the return value is safe for both HTML and JSON let mut rv = String::with_capacity(s.len()); for c in s.chars() { match c { diff --git a/minijinja/src/syntax.rs b/minijinja/src/syntax.rs index 7e81cc20..79dff939 100644 --- a/minijinja/src/syntax.rs +++ b/minijinja/src/syntax.rs @@ -449,6 +449,9 @@ //! //! After an `endautoescape` the behavior is reverted to what it was before. //! +//! The exact auto escaping behavior is determined by the value of +//! [`AutoEscape`](crate::AutoEscape) set to the template. +//! //! ## `{% raw %}` //! //! A raw block is a special construct that lets you ignore the embedded template diff --git a/minijinja/src/utils.rs b/minijinja/src/utils.rs index 10fa68ac..d7de0902 100644 --- a/minijinja/src/utils.rs +++ b/minijinja/src/utils.rs @@ -31,12 +31,27 @@ pub fn memstr(haystack: &[u8], needle: &[u8]) -> Option { } /// Controls the autoescaping behavior. +/// +/// For more information see +/// [`set_auto_escape_callback`](crate::Environment::set_auto_escape_callback). #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[non_exhaustive] pub enum AutoEscape { - /// Do not apply auto escaping + /// Do not apply auto escaping. None, - /// Use HTML auto escaping rules + /// Use HTML auto escaping rules. + /// + /// Any value will be converted into a string and the following characters + /// will be escaped in ways compatible to XML and HTML: `<`, `>`, `&`, `"`, + /// `'`, and `/`. Html, + /// Use escaping rules suitable for JSON/JavaScript or YAML. + /// + /// Any value effectively ends up being serialized to JSON upon printing. The + /// serialized values will be compatible with JavaScript and YAML as well. + #[cfg(feature = "json")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + Json, } /// Helper to HTML escape a string. diff --git a/minijinja/src/vm.rs b/minijinja/src/vm.rs index a671d06d..1d14a4a8 100644 --- a/minijinja/src/vm.rs +++ b/minijinja/src/vm.rs @@ -918,6 +918,8 @@ impl<'env> Vm<'env> { auto_escape_stack.push(state.auto_escape); state.auto_escape = match (value.as_str(), value == Value::from(true)) { (Some("html"), _) => AutoEscape::Html, + #[cfg(feature = "json")] + (Some("json"), _) => AutoEscape::Json, (Some("none"), _) | (None, false) => AutoEscape::None, (None, true) => { if matches!(initial_auto_escape, AutoEscape::None) { diff --git a/minijinja/tests/test_templates.rs b/minijinja/tests/test_templates.rs index ef2f56af..f6582e01 100644 --- a/minijinja/tests/test_templates.rs +++ b/minijinja/tests/test_templates.rs @@ -73,3 +73,37 @@ fn test_single() { let rv = tmpl.render(context!(name => "Peter")).unwrap(); assert_eq!(rv, "Hello Peter!"); } + +#[test] +fn test_auto_escaping() { + let mut env = Environment::new(); + env.add_template("index.html", "{{ var }}").unwrap(); + #[cfg(feature = "json")] + { + env.add_template("index.js", "{{ var }}").unwrap(); + } + env.add_template("index.txt", "{{ var }}").unwrap(); + + // html + let tmpl = env.get_template("index.html").unwrap(); + let rv = tmpl.render(context!(var => "