/*
* Copyright 2016 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.passes;
import com.google.common.collect.ImmutableList;
import com.google.template.soy.base.SourceLocation;
import com.google.template.soy.base.internal.IdGenerator;
import com.google.template.soy.data.SanitizedContent.ContentKind;
import com.google.template.soy.error.ErrorReporter;
import com.google.template.soy.error.ErrorReporter.Checkpoint;
import com.google.template.soy.error.SoyErrorKind;
import com.google.template.soy.exprtree.ExprRootNode;
import com.google.template.soy.soytree.AbstractSoyNodeVisitor;
import com.google.template.soy.soytree.AutoescapeMode;
import com.google.template.soy.soytree.HtmlCloseTagNode;
import com.google.template.soy.soytree.HtmlOpenTagNode;
import com.google.template.soy.soytree.IfCondNode;
import com.google.template.soy.soytree.IfElseNode;
import com.google.template.soy.soytree.IfNode;
import com.google.template.soy.soytree.NamespaceDeclaration;
import com.google.template.soy.soytree.SoyFileNode;
import com.google.template.soy.soytree.SoyNode;
import com.google.template.soy.soytree.SoyNode.BlockNode;
import com.google.template.soy.soytree.SoyNode.ParentSoyNode;
import com.google.template.soy.soytree.StrictHtmlMode;
import com.google.template.soy.soytree.SwitchCaseNode;
import com.google.template.soy.soytree.SwitchDefaultNode;
import com.google.template.soy.soytree.SwitchNode;
import com.google.template.soy.soytree.TagName;
import com.google.template.soy.soytree.TemplateNode;
import java.util.ArrayDeque;
/** A {@link CompilerFilePass} that checks strict html mode. See go/soy-html for usages. */
final class StrictHtmlValidationPass extends CompilerFilePass {
private static final SoyErrorKind STRICT_HTML_DISABLED =
SoyErrorKind.of(
"Strict HTML mode is disabled by default. In order to use stricthtml syntax in your Soy "
+ "template, explicitly pass --enabledExperimentalFeatures=stricthtml to compiler.");
private static final SoyErrorKind STRICT_HTML_WITHOUT_AUTOESCAPE =
SoyErrorKind.of("stricthtml=\"true\" must be used with autoescape=\"strict\".");
private static final SoyErrorKind STRICT_HTML_WITH_NON_HTML =
SoyErrorKind.of("stricthtml=\"true\" can only be used with kind=\"html\".");
private static final SoyErrorKind INVALID_SELF_CLOSING_TAG =
SoyErrorKind.of("''{0}'' tag is not allowed to be self-closing.");
private static final SoyErrorKind INVALID_CLOSE_TAG =
SoyErrorKind.of("''{0}'' tag is a void element and must not specify a close tag.");
private static final SoyErrorKind SWITCH_HTML_MODE_IN_BLOCK =
SoyErrorKind.of("Foreign elements (svg) must be opened and closed within the same block.");
private static final SoyErrorKind NESTED_SVG = SoyErrorKind.of("Nested SVG tags are disallowed.");
private final boolean enabledStrictHtml;
private final ErrorReporter errorReporter;
StrictHtmlValidationPass(
ImmutableList<String> experimentalFeatures, ErrorReporter errorReporter) {
this.enabledStrictHtml = experimentalFeatures.contains("stricthtml");
this.errorReporter = errorReporter;
}
@Override
public void run(SoyFileNode file, IdGenerator nodeIdGen) {
// First check namespace declarations, and return if there is any violation.
NamespaceDeclaration namespace = file.getNamespaceDeclaration();
if (namespace.getStrictHtmlMode() != StrictHtmlMode.UNSET) {
if (!enabledStrictHtml && namespace.getStrictHtmlMode() == StrictHtmlMode.YES) {
errorReporter.report(namespace.getStrictHtmlModeLocation(), STRICT_HTML_DISABLED);
return;
}
if (namespace.getDefaultAutoescapeMode() != AutoescapeMode.STRICT
&& namespace.getStrictHtmlMode() == StrictHtmlMode.YES) {
errorReporter.report(namespace.getAutoescapeModeLocation(), STRICT_HTML_WITHOUT_AUTOESCAPE);
return;
}
}
// Then check each template node.
for (TemplateNode node : file.getChildren()) {
checkTemplateNode(node);
}
}
private void checkTemplateNode(TemplateNode node) {
if (!enabledStrictHtml && node.isStrictHtml()) {
errorReporter.report(node.getSourceLocation(), STRICT_HTML_DISABLED);
return;
}
AutoescapeMode autoescapeMode = node.getAutoescapeMode();
if (autoescapeMode != AutoescapeMode.STRICT && node.isStrictHtml()) {
errorReporter.report(node.getSourceLocation(), STRICT_HTML_WITHOUT_AUTOESCAPE);
return;
}
// ContentKind is guaranteed to be non-null if AutoescapeMode is strict.
ContentKind contentKind = node.getContentKind();
if (contentKind != ContentKind.HTML && node.isStrictHtml()) {
errorReporter.report(node.getSourceLocation(), STRICT_HTML_WITH_NON_HTML);
return;
}
if (node.isStrictHtml()) {
new HtmlTagVisitor(errorReporter).exec(node);
}
}
private static final class HtmlTagVisitor extends AbstractSoyNodeVisitor<Void> {
/** Current condition that will be updated when we visit a control flow node. */
private Condition currentCondition = Condition.getEmptyCondition();
/**
* A {@code ConditionalBranches} that stores all open tags in an {@code IfNode}. The branch will
* be pushed to openTagStack once we visit all children of an {@code IfNode}.
*/
private final ConditionalBranches openTagBranches = new ConditionalBranches();
/**
* A {@code ConditionalBranches} that stores all close tags in an {@code IfNode}. The branch
* will be pushed to closeTagQueue once we visit all children of an {@code IfNode}.
*/
private final ConditionalBranches closeTagBranches = new ConditionalBranches();
/**
* A stack of open tags. After we visit all children of a {@code BlockNode}, the stack will be
* added to a {@code ConditionalBranches} (based on currentConditions).
*/
private final ArrayDeque<HtmlTagEntry> openTagStack = new ArrayDeque<>();
/**
* A queue of close tags. After we visit all children of a {@code BlockNode}, the queue will be
* added to a {@code ConditionalBranches} (based on currentConditions).
*/
private final ArrayDeque<HtmlTagEntry> closeTagQueue = new ArrayDeque<>();
/**
* A boolean indicates that the current snippet is in a foreign content (in particular, svg). If
* this is true, we treat later tags as xml tags (that can be either self-closing or explicitly
* closed) until we leave foreign content.
*/
private boolean inForeignContent = false;
private SourceLocation foreignContentStartLocation = SourceLocation.UNKNOWN;
private SourceLocation foreignContentEndLocation = SourceLocation.UNKNOWN;
private final ErrorReporter errorReporter;
HtmlTagVisitor(ErrorReporter errorReporter) {
this.errorReporter = errorReporter;
}
@Override
protected void visitHtmlOpenTagNode(HtmlOpenTagNode node) {
TagName openTag = node.getTagName();
// Switch to xml mode if we reach a <svg> tag.
if (openTag.isForeignContent()) {
if (inForeignContent) {
errorReporter.report(node.getSourceLocation(), NESTED_SVG);
}
inForeignContent = true;
foreignContentStartLocation = node.getSourceLocation();
}
// For static tag, check if it is a valid self-closing tag.
if (openTag.isStatic()) {
// Report errors for non-void tags that are self-closing.
// For void tags, we don't care if they are self-closing or not. But when we visit
// a HtmlCloseTagNode we will throw an error if it is a void tag.
// Ignore this check if we are currently in a foreign content (svg).
if (!inForeignContent && !isDefinitelyVoid(node) && node.isSelfClosing()) {
errorReporter.report(
node.getSourceLocation(),
INVALID_SELF_CLOSING_TAG,
openTag.getStaticTagName().getRawText());
return;
}
}
// Push the node into open tag stack.
if (!node.isSelfClosing() && !isDefinitelyVoid(node)) {
openTagStack.addFirst(new HtmlTagEntry(openTag));
}
}
@Override
protected void visitHtmlCloseTagNode(HtmlCloseTagNode node) {
TagName closeTag = node.getTagName();
// Report an error if this node is a void tag. Void tag should never be closed.
if (isDefinitelyVoid(node)) {
errorReporter.report(
node.getSourceLocation(), INVALID_CLOSE_TAG, closeTag.getStaticTagName().getRawText());
return;
}
// Switch back to html mode if we leave a svg tag.
if (closeTag.isForeignContent()) {
foreignContentEndLocation = node.getSourceLocation();
inForeignContent = false;
}
// If we cannot find a matching open tag in current block, put the current tag into
// closeTagQueue and compare everything after we visit the entire template node.
if (!HtmlTagEntry.tryMatchCloseTag(openTagStack, closeTag, errorReporter)) {
closeTagQueue.addLast(new HtmlTagEntry(closeTag));
}
}
/**
* When we visit IfNode, we do the following steps:
*
* <ul>
* <li>Create new {@code ConditionalBranches} (and save old branches).
* <li>For each of its children, update the current conditions.
* <li>After visiting all children, check if branches are empty. If they are not empty (i.e.,
* we find some HTML tags within this {@code IfNode}), push the branches into
* corresponding stack or queue.
* <li>Restore the conditions and branches.
* </ul>
*/
@Override
protected void visitIfNode(IfNode node) {
ConditionalBranches outerOpenTagBranches = new ConditionalBranches(openTagBranches);
ConditionalBranches outerCloseTagBranches = new ConditionalBranches(closeTagBranches);
openTagBranches.clear();
closeTagBranches.clear();
visitChildren(node);
if (!openTagBranches.isEmpty()) {
openTagStack.addFirst(new HtmlTagEntry(openTagBranches));
openTagBranches.clear();
}
if (!closeTagBranches.isEmpty()) {
closeTagQueue.addLast(new HtmlTagEntry(closeTagBranches));
closeTagBranches.clear();
}
// At this point we should try to match openTagStack and closeTagQueue and remove anything
// that matches.
HtmlTagEntry.tryMatchOrError(openTagStack, closeTagQueue, errorReporter);
openTagBranches.addAll(outerOpenTagBranches);
closeTagBranches.addAll(outerCloseTagBranches);
}
@Override
protected void visitIfCondNode(IfCondNode node) {
Condition outerCondition = currentCondition.copy();
currentCondition = Condition.createIfCondition(node.getExpr());
visitBlockChildren(node, true);
currentCondition = outerCondition.copy();
}
@Override
protected void visitIfElseNode(IfElseNode node) {
Condition outerCondition = currentCondition.copy();
currentCondition = Condition.createIfCondition();
visitBlockChildren(node, true);
currentCondition = outerCondition.copy();
}
/**
* {@code SwitchNode} is very similar with {@code IfNode}. The major difference is the way to
* generate conditions.
*/
@Override
protected void visitSwitchNode(SwitchNode node) {
ConditionalBranches outerOpenTagBranches = new ConditionalBranches(openTagBranches);
ConditionalBranches outerCloseTagBranches = new ConditionalBranches(closeTagBranches);
openTagBranches.clear();
closeTagBranches.clear();
visitChildren(node);
if (!openTagBranches.isEmpty()) {
openTagStack.addFirst(new HtmlTagEntry(openTagBranches));
openTagBranches.clear();
}
if (!closeTagBranches.isEmpty()) {
closeTagQueue.addLast(new HtmlTagEntry(closeTagBranches));
closeTagBranches.clear();
}
// At this point we should try to match openTagStack and closeTagQueue and remove anything
// that matches.
HtmlTagEntry.tryMatchOrError(openTagStack, closeTagQueue, errorReporter);
openTagBranches.addAll(outerOpenTagBranches);
closeTagBranches.addAll(outerCloseTagBranches);
}
@Override
protected void visitSwitchCaseNode(SwitchCaseNode node) {
Condition outerCondition = currentCondition.copy();
SwitchNode parent = (SwitchNode) node.getParent();
currentCondition = Condition.createSwitchCondition(parent.getExpr(), node.getExprList());
visitBlockChildren(node, true);
currentCondition = outerCondition.copy();
}
@Override
protected void visitSwitchDefaultNode(SwitchDefaultNode node) {
Condition outerCondition = currentCondition.copy();
SwitchNode parent = (SwitchNode) node.getParent();
currentCondition =
Condition.createSwitchCondition(parent.getExpr(), ImmutableList.<ExprRootNode>of());
visitBlockChildren(node, true);
currentCondition = outerCondition.copy();
}
@Override
protected void visitTemplateNode(TemplateNode node) {
Checkpoint checkpoint = errorReporter.checkpoint();
visitChildren(node);
// Return if we have already seen some errors. This case we won't generate a whole cascade
// of errors for things in the remaining stack/queue.
if (errorReporter.errorsSince(checkpoint)) {
return;
}
// Match the tags in the deques.
HtmlTagEntry.matchOrError(openTagStack, closeTagQueue, errorReporter);
}
private static boolean isDefinitelyVoid(HtmlOpenTagNode node) {
return node.getTagName().isDefinitelyVoid();
}
private static boolean isDefinitelyVoid(HtmlCloseTagNode node) {
return node.getTagName().isDefinitelyVoid();
}
@Override
protected void visitSoyNode(SoyNode node) {
if (node instanceof ParentSoyNode) {
if (node instanceof BlockNode) {
visitBlockChildren((BlockNode) node, false);
} else {
visitChildren((ParentSoyNode<?>) node);
}
}
}
private void visitBlockChildren(BlockNode node, boolean inControlBlock) {
// Whenever we visit a {@code BlockNode}, we create new deques for this block. Save the
// contents that are not introduced by the current block.
ArrayDeque<HtmlTagEntry> outerOpenTagStack = new ArrayDeque<>();
ArrayDeque<HtmlTagEntry> outerCloseTagQueue = new ArrayDeque<>();
outerOpenTagStack.addAll(openTagStack);
outerCloseTagQueue.addAll(closeTagQueue);
openTagStack.clear();
closeTagQueue.clear();
boolean inForeignContentBeforeBlock = inForeignContent;
visitChildren(node);
// After we visit all children, we check if deques are empty or not.
if (inControlBlock) {
// If we are in a control block, we add non-empty deques to the branches.
if (!openTagStack.isEmpty()) {
openTagBranches.add(currentCondition, openTagStack);
}
if (!closeTagQueue.isEmpty()) {
closeTagBranches.add(currentCondition, closeTagQueue);
}
} else {
// If we are not in a control block, we try to match deques and report an error if we find
// nodes that do not match.
HtmlTagEntry.matchOrError(openTagStack, closeTagQueue, errorReporter);
}
// No matter what happened in this block, clear everything and continue.
openTagStack.clear();
closeTagQueue.clear();
// Restore the deques.
openTagStack.addAll(outerOpenTagStack);
closeTagQueue.addAll(outerCloseTagQueue);
// If inForeignContent has been changed after visiting a block, it means there is a svg tag
// that has not been closed.
if (inForeignContent != inForeignContentBeforeBlock) {
errorReporter.report(
inForeignContent ? foreignContentStartLocation : foreignContentEndLocation,
SWITCH_HTML_MODE_IN_BLOCK);
}
// Switch back to the original html mode.
inForeignContent = inForeignContentBeforeBlock;
}
}
}