From 46d1f9299232b74e2a8d14f5d8e1e8a3c9bb72b9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 30 Aug 2022 13:42:52 +0200 Subject: [PATCH 1/4] Add support for JSON auto escaping --- minijinja/src/environment.rs | 35 ++++++++++++++++++++++++++++--- minijinja/src/filters.rs | 26 ++++++++++++++++------- minijinja/src/utils.rs | 4 ++++ minijinja/src/vm.rs | 2 ++ minijinja/tests/test_templates.rs | 34 ++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/minijinja/src/environment.rs b/minijinja/src/environment.rs index 8192b504..218477cc 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, } } @@ -335,6 +337,9 @@ impl<'source> Environment<'source> { /// 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`. + /// + /// When the `json` feature is enabled, then auto escaping is also enabled in + /// JSON more for files ending with `.js`, `.json`, `.yml` and `.yaml`. pub fn set_auto_escape_callback AutoEscape + 'static + Sync + Send>( &mut self, f: F, @@ -446,7 +451,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 +549,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 +577,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/utils.rs b/minijinja/src/utils.rs index 4490da76..56b3ef16 100644 --- a/minijinja/src/utils.rs +++ b/minijinja/src/utils.rs @@ -37,6 +37,10 @@ pub enum AutoEscape { None, /// Use HTML auto escaping rules Html, + /// Escape for JSON/JavaScript or YAML. + #[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 => "