/* * Copyright 2013 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 com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.base.Predicates; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.template.soy.soytree.RawTextNode; import java.util.Collections; import java.util.List; import javax.annotation.Nullable; /** * A raw text node divided into a slice for each context found by the inference engine so that later * parse passes can take action based on text and attribute boundaries. * */ public final class SlicedRawTextNode { /** * A substring of raw text that is exposed to parse passes. * * <p>This slice is not the entire substring with the same context. Such a thing cannot be * statically determined since in a portion of a template that stays in the same context like * * <pre> * foo {if $cond}bar{else}baz{/if} boo * </pre> * * there might be two possible strings with the same context: {@code "foo bar boo"} and {@code * "foo baz boo"}. * * <p> */ public static final class RawTextSlice { /** The context of the slice. */ public final Context context; /** The text node containing the slice. */ public final SlicedRawTextNode slicedRawTextNode; /** The start offset (inclusive) into the text node's text. */ private int startOffset; /** The end offset (exclusive) into the text node's text. */ private int endOffset; RawTextSlice( Context context, SlicedRawTextNode slicedRawTextNode, int startOffset, int endOffset) { this.context = context; this.slicedRawTextNode = slicedRawTextNode; this.startOffset = startOffset; this.endOffset = endOffset; } /** The start offset (inclusive) into the text node's text. */ public int getStartOffset() { return startOffset; } /** The length of the slice in {@code char}s. */ public int getLength() { return endOffset - startOffset; } public SlicedRawTextNode getSlicedRawTextNode() { return slicedRawTextNode; } /** * Splits this slice in two at the given offset and returns the slice after the split. * * @param offset into the slice. */ private RawTextSlice split(int offset) { int indexInParent = slicedRawTextNode.slices.indexOf(this); if (indexInParent < 0) { throw new AssertionError("slice is not in its parent"); } Preconditions.checkElementIndex(offset, getLength(), "slice offset"); RawTextSlice secondSlice = slicedRawTextNode.insertSlice(indexInParent + 1, context, 0); int wholeTextOffset = offset + startOffset; secondSlice.startOffset = wholeTextOffset; this.endOffset = wholeTextOffset; return secondSlice; } /** * Mutates the parse tree by replacing the sliced text node with a text node that includes the * given text at the given point within this slice. * * @param text A string in context context. * @param offset An offset between 0 (inclusive) and {@link #getLength()} (exclusive). * @param context the context of text. * @throws SoyAutoescapeException if inserting text would violate context assumptions made by * the contextual autoescaper. */ public void insertText(int offset, String text) throws SoyAutoescapeException { int indexInParent = slicedRawTextNode.slices.indexOf(this); if (indexInParent < 0) { throw new AssertionError("slice is not in its parent"); } Preconditions.checkElementIndex(offset, getLength(), "slice offset"); // Figure out at which character offset to insert text. int insertionIndex = -1; int insertionOffset = -1; // Split or recurse as necessary so that we are called at the boundary of a slice. if (offset == 0) { insertionIndex = indexInParent; insertionOffset = startOffset; } else if (offset == getLength()) { insertionIndex = indexInParent; insertionOffset = endOffset; } else { split(offset).insertText(0, text); return; } // Compute the new raw text and create a node to hold it. // We re-use the node ID since we're going to remove the old node and discard it. RawTextNode rawTextNode = slicedRawTextNode.getRawTextNode(); String originalText = rawTextNode.getRawText(); String replacementText = originalText.substring(0, insertionOffset) + text + originalText.substring(insertionOffset); RawTextNode replacementNode = new RawTextNode(rawTextNode.getId(), replacementText, rawTextNode.getSourceLocation()); // Rerun the context update algo so that we can figure out the context of the inserted slices // and ensure that the inserted text does not invalidate any of the security assumptions made // by the auto-escaper. Context startContext = slicedRawTextNode.startContext; Context expectedEndContext = slicedRawTextNode.endContext; SlicedRawTextNode retyped = RawTextContextUpdater.processRawText(replacementNode, startContext); Context actualEndContext = retyped.getEndContext(); if (!expectedEndContext.equals(actualEndContext)) { // Inserting the text would invalidate typing assumptions made earlier. throw SoyAutoescapeException.createWithNode( "Inserting `" + text + "` would cause text node to end in context " + actualEndContext + " instead of " + expectedEndContext, rawTextNode); } // Now that we know that it's valid to insert the text at that location, replace the text node // and insert slices for each of the slices in builder corresponding to characters in text. slicedRawTextNode.replaceNode(replacementNode); int insertionEndOffset = insertionOffset + text.length(); for (RawTextSlice slice : retyped.slices) { if (slice.endOffset <= insertionOffset) { continue; } if (slice.startOffset >= insertionEndOffset) { break; } int length = Math.min(insertionEndOffset, slice.endOffset) - Math.max(insertionOffset, slice.startOffset); slicedRawTextNode.insertSlice(insertionIndex, slice.context, length); // Increment the insertion index to point past the slice just inserted so that // we're ready for the next one. ++insertionIndex; } } /** The raw text of the slice. */ public String getRawText() { return slicedRawTextNode.rawTextNode.getRawText().substring(startOffset, endOffset); } /** Adjusts the start and end offsets right by the given amount. */ void shiftOffsets(int delta) { startOffset += delta; endOffset += delta; } /** For debugging. */ @Override public String toString() { String rawText = getRawText(); int id = slicedRawTextNode.rawTextNode.getId(); // "<rawText>"@<textNodeId> with \ and " escaped. return "\"" + rawText.replaceAll("\"|\\\\", "\\\\$0") + "\"#" + id; } } /** The backing raw text node. */ private RawTextNode rawTextNode; /** The context in which the text node starts. */ private final Context startContext; /** The context in which the text node ends. */ private Context endContext; /** A collection of all the slices of a particular raw text node in order. */ private final List<RawTextSlice> slices = Lists.newArrayList(); public SlicedRawTextNode(RawTextNode rawTextNode, Context startContext) { this.rawTextNode = rawTextNode; this.startContext = startContext; } public RawTextNode getRawTextNode() { return rawTextNode; } public List<RawTextSlice> getSlices() { return Collections.unmodifiableList(slices); } /** The context in which the text node ends. */ public Context getEndContext() { return endContext; } void setEndContext(Context endContext) { this.endContext = endContext; } /** * Called by the builder to add slices as their context becomes known. * * @param startOffset an offset (inclusive) into the rawTextNode's string content. * @param endOffset an offset (exclusive) into the rawTextNode's string content. * @param context the context for the slice. */ void addSlice(int startOffset, int endOffset, Context context) { int lastSliceIndex = slices.size() - 1; // Merge adjacent tokens that don't change context. if (lastSliceIndex >= 0) { RawTextSlice last = slices.get(lastSliceIndex); if (last.endOffset == startOffset && context.equals(last.context)) { slices.remove(lastSliceIndex); startOffset = last.startOffset; } } slices.add(new RawTextSlice(context, this, startOffset, endOffset)); } /** Replaces the backing node in the parse tree and internally. */ void replaceNode(RawTextNode replacement) { rawTextNode.getParent().replaceChild(rawTextNode, replacement); this.rawTextNode = replacement; } /** * Inserts a slice, updating the offsets of any following slices and returns the newly created * slice. */ RawTextSlice insertSlice(int index, Context context, int length) { if (length < 0) { throw new IllegalArgumentException("length " + length + " < 0"); } int startOffset = index == 0 ? 0 : slices.get(index - 1).endOffset; for (RawTextSlice follower : slices.subList(index, slices.size())) { follower.shiftOffsets(length); } RawTextSlice slice = new RawTextSlice(context, this, startOffset, startOffset + length); slices.add(index, slice); return slice; } @VisibleForTesting void mergeAdjacentSlicesWithSameContext() { // Rewrite list from left to right merging adjacent slices with the same context. int nMerged = 0; for (int i = 0, n = slices.size(), next; i < n; i = next, ++nMerged) { next = i + 1; RawTextSlice slice = slices.get(i); // Walk next forward until we see a different context. while (next < n && slice.context.equals(slices.get(next).context)) { ++next; } // Modify slices in place to have exactly one slice corresponding to [i, next). RawTextSlice merged; if (next - i == 1) { // If there haven't been modifications since the last merge, don't orphan slices. merged = slice; } else { merged = new RawTextSlice( slice.context, this, slice.startOffset, slices.get(next - 1).endOffset); } slices.set(nMerged, merged); } // Truncate. slices.subList(nMerged, slices.size()).clear(); } /** * The slices that occur in the context described by the given predicates. * * <p>The order is deterministic but does not necessarily bear any relationship to the order in * which slices can appear in the template's output because it is dependent on the ordering of * individual templates in the parsed input. * * @param slicedRawTextNodes The sliced raw text nodes to search. * @param prevContextPredicate Applied to the context before the slice being tested. * @param sliceContextPredicate Applied to the context of the slice being tested. * @param nextContextPredicate Applied to the context after the slice being tested. * @return a list of slices such that input predicates are all true when applied to the contexts * at and around that slice. */ public static List<RawTextSlice> find( Iterable<? extends SlicedRawTextNode> slicedTextNodes, @Nullable Predicate<? super Context> prevContextPredicate, @Nullable Predicate<? super Context> sliceContextPredicate, @Nullable Predicate<? super Context> nextContextPredicate) { if (prevContextPredicate == null) { prevContextPredicate = Predicates.<Context>alwaysTrue(); } if (sliceContextPredicate == null) { sliceContextPredicate = Predicates.<Context>alwaysTrue(); } if (nextContextPredicate == null) { nextContextPredicate = Predicates.<Context>alwaysTrue(); } ImmutableList.Builder<RawTextSlice> matches = ImmutableList.builder(); for (SlicedRawTextNode slicedTextNode : slicedTextNodes) { // insertText can leave adjacent slices with the same context. // Merge slices so that each element in find()'s result list stands alone. // This could cause problems with concurrent iteration over two find lists, but the mutators // check that a slice is part of its parent so we will fail fast. slicedTextNode.mergeAdjacentSlicesWithSameContext(); Context prevContext = slicedTextNode.startContext; List<RawTextSlice> slices = slicedTextNode.slices; for (int i = 0, n = slices.size(); i < n; ++i) { RawTextSlice current = slices.get(i); Context nextContext; if (i + 1 < n) { nextContext = slices.get(i + 1).context; } else { nextContext = slicedTextNode.endContext; } // Apply the predicates. if (prevContextPredicate.apply(prevContext) && sliceContextPredicate.apply(current.context) && nextContextPredicate.apply(nextContext)) { matches.add(current); } prevContext = current.context; } } return matches.build(); } }