/*
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.template.soy.parsepasses.contextautoesc;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;
import com.google.common.base.Joiner;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableMap;
import com.google.template.soy.SoyFileSetParser.ParseResult;
import com.google.template.soy.SoyFileSetParserBuilder;
import com.google.template.soy.base.SourceLocation;
import com.google.template.soy.error.ErrorReporter;
import com.google.template.soy.error.ExplodingErrorReporter;
import com.google.template.soy.shared.restricted.SoyPrintDirective;
import com.google.template.soy.soytree.HtmlContext;
import com.google.template.soy.soytree.RawTextNode;
import com.google.template.soy.soytree.SoyFileNode;
import com.google.template.soy.soytree.SoyFileSetNode;
import com.google.template.soy.soytree.TemplateNode;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
/**
* Test for {@link SlicedRawTextNode}.
*
*/
@RunWith(JUnit4.class)
public final class SlicedRawTextNodeTest {
/** Custom print directives used in tests below. */
private static final ImmutableMap<String, SoyPrintDirective> SOY_PRINT_DIRECTIVES =
ImmutableMap.of();
@Test
public void testTrivialTemplate() throws Exception {
assertInjected(
join("{template .foo}\n", "Hello, World!\n", "{/template}"),
join("{template .foo}\n", "Hello, World!\n", "{/template}"));
}
@Test
public void testOneScriptWithBody() throws Exception {
assertInjected(
join(
"{template .foo}\n",
"<script INJE='CTED'>alert('Hello, World!')</script>\n",
"{/template}"),
join("{template .foo}\n", "<script>alert('Hello, World!')</script>\n", "{/template}"));
}
@Test
public void testOneSrcedScript() throws Exception {
assertInjected(
join("{template .foo}\n", "<script src=\"app.js\" INJE='CTED'></script>\n", "{/template}"),
join("{template .foo}\n", "<script src=\"app.js\"></script>\n", "{/template}"));
}
@Test
public void testManyScripts() throws Exception {
assertInjected(
join(
"{template .foo}\n",
"<script src=\"one.js\" INJE='CTED'></script>",
"<script src=two.js INJE='CTED'></script>",
"<script src=three.js INJE='CTED'/></script>",
"<h1>Not a script</h1>",
"<script type='text/javascript' INJE='CTED'>main()</script>\n",
"{/template}"),
join(
"{template .foo}\n",
"<script src=\"one.js\"></script>",
"<script src=two.js></script>",
"<script src=three.js /></script>",
"<h1>Not a script</h1>",
"<script type='text/javascript'>main()</script>\n",
"{/template}"));
}
@Test
public void testFakeScripts() throws Exception {
assertInjected(
join(
"{template .foo}\n",
"<noscript></noscript>",
"<script INJE='CTED'>alert('Hi');</script>",
"<!-- <script>notAScript()</script> -->",
"<textarea><script>notAScript()</script></textarea>",
"<script is-script=yes>document.write('<script>not()<\\/script>');</script>",
"<a href=\"//google.com/search?q=<script>hi()</script>\">Link</a>\n",
"{/template}"),
join(
"{template .foo}\n",
"<noscript></noscript>",
// An actual script in a sea of imposters.
"<script>alert('Hi');</script>",
// Injecting a nonce into something that is not a script might be bad.
"<!-- <script>notAScript()</script> -->",
"<textarea><script>notAScript()</script></textarea>",
"<script is-script=yes>document.write('<script>not()<\\/script>');</script>",
"<a href=\"//google.com/search?q=<script>hi()</script>\">Link</a>\n",
"{/template}"));
}
@Test
public void testPrintDirectiveInScriptTag() throws Exception {
assertInjected(
join(
"{template .foo}\n",
" {@param appScriptUrl: ?}\n",
"<script src='{$appScriptUrl |filterTrustedResourceUri |escapeHtmlAttribute}' ",
"INJE='CTED'>",
"alert('Hello, World!')</script>\n",
"{/template}"),
join(
"{template .foo}\n",
" {@param appScriptUrl: ?}\n",
"<script src='{$appScriptUrl}'>",
"alert('Hello, World!')</script>\n",
"{/template}"));
}
@Test
public void testContextAssumptionsUpheld() throws Exception {
try {
parseAndInjectIntoScriptTags(
join("{template .foo}\n", "<script src='foo.js'></script>\n", "{/template}"),
" title='unclosed");
} catch (SoyAutoescapeException ex) {
assertThat(ex)
.hasMessageThat()
.isEqualTo(
"In file no-path:3:16, template ns.foo:"
+ " Inserting ` title='unclosed` would cause text node to end in context"
+ " (Context HTML_NORMAL_ATTR_VALUE SCRIPT PLAIN_TEXT SINGLE_QUOTE) instead of"
+ " (Context HTML_PCDATA)");
return;
}
fail("Expected SoyAutoescapeException");
}
@Test
public void testMergeAdjacentSlicesWithSameContext() throws Exception {
// Insert slices in a way that we end up with multiple adjacent slices with the
// same context arranged thus:
// Index 0 1 2 3 4 5 6 7 8 9 A B C D E F
// Char H e l l o , < W o r l d > !
// Slice 0 0 0 0 1 1 1 2 2 2 2 3 5 5 6
// Context a a a a a a a b b b b b b b a
RawTextNode rawTextNode = new RawTextNode(0, "Hello, <World>!", SourceLocation.UNKNOWN);
Context a = Context.HTML_PCDATA;
Context b = Context.HTML_PCDATA.derive(HtmlContext.HTML_TAG_NAME);
SlicedRawTextNode slicedNode = new SlicedRawTextNode(rawTextNode, a);
slicedNode.insertSlice(0, a, 4); // "Hell"
slicedNode.insertSlice(1, a, 3); // "o, "
slicedNode.insertSlice(2, b, 4); // "<Wor"
slicedNode.insertSlice(3, b, 1); // "l"
slicedNode.insertSlice(4, b, 0); // ""
slicedNode.insertSlice(5, b, 2); // "d>"
slicedNode.insertSlice(6, a, 1); // "!"
slicedNode.setEndContext(a);
assertThat(slicedNode.getSlices()).hasSize(7);
assertThat(slicesToString(slicedNode.getSlices()))
.isEqualTo(
"\"Hell\"#0:HTML_PCDATA, "
+ "\"o, \"#0:HTML_PCDATA, "
+ "\"<Wor\"#0:HTML_TAG_NAME, "
+ "\"l\"#0:HTML_TAG_NAME, "
+ "\"\"#0:HTML_TAG_NAME, "
+ "\"d>\"#0:HTML_TAG_NAME, "
+ "\"!\"#0:HTML_PCDATA");
slicedNode.mergeAdjacentSlicesWithSameContext();
assertThat(slicedNode.getSlices()).hasSize(3);
assertThat(slicesToString(slicedNode.getSlices()))
.isEqualTo(
"\"Hello, \"#0:HTML_PCDATA, "
+ "\"<World>\"#0:HTML_TAG_NAME, "
+ "\"!\"#0:HTML_PCDATA");
}
/**
* Renders slices to a comma separated list with elements like {@code
* "<text>"#<node-id>:<context>}.
*/
private static final String slicesToString(List<SlicedRawTextNode.RawTextSlice> slices) {
StringBuilder sb = new StringBuilder();
for (SlicedRawTextNode.RawTextSlice slice : slices) {
if (sb.length() != 0) {
sb.append(", ");
}
sb.append(slice);
sb.append(":");
sb.append(slice.context.state);
}
return sb.toString();
}
private static String join(String... lines) {
return Joiner.on("").join(lines);
}
private SoyFileSetNode parseAndInjectIntoScriptTags(String input, String toInject) {
String namespace = "{namespace ns autoescape=\"deprecated-contextual\"}\n\n";
ErrorReporter boom = ExplodingErrorReporter.get();
ParseResult parseResult =
SoyFileSetParserBuilder.forFileContents(namespace + input).errorReporter(boom).parse();
SoyFileSetNode soyTree = parseResult.fileSet();
ContextualAutoescaper contextualAutoescaper = new ContextualAutoescaper(SOY_PRINT_DIRECTIVES);
List<TemplateNode> extras =
contextualAutoescaper.rewrite(soyTree, parseResult.registry(), boom);
SoyFileNode file = soyTree.getChild(soyTree.numChildren() - 1);
file.addChildren(file.numChildren(), extras);
insertTextAtEndOfScriptOpenTag(contextualAutoescaper.getSlicedRawTextNodes(), toInject);
return soyTree;
}
/**
* Returns the contextually rewritten source.
*
* <p>The Soy tree may have multiple files, but only the source code for the first is returned.
*/
private void assertInjected(String expectedOutput, String input) {
SoyFileSetNode soyTree = parseAndInjectIntoScriptTags(input, " INJE='CTED'");
StringBuilder src = new StringBuilder();
src.append(soyTree.getChild(0).toSourceString());
String output = src.toString().trim();
if (output.startsWith("{namespace ns")) {
output = output.substring(output.indexOf('}') + 1).trim();
}
assertThat(output).isEqualTo(expectedOutput);
}
private static void insertTextAtEndOfScriptOpenTag(
List<SlicedRawTextNode> slicedRawTextNodes, String toInject) {
Predicate<? super Context> inScriptTag =
new Predicate<Context>() {
@Override
public boolean apply(Context c) {
return (
// In a script tag,
c.elType == Context.ElementType.SCRIPT
&& c.state == HtmlContext.HTML_TAG
// but not in an attribute
&& c.attrType == Context.AttributeType.NONE);
}
};
Predicate<? super Context> inScriptBody =
new Predicate<Context>() {
@Override
public boolean apply(Context c) {
return (
// If we're not in an attribute,
c.attrType == Context.AttributeType.NONE
// but we're in JS, then we must be in a script body.
&& c.state == HtmlContext.JS);
}
};
for (SlicedRawTextNode.RawTextSlice slice :
SlicedRawTextNode.find(slicedRawTextNodes, null, inScriptTag, inScriptBody)) {
String rawText = slice.getRawText();
int rawTextLen = rawText.length();
assertThat(rawText.charAt(rawTextLen - 1)).isEqualTo('>');
int insertionPoint = rawTextLen - 1;
// Do not insert in the middle of a "/>" tag terminator.
if (insertionPoint - 1 >= 0 && rawText.charAt(insertionPoint - 1) == '/') {
--insertionPoint;
}
slice.insertText(insertionPoint, toInject);
}
}
}