Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for JSON/YAML/JavaScript Escaping #82

Merged
merged 4 commits into from Aug 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"###);
}