Skip to content

Commit

Permalink
Add support for JSON/YAML/JavaScript Escaping (#82)
Browse files Browse the repository at this point in the history
  • Loading branch information
mitsuhiko committed Aug 30, 2022
1 parent 2e62775 commit bbcf5e5
Show file tree
Hide file tree
Showing 11 changed files with 157 additions and 16 deletions.
9 changes: 9 additions & 0 deletions 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"] }
9 changes: 9 additions & 0 deletions 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
```
21 changes: 21 additions & 0 deletions 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::<BTreeMap<_, _>>(),
title => "Hello World!",
yaml => "[1, 2, 3]",
})
.unwrap()
);
}
5 changes: 5 additions & 0 deletions examples/generate-yaml/src/template.yaml
@@ -0,0 +1,5 @@
env: {{ env }}
title: {{ title }}
skip: {{ true }}
run: {{ ["bash", "./script.sh"] }}
yaml_value: {{ yaml|safe }}
4 changes: 2 additions & 2 deletions 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;
Expand Down Expand Up @@ -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
Expand Down
41 changes: 37 additions & 4 deletions minijinja/src/environment.rs
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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<F: Fn(&str) -> AutoEscape + 'static + Sync + Send>(
&mut self,
f: F,
Expand Down Expand Up @@ -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),
})
}

Expand Down Expand Up @@ -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,
Expand All @@ -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]
Expand Down
26 changes: 18 additions & 8 deletions minijinja/src/filters.rs
Expand Up @@ -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<Value>) -> Result<Value, Error> + Sync + Send + 'static;

Expand Down Expand Up @@ -159,15 +159,24 @@ pub fn safe(_state: &State, v: String) -> Result<Value, Error> {
/// HTML escapes a string.
///
/// By default this filter is also registered under the alias `e`.
pub fn escape(_state: &State, v: Value) -> Result<Value, Error> {
// TODO: this ideally understands which type of escaping is in use
pub fn escape(state: &State, v: Value) -> Result<Value, Error> {
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")]
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions minijinja/src/syntax.rs
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions minijinja/src/utils.rs
Expand Up @@ -31,12 +31,27 @@ pub fn memstr(haystack: &[u8], needle: &[u8]) -> Option<usize> {
}

/// 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.
Expand Down
2 changes: 2 additions & 0 deletions minijinja/src/vm.rs
Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions minijinja/tests/test_templates.rs
Expand Up @@ -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 => "<script>")).unwrap();
insta::assert_snapshot!(rv, @"&lt;script&gt;");

// JSON
#[cfg(feature = "json")]
{
use minijinja::value::Value;
let tmpl = env.get_template("index.js").unwrap();
let rv = tmpl.render(context!(var => "foo\"bar'baz")).unwrap();
insta::assert_snapshot!(rv, @r###""foo\"bar'baz""###);
let rv = tmpl
.render(context!(var => [Value::from(true), Value::from("<foo>"), Value::from(())]))
.unwrap();
insta::assert_snapshot!(rv, @r###"[true,"<foo>",null]"###);
}

// Text
let tmpl = env.get_template("index.txt").unwrap();
let rv = tmpl.render(context!(var => "foo\"bar'baz")).unwrap();
insta::assert_snapshot!(rv, @r###"foo"bar'baz"###);
}

0 comments on commit bbcf5e5

Please sign in to comment.