#![cfg(feature = "html")]
use gotmpl::html::{CSS, HTML, HTMLAttr, JS, JSStr, Srcset, Template, URL};
use gotmpl::{Value, tmap};
#[cfg(all(feature = "go-crosscheck", feature = "std"))]
mod go_crosscheck {
use gotmpl::{SafeKind, Value};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::LazyLock;
static GO_BINARY: LazyLock<PathBuf> = LazyLock::new(|| {
let src =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/testdata/go_html_crosscheck.go");
let bin = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("go-html-crosscheck");
let output = Command::new("go")
.args(["build", "-o", bin.to_str().unwrap(), src.to_str().unwrap()])
.output()
.expect("failed to run `go build` — is Go installed?");
assert!(
output.status.success(),
"go build failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
bin
});
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c < '\x20' => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
fn safe_kind_tag(kind: SafeKind) -> &'static str {
match kind {
SafeKind::Html => "html",
SafeKind::HtmlAttr => "htmlattr",
SafeKind::Js => "js",
SafeKind::JsStr => "jsstr",
SafeKind::Css => "css",
SafeKind::Url => "url",
SafeKind::Srcset => "srcset",
}
}
fn value_to_json(v: &Value) -> Result<String, String> {
Ok(match v {
Value::Nil => r#"{"type":"nil"}"#.to_string(),
Value::Bool(b) => format!(r#"{{"type":"bool","value":{b}}}"#),
Value::Int(n) => format!(r#"{{"type":"int","value":{n}}}"#),
Value::Uint(n) => format!(r#"{{"type":"uint","value":{n}}}"#),
Value::Float(f) => {
if f.is_infinite() || f.is_nan() {
return Err("Value::Float is NaN or infinite".into());
}
let s = if f.fract() == 0.0 {
format!("{f:.1}")
} else {
format!("{f}")
};
format!(r#"{{"type":"float","value":{s}}}"#)
}
Value::String(s) => format!(r#"{{"type":"string","value":"{}"}}"#, json_escape(s)),
Value::Safe { kind, s } => format!(
r#"{{"type":"safe","kind":"{}","value":"{}"}}"#,
safe_kind_tag(*kind),
json_escape(s)
),
Value::List(items) => {
let encoded: Result<Vec<String>, _> = items.iter().map(value_to_json).collect();
format!(r#"{{"type":"list","items":[{}]}}"#, encoded?.join(","))
}
Value::Map(m) => {
let mut entries = Vec::new();
for (k, v) in m.as_ref() {
entries.push(format!(r#""{}":{}"#, json_escape(k), value_to_json(v)?));
}
format!(r#"{{"type":"map","map":{{{}}}}}"#, entries.join(","))
}
Value::Function(_) => return Err("Value::Function cannot be serialized".into()),
})
}
fn payload(template_str: &str, data: &Value) -> String {
let data_json = value_to_json(data)
.unwrap_or_else(|reason| panic!("cross-check refused for {template_str:?}: {reason}"));
format!(
r#"{{"template":"{}","data":{}}}"#,
json_escape(template_str),
data_json
)
}
fn run_go(template_str: &str, data: &Value) -> std::process::Output {
let mut child = Command::new(GO_BINARY.as_path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn go-html-crosscheck binary");
child
.stdin
.take()
.unwrap()
.write_all(payload(template_str, data).as_bytes())
.expect("failed to write to go stdin");
child.wait_with_output().expect("go-html-crosscheck failed")
}
pub fn check(template_str: &str, data: &Value, rust_result: &str) {
let output = run_go(template_str, data);
assert!(
output.status.success(),
"Go html crosscheck failed for {:?}:\n{}",
template_str,
String::from_utf8_lossy(&output.stderr)
);
let go_result = String::from_utf8(output.stdout).expect("non-UTF-8 Go output");
assert_eq!(
rust_result, &go_result,
"Rust/Go html mismatch for template: {:?}\n Rust: {:?}\n Go: {:?}",
template_str, rust_result, go_result
);
}
pub fn check_fails(template_str: &str, data: &Value) {
let output = run_go(template_str, data);
assert!(
!output.status.success(),
"Go html crosscheck: {:?} was expected to fail but Go produced {:?}",
template_str,
String::from_utf8_lossy(&output.stdout)
);
}
}
fn run(input: &str, data: &Value) -> Result<String, String> {
Template::new("test")
.parse(input)
.map_err(|e| e.to_string())?
.execute_to_string(data)
.map_err(|e| e.to_string())
}
fn ok(input: &str, data: &Value, expected: &str) {
match run(input, data) {
Ok(result) => {
assert_eq!(result, expected, "template: {input}");
#[cfg(all(feature = "go-crosscheck", feature = "std"))]
go_crosscheck::check(input, data, &result);
}
Err(e) => panic!("template {input:?} failed: {e}"),
}
}
fn fail(input: &str, data: &Value) {
if let Ok(result) = run(input, data) {
panic!("template {input:?} should have failed but got {result:?}");
}
#[cfg(all(feature = "go-crosscheck", feature = "std"))]
go_crosscheck::check_fails(input, data);
}
fn s(v: &str) -> Value {
Value::String(v.into())
}
#[test]
fn html_text() {
ok(
"{{.}}",
&s("<b> & \"quote\" 'apos'"),
"<b> & "quote" 'apos'",
);
}
#[test]
fn quoted_attr() {
ok(
"<a title=\"{{.}}\">",
&s("i <3 you & \"x\""),
"<a title=\"i <3 you & "x"\">",
);
}
#[test]
fn unquoted_attr() {
ok("<a title={{.}}>", &s("a b"), "<a title=a b>");
}
#[test]
fn rcdata_textarea() {
ok(
"<textarea>{{.}}</textarea>",
&s("</textarea><b>"),
"<textarea></textarea><b></textarea>",
);
}
#[test]
fn html_comment_elided() {
ok(
"<b>Hi <!-- c -->{{.}}</b>",
&s("<x>"),
"<b>Hi <x></b>",
);
}
#[test]
fn url_unsafe_scheme_filtered() {
ok(
"<a href=\"{{.}}\">",
&s("javascript:alert(1)"),
"<a href=\"#ZgotmplZ\">",
);
}
#[test]
fn url_query_escaped() {
ok(
"<a href=\"/search?q={{.}}\">",
&s("1 & 2"),
"<a href=\"/search?q=1%20%26%202\">",
);
}
#[test]
fn js_value_string() {
ok(
"<script>var s = {{.}}</script>",
&s("</script>"),
"<script>var s = \"\\u003c/script\\u003e\"</script>",
);
}
#[test]
fn js_value_int_padded() {
ok(
"<script>var n = {{.}}</script>",
&Value::Int(42),
"<script>var n = 42 </script>",
);
}
#[test]
fn js_value_map_json() {
let data = tmap! { "k" => "</script>" };
ok(
"<script>var o = {{.}}</script>",
&data,
"<script>var o = {\"k\":\"\\u003c/script\\u003e\"}</script>",
);
}
#[test]
fn css_value_ok() {
ok(
"<style>p{color:{{.}}}</style>",
&s("red"),
"<style>p{color:red}</style>",
);
}
#[test]
fn css_value_dangerous_defanged() {
ok(
"<style>p{color:{{.}}}</style>",
&s("expression(alert(1))"),
"<style>p{color:ZgotmplZ}</style>",
);
}
#[test]
fn range_escapes_each() {
let data = Value::List(vec![s("<a>"), s("<b>")].into());
ok("{{range .}}{{.}}{{end}}", &data, "<a><b>");
}
#[test]
fn if_in_attr() {
ok(
"<a title=\"{{if .}}x{{else}}y{{end}}\">",
&Value::Bool(true),
"<a title=\"x\">",
);
}
#[test]
fn template_invocation_escapes() {
ok(
r#"{{define "x"}}<b>{{.}}</b>{{end}}{{template "x" .}}"#,
&s("<i>"),
"<b><i></b>",
);
}
#[test]
fn safe_html_passthrough_in_text() {
ok("{{.}}", &HTML::from("<b>ok</b>").into(), "<b>ok</b>");
}
#[test]
fn safe_html_stripped_in_attr() {
ok(
"<a title=\"{{.}}\">",
&HTML::from("<b>x</b>").into(),
"<a title=\"x\">",
);
}
#[test]
fn safe_url_passthrough_but_normalized() {
ok(
"<a href=\"{{.}}\">",
&URL::from("javascript:ok()").into(),
"<a href=\"javascript:ok%28%29\">",
);
}
#[test]
fn safe_html_does_not_bypass_url_filter() {
ok(
"<a href=\"{{.}}\">",
&HTML::from("javascript:alert(1)").into(),
"<a href=\"#ZgotmplZ\">",
);
}
#[test]
fn safe_url_does_not_bypass_text_escaping() {
ok("{{.}}", &URL::from("a<b>c").into(), "a<b>c");
}
#[test]
fn safe_js_does_not_bypass_text_escaping() {
ok("{{.}}", &JS::from("<b>&").into(), "<b>&");
}
#[test]
fn safe_css_does_not_bypass_text_escaping() {
ok("{{.}}", &CSS::from("a<b").into(), "a<b");
}
#[test]
fn safe_html_does_not_bypass_js_context() {
ok(
"<script>var x = {{.}}</script>",
&HTML::from("<b>x</b>").into(),
r#"<script>var x = "\u003cb\u003ex\u003c/b\u003e"</script>"#,
);
}
#[test]
fn safe_js_passthrough_in_script() {
ok(
"<script>{{.}}</script>",
&JS::from("x = y < 1").into(),
"<script>x = y < 1</script>",
);
}
#[test]
fn safe_jsstr_passthrough_in_js_string() {
ok(
"<script>var s = \"{{.}}\"</script>",
&JSStr::from(r"a\x3cb").into(),
r#"<script>var s = "a\x3cb"</script>"#,
);
}
#[test]
fn safe_css_passthrough_in_style() {
ok(
"<style>{{.}}</style>",
&CSS::from("color: red; width: 2px").into(),
"<style>color: red; width: 2px</style>",
);
}
#[test]
fn safe_htmlattr_passthrough_in_tag() {
ok(
"<a {{.}}>",
&HTMLAttr::from("title=\"ok\"").into(),
"<a title=\"ok\">",
);
}
#[test]
fn safe_srcset_passthrough_in_srcset() {
ok(
"<img srcset=\"{{.}}\">",
&Srcset::from("a.png 1x, b.png 2x").into(),
"<img srcset=\"a.png 1x, b.png 2x\">",
);
}
#[test]
fn srcset_filters_unsafe_url() {
ok(
"<img srcset=\"{{.}}\">",
&s("javascript:alert(1), b.png 2x"),
"<img srcset=\"#ZgotmplZ, b.png 2x\">",
);
}
#[test]
fn js_double_quoted_string() {
ok(
"<script>var s = \"{{.}}\"</script>",
&s(r#"he said "hi" </script>"#),
r#"<script>var s = "he said \u0022hi\u0022 \u003c\/script\u003e"</script>"#,
);
}
#[test]
fn js_regexp_literal() {
ok(
"<script>var r = /{{.}}/</script>",
&s("a.b"),
r#"<script>var r = /a\.b/</script>"#,
);
}
#[test]
fn css_url_filters_unsafe_scheme() {
ok(
"<style>a{background:url({{.}})}</style>",
&s("javascript:alert(1)"),
"<style>a{background:url(#ZgotmplZ)}</style>",
);
}
#[test]
fn unquoted_url_attr() {
ok("<a href={{.}}>", &s("/x?a=1 2"), "<a href=/x?a=1%202>");
}
#[test]
fn attr_name_filtered() {
ok("<a {{.}}=x>", &s("href"), "<a ZgotmplZ=x>");
}
#[test]
fn err_ends_in_url_context() {
fail("<a href=\"{{.}}", &s("x"));
}
#[test]
fn err_ends_in_script_context() {
fail("<script>var x = 1", &s("x"));
}
#[test]
fn err_branches_end_in_different_contexts() {
fail(
"<a {{if .}}href=\"{{else}}title='{{end}}x\">",
&Value::Bool(true),
);
}
#[test]
fn err_ambiguous_slash_in_js() {
fail(
"<script>{{if true}}var x = 1{{end}}/foo/g{{.}}</script>",
&s("y"),
);
}
#[test]
fn typed_content_matrix() {
let data: [Value; 9] = [
s(r#"<b> "foo%" O'Reilly &bar;"#), CSS::from(r#"a[href =~ "//example.com"]#foo"#).into(), HTML::from("Hello, <b>World</b> &tc!").into(), HTMLAttr::from(r#" dir="ltr""#).into(), JS::from(r#"c && alert("Hello, World!");"#).into(), JSStr::from(r"Hello, World & O'Reilly\u0021").into(), URL::from("greeting=H%69,&addressee=(World)").into(), Srcset::from("greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w")
.into(), URL::from(",foo/,").into(), ];
let cases: &[(&str, [&str; 9])] = &[
(
r##"<style>{{.}} { color: blue }</style>"##,
[
r##"ZgotmplZ"##,
r##"a[href =~ "//example.com"]#foo"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
],
),
(
r##"<div style="{{.}}">"##,
[
r##"ZgotmplZ"##,
r##"a[href =~ "//example.com"]#foo"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
],
),
(
r##"{{.}}"##,
[
r##"<b> "foo%" O'Reilly &bar;"##,
r##"a[href =~ "//example.com"]#foo"##,
r##"Hello, <b>World</b> &tc!"##,
r##" dir="ltr""##,
r##"c && alert("Hello, World!");"##,
r##"Hello, World & O'Reilly\u0021"##,
r##"greeting=H%69,&addressee=(World)"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##",foo/,"##,
],
),
(
r##"<a{{.}}>"##,
[
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##" dir="ltr""##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
r##"ZgotmplZ"##,
],
),
(
r##"<a title={{.}}>"##,
[
r##"<b> "foo%" O'Reilly &bar;"##,
r##"a[href =~ "//example.com"]#foo"##,
r##"Hello, World &tc!"##,
r##" dir="ltr""##,
r##"c && alert("Hello, World!");"##,
r##"Hello, World & O'Reilly\u0021"##,
r##"greeting=H%69,&addressee=(World)"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##",foo/,"##,
],
),
(
r##"<a title='{{.}}'>"##,
[
r##"<b> "foo%" O'Reilly &bar;"##,
r##"a[href =~ "//example.com"]#foo"##,
r##"Hello, World &tc!"##,
r##" dir="ltr""##,
r##"c && alert("Hello, World!");"##,
r##"Hello, World & O'Reilly\u0021"##,
r##"greeting=H%69,&addressee=(World)"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##",foo/,"##,
],
),
(
r##"<textarea>{{.}}</textarea>"##,
[
r##"<b> "foo%" O'Reilly &bar;"##,
r##"a[href =~ "//example.com"]#foo"##,
r##"Hello, <b>World</b> &tc!"##,
r##" dir="ltr""##,
r##"c && alert("Hello, World!");"##,
r##"Hello, World & O'Reilly\u0021"##,
r##"greeting=H%69,&addressee=(World)"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##",foo/,"##,
],
),
(
r##"<script>alert({{.}})</script>"##,
[
r##""\u003cb\u003e \"foo%\" O'Reilly \u0026bar;""##,
r##""a[href =~ \"//example.com\"]#foo""##,
r##""Hello, \u003cb\u003eWorld\u003c/b\u003e \u0026amp;tc!""##,
r##"" dir=\"ltr\"""##,
r##"c && alert("Hello, World!");"##,
r##""Hello, World & O'Reilly\u0021""##,
r##""greeting=H%69,\u0026addressee=(World)""##,
r##""greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w""##,
r##"",foo/,""##,
],
),
(
r##"<button onclick="alert({{.}})">"##,
[
r##""\u003cb\u003e \"foo%\" O'Reilly \u0026bar;""##,
r##""a[href =~ \"//example.com\"]#foo""##,
r##""Hello, \u003cb\u003eWorld\u003c/b\u003e \u0026amp;tc!""##,
r##"" dir=\"ltr\"""##,
r##"c && alert("Hello, World!");"##,
r##""Hello, World & O'Reilly\u0021""##,
r##""greeting=H%69,\u0026addressee=(World)""##,
r##""greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w""##,
r##"",foo/,""##,
],
),
(
r##"<script>alert("{{.}}")</script>"##,
[
r##"\u003cb\u003e \u0022foo%\u0022 O\u0027Reilly \u0026bar;"##,
r##"a[href =~ \u0022\/\/example.com\u0022]#foo"##,
r##"Hello, \u003cb\u003eWorld\u003c\/b\u003e \u0026amp;tc!"##,
r##" dir=\u0022ltr\u0022"##,
r##"c \u0026\u0026 alert(\u0022Hello, World!\u0022);"##,
r##"Hello, World \u0026 O\u0027Reilly\u0021"##,
r##"greeting=H%69,\u0026addressee=(World)"##,
r##"greeting=H%69,\u0026addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w"##,
r##",foo\/,"##,
],
),
(
r##"<script type="text/javascript">alert("{{.}}")</script>"##,
[
r##"\u003cb\u003e \u0022foo%\u0022 O\u0027Reilly \u0026bar;"##,
r##"a[href =~ \u0022\/\/example.com\u0022]#foo"##,
r##"Hello, \u003cb\u003eWorld\u003c\/b\u003e \u0026amp;tc!"##,
r##" dir=\u0022ltr\u0022"##,
r##"c \u0026\u0026 alert(\u0022Hello, World!\u0022);"##,
r##"Hello, World \u0026 O\u0027Reilly\u0021"##,
r##"greeting=H%69,\u0026addressee=(World)"##,
r##"greeting=H%69,\u0026addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w"##,
r##",foo\/,"##,
],
),
(
r##"<script type="text/javascript">alert({{.}})</script>"##,
[
r##""\u003cb\u003e \"foo%\" O'Reilly \u0026bar;""##,
r##""a[href =~ \"//example.com\"]#foo""##,
r##""Hello, \u003cb\u003eWorld\u003c/b\u003e \u0026amp;tc!""##,
r##"" dir=\"ltr\"""##,
r##"c && alert("Hello, World!");"##,
r##""Hello, World & O'Reilly\u0021""##,
r##""greeting=H%69,\u0026addressee=(World)""##,
r##""greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w""##,
r##"",foo/,""##,
],
),
(
r##"<script type="text/template">{{.}}</script>"##,
[
r##"<b> "foo%" O'Reilly &bar;"##,
r##"a[href =~ "//example.com"]#foo"##,
r##"Hello, <b>World</b> &tc!"##,
r##" dir="ltr""##,
r##"c && alert("Hello, World!");"##,
r##"Hello, World & O'Reilly\u0021"##,
r##"greeting=H%69,&addressee=(World)"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##",foo/,"##,
],
),
(
r##"<button onclick='alert("{{.}}")'>"##,
[
r##"\u003cb\u003e \u0022foo%\u0022 O\u0027Reilly \u0026bar;"##,
r##"a[href =~ \u0022\/\/example.com\u0022]#foo"##,
r##"Hello, \u003cb\u003eWorld\u003c\/b\u003e \u0026amp;tc!"##,
r##" dir=\u0022ltr\u0022"##,
r##"c \u0026\u0026 alert(\u0022Hello, World!\u0022);"##,
r##"Hello, World \u0026 O\u0027Reilly\u0021"##,
r##"greeting=H%69,\u0026addressee=(World)"##,
r##"greeting=H%69,\u0026addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w"##,
r##",foo\/,"##,
],
),
(
r##"<a href="?q={{.}}">"##,
[
r##"%3cb%3e%20%22foo%25%22%20O%27Reilly%20%26bar%3b"##,
r##"a%5bhref%20%3d~%20%22%2f%2fexample.com%22%5d%23foo"##,
r##"Hello%2c%20%3cb%3eWorld%3c%2fb%3e%20%26amp%3btc%21"##,
r##"%20dir%3d%22ltr%22"##,
r##"c%20%26%26%20alert%28%22Hello%2c%20World%21%22%29%3b"##,
r##"Hello%2c%20World%20%26%20O%27Reilly%5cu0021"##,
r##"greeting=H%69,&addressee=%28World%29"##,
r##"greeting%3dH%2569%2c%26addressee%3d%28World%29%202x%2c%20https%3a%2f%2fgolang.org%2ffavicon.ico%20500.5w"##,
r##",foo/,"##,
],
),
(
r##"<style>body { background: url('?img={{.}}') }</style>"##,
[
r##"%3cb%3e%20%22foo%25%22%20O%27Reilly%20%26bar%3b"##,
r##"a%5bhref%20%3d~%20%22%2f%2fexample.com%22%5d%23foo"##,
r##"Hello%2c%20%3cb%3eWorld%3c%2fb%3e%20%26amp%3btc%21"##,
r##"%20dir%3d%22ltr%22"##,
r##"c%20%26%26%20alert%28%22Hello%2c%20World%21%22%29%3b"##,
r##"Hello%2c%20World%20%26%20O%27Reilly%5cu0021"##,
r##"greeting=H%69,&addressee=%28World%29"##,
r##"greeting%3dH%2569%2c%26addressee%3d%28World%29%202x%2c%20https%3a%2f%2fgolang.org%2ffavicon.ico%20500.5w"##,
r##",foo/,"##,
],
),
(
r##"<img srcset="{{.}}">"##,
[
r##"#ZgotmplZ"##,
r##"#ZgotmplZ"##,
r##"Hello,#ZgotmplZ"##,
r##" dir=%22ltr%22"##,
r##"#ZgotmplZ, World!%22%29;"##,
r##"Hello,#ZgotmplZ"##,
r##"greeting=H%69%2c&addressee=%28World%29"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##"%2cfoo/%2c"##,
],
),
(
r##"<img srcset={{.}}>"##,
[
r##"#ZgotmplZ"##,
r##"#ZgotmplZ"##,
r##"Hello,#ZgotmplZ"##,
r##" dir=%22ltr%22"##,
r##"#ZgotmplZ, World!%22%29;"##,
r##"Hello,#ZgotmplZ"##,
r##"greeting=H%69%2c&addressee=%28World%29"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##"%2cfoo/%2c"##,
],
),
(
r##"<img srcset="{{.}} 2x, https://golang.org/ 500.5w">"##,
[
r##"#ZgotmplZ"##,
r##"#ZgotmplZ"##,
r##"Hello,#ZgotmplZ"##,
r##" dir=%22ltr%22"##,
r##"#ZgotmplZ, World!%22%29;"##,
r##"Hello,#ZgotmplZ"##,
r##"greeting=H%69%2c&addressee=%28World%29"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##"%2cfoo/%2c"##,
],
),
(
r##"<img srcset="http://godoc.org/ {{.}}, https://golang.org/ 500.5w">"##,
[
r##"#ZgotmplZ"##,
r##"#ZgotmplZ"##,
r##"Hello,#ZgotmplZ"##,
r##" dir=%22ltr%22"##,
r##"#ZgotmplZ, World!%22%29;"##,
r##"Hello,#ZgotmplZ"##,
r##"greeting=H%69%2c&addressee=%28World%29"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##"%2cfoo/%2c"##,
],
),
(
r##"<img srcset="http://godoc.org/?q={{.}} 2x, https://golang.org/ 500.5w">"##,
[
r##"#ZgotmplZ"##,
r##"#ZgotmplZ"##,
r##"Hello,#ZgotmplZ"##,
r##" dir=%22ltr%22"##,
r##"#ZgotmplZ, World!%22%29;"##,
r##"Hello,#ZgotmplZ"##,
r##"greeting=H%69%2c&addressee=%28World%29"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##"%2cfoo/%2c"##,
],
),
(
r##"<img srcset="http://godoc.org/ 2x, {{.}} 500.5w">"##,
[
r##"#ZgotmplZ"##,
r##"#ZgotmplZ"##,
r##"Hello,#ZgotmplZ"##,
r##" dir=%22ltr%22"##,
r##"#ZgotmplZ, World!%22%29;"##,
r##"Hello,#ZgotmplZ"##,
r##"greeting=H%69%2c&addressee=%28World%29"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##"%2cfoo/%2c"##,
],
),
(
r##"<img srcset="http://godoc.org/ 2x, https://golang.org/ {{.}}">"##,
[
r##"#ZgotmplZ"##,
r##"#ZgotmplZ"##,
r##"Hello,#ZgotmplZ"##,
r##" dir=%22ltr%22"##,
r##"#ZgotmplZ, World!%22%29;"##,
r##"Hello,#ZgotmplZ"##,
r##"greeting=H%69%2c&addressee=%28World%29"##,
r##"greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"##,
r##"%2cfoo/%2c"##,
],
),
];
const MARKER: &str = "{{.}}";
for (tmpl, wants) in cases {
let idx = tmpl.find(MARKER).expect("template must contain {{.}}");
let prefix = &tmpl[..idx];
let suffix = &tmpl[idx + MARKER.len()..];
for (i, want) in wants.iter().enumerate() {
let expected = format!("{prefix}{want}{suffix}");
ok(tmpl, &data[i], &expected);
}
}
}
#[test]
fn script_type_mime_classification() {
for ty in [
"text/javascript",
"application/json",
"application/ld+json",
"module",
"text/javascript;version=1.8",
] {
ok(
&format!(r#"<script type="{ty}">alert({{{{.}}}})</script>"#),
&s("</script>"),
&format!(r#"<script type="{ty}">alert("\u003c/script\u003e")</script>"#),
);
}
for ty in ["text/template", "text/plain"] {
ok(
&format!(r#"<script type="{ty}">alert({{{{.}}}})</script>"#),
&s("</script>"),
&format!(r#"<script type="{ty}">alert(</script>)</script>"#),
);
}
}
#[test]
fn json_content_type_script_string() {
let tmpl = r#"<script type="application/ld+json">"{{.}}"</script>"#;
let cases: &[(&str, &str)] = &[
("", r##""##),
("\u{FFFD}", "\u{FFFD}"),
("\u{0}", r##"\u0000"##),
("\u{1F}", r##"\u001f"##),
("\t", r##"\t"##),
("<>", r##"\u003c\u003e"##),
("'\"", r##"\u0027\u0022"##),
("ASCII letters", r##"ASCII letters"##),
(
"\u{0295}\u{2299}\u{03D6}\u{2299}\u{0294}",
"\u{0295}\u{2299}\u{03D6}\u{2299}\u{0294}",
),
("\u{1F355}", "\u{1F355}"),
];
let prefix = r#"<script type="application/ld+json">""#;
let suffix = r#""</script>"#;
for &(input, want) in cases {
ok(tmpl, &s(input), &format!("{prefix}{want}{suffix}"));
}
}
#[test]
fn nospace_empty_unquoted_attr() {
ok("<p title={{.}}>", &s(""), "<p title=ZgotmplZ>");
ok("<p title={{.}}>", &s("a b"), "<p title=a b>");
}
#[test]
fn clone_escapes_independently_after_source_execute() {
let base = Template::new("t").parse("<p>{{.}}</p>").unwrap();
let clone = base.clone();
assert_eq!(
base.execute_to_string(&s("<x>")).unwrap(),
"<p><x></p>"
);
assert_eq!(
clone.execute_to_string(&s("<y>")).unwrap(),
"<p><y></p>"
);
}
#[test]
fn clone_can_be_parsed_into_after_source_execute() {
let base = Template::new("t").parse("<p>{{.}}</p>").unwrap();
let clone = base.clone();
let _ = base.execute_to_string(&s("x")).unwrap();
assert!(base.clone().parse("x").is_ok()); let clone = clone
.parse(r#"{{define "extra"}}<a href="{{.}}">{{end}}"#)
.unwrap();
assert_eq!(
clone
.execute_template_to_string("extra", &s("javascript:alert(1)"))
.unwrap(),
r##"<a href="#ZgotmplZ">"##
);
}
#[test]
fn clone_preserves_registered_funcs() {
let base = Template::new("t")
.func("shout", |args| {
let v = args.first().and_then(|v| v.as_str()).unwrap_or_default();
Ok(Value::String(v.to_uppercase().into()))
})
.parse("<p>{{shout .}}</p>")
.unwrap();
let clone = base.clone();
assert_eq!(
clone.execute_to_string(&s("hi <b>")).unwrap(),
"<p>HI <B></p>",
);
}
#[test]
fn escape_map_field_named_like_escaper() {
let data = tmap! {
"html" => "<h1>Hi!</h1>",
"urlquery" => "http://www.foo.com/index.html?title=main",
};
ok("{{.html | print}}", &data, "<h1>Hi!</h1>");
ok(
"{{.urlquery | print}}",
&data,
"http://www.foo.com/index.html?title=main",
);
}
#[test]
fn idempotent_execute() {
let t = Template::new("")
.parse(r#"{{define "main"}}<body>{{template "hello"}}</body>{{end}}"#)
.unwrap()
.parse(r#"{{define "hello"}}Hello, {{"Ladies & Gentlemen!"}}{{end}}"#)
.unwrap();
for _ in 0..2 {
assert_eq!(
t.execute_template_to_string("hello", &Value::Nil).unwrap(),
"Hello, Ladies & Gentlemen!",
);
}
assert_eq!(
t.execute_template_to_string("main", &Value::Nil).unwrap(),
"<body>Hello, Ladies & Gentlemen!</body>",
);
}
#[test]
fn escape_errors_produce_no_output() {
let t = Template::new("dangerous").parse("<a").unwrap();
let mut buf = String::new();
assert!(t.execute_fmt(&mut buf, &Value::Nil).is_err());
assert!(buf.is_empty(), "emitted output despite failure: {buf:?}");
let t = Template::new("root")
.parse(r#"{{define "t"}}<a{{end}}"#)
.unwrap();
let mut buf = String::new();
assert!(t.execute_template_fmt(&mut buf, "t", &Value::Nil).is_err());
assert!(buf.is_empty(), "emitted output despite failure: {buf:?}");
}
#[test]
fn aliased_parse_tree_does_not_overescape() {
let t = Template::new("foo").parse("{{.}}").unwrap();
let tree = t.lookup("foo").expect("foo tree").clone();
let t = t.add_parse_tree("bar", tree).unwrap();
let got_foo = t.execute_template_to_string("foo", &s("<baz>")).unwrap();
let got_bar = t.execute_template_to_string("bar", &s("<baz>")).unwrap();
assert_eq!(got_foo, "<baz>");
assert_eq!(got_foo, got_bar);
}