Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public class DocumentParser implements ParserState {
private final List<LinkProcessor> linkProcessors;
private final Set<Character> linkMarkers;
private final IncludeSourceSpans includeSourceSpans;
private final int maxOpenBlockParsers;
private final DocumentBlockParser documentBlockParser;
private final Definitions definitions = new Definitions();

Expand All @@ -84,14 +85,16 @@ public class DocumentParser implements ParserState {

public DocumentParser(List<BlockParserFactory> blockParserFactories, InlineParserFactory inlineParserFactory,
List<InlineContentParserFactory> inlineContentParserFactories, List<DelimiterProcessor> delimiterProcessors,
List<LinkProcessor> linkProcessors, Set<Character> linkMarkers, IncludeSourceSpans includeSourceSpans) {
List<LinkProcessor> linkProcessors, Set<Character> linkMarkers,
IncludeSourceSpans includeSourceSpans, int maxOpenBlockParsers) {
this.blockParserFactories = blockParserFactories;
this.inlineParserFactory = inlineParserFactory;
this.inlineContentParserFactories = inlineContentParserFactories;
this.delimiterProcessors = delimiterProcessors;
this.linkProcessors = linkProcessors;
this.linkMarkers = linkMarkers;
this.includeSourceSpans = includeSourceSpans;
this.maxOpenBlockParsers = maxOpenBlockParsers;

this.documentBlockParser = new DocumentBlockParser();
activateBlockParser(new OpenBlockParser(documentBlockParser, 0));
Expand Down Expand Up @@ -461,6 +464,9 @@ private void addSourceSpans() {
}

private BlockStartImpl findBlockStart(BlockParser blockParser) {
if (openBlockParsers.size() > maxOpenBlockParsers) {
return null;
}
MatchedBlockParser matchedBlockParser = new MatchedBlockParserImpl(blockParser);
for (BlockParserFactory blockParserFactory : blockParserFactories) {
BlockStart result = blockParserFactory.tryStart(this, matchedBlockParser);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@
* call {@link #visitChildren}.
*/
public abstract class AbstractVisitor implements Visitor {
private final int maxDepth;
private int currentDepth;

public AbstractVisitor() {
this(Integer.MAX_VALUE);
}

protected AbstractVisitor(int maxDepth) {
if (maxDepth < 0) {
throw new IllegalArgumentException("maxDepth must be >= 0");
}
this.maxDepth = maxDepth;
}

@Override
public void visit(BlockQuote blockQuote) {
Expand Down Expand Up @@ -129,12 +142,20 @@ public void visit(CustomNode customNode) {
* @param parent the parent node whose children should be visited
*/
protected void visitChildren(Node parent) {
if (currentDepth >= maxDepth) {
return;
}
Node node = parent.getFirstChild();
while (node != null) {
// A subclass of this visitor might modify the node, resulting in getNext returning a different node or no
// node after visiting it. So get the next node before visiting.
Node next = node.getNext();
node.accept(this);
currentDepth++;
try {
node.accept(this);
} finally {
currentDepth--;
}
node = next;
}
}
Expand Down
26 changes: 25 additions & 1 deletion commonmark/src/main/java/org/commonmark/parser/Parser.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class Parser {
private final InlineParserFactory inlineParserFactory;
private final List<PostProcessor> postProcessors;
private final IncludeSourceSpans includeSourceSpans;
private final int maxOpenBlockParsers;

private Parser(Builder builder) {
this.blockParserFactories = DocumentParser.calculateBlockParserFactories(builder.blockParserFactories, builder.enabledBlockTypes);
Expand All @@ -47,6 +48,7 @@ private Parser(Builder builder) {
this.linkProcessors = builder.linkProcessors;
this.linkMarkers = builder.linkMarkers;
this.includeSourceSpans = builder.includeSourceSpans;
this.maxOpenBlockParsers = builder.maxOpenBlockParsers;

// Try to construct an inline parser. Invalid configuration might result in an exception, which we want to
// detect as soon as possible.
Expand Down Expand Up @@ -106,7 +108,7 @@ public Node parseReader(Reader input) throws IOException {

private DocumentParser createDocumentParser() {
return new DocumentParser(blockParserFactories, inlineParserFactory, inlineContentParserFactories,
delimiterProcessors, linkProcessors, linkMarkers, includeSourceSpans);
delimiterProcessors, linkProcessors, linkMarkers, includeSourceSpans, maxOpenBlockParsers);
}

private Node postProcess(Node document) {
Expand All @@ -129,6 +131,7 @@ public static class Builder {
private Set<Class<? extends Block>> enabledBlockTypes = DocumentParser.getDefaultBlockParserTypes();
private InlineParserFactory inlineParserFactory;
private IncludeSourceSpans includeSourceSpans = IncludeSourceSpans.NONE;
private int maxOpenBlockParsers = Integer.MAX_VALUE;

/**
* @return the configured {@link Parser}
Expand Down Expand Up @@ -200,6 +203,27 @@ public Builder includeSourceSpans(IncludeSourceSpans includeSourceSpans) {
return this;
}

/**
* Limit how many non-document block parsers may be open at once while parsing.
* <p>
* Once the limit is reached, additional block starts are treated as plain text instead of
* creating deeper nested block structure.
* <p>
* The document root parser is not counted. The default is unlimited, so callers that keep
* using {@code Parser.builder().build()} preserve behavior.
*
* @param maxOpenBlockParsers maximum number of open non-document block parsers, must be
* zero or greater
* @return {@code this}
*/
public Builder maxOpenBlockParsers(int maxOpenBlockParsers) {
if (maxOpenBlockParsers < 0) {
throw new IllegalArgumentException("maxOpenBlockParsers must be >= 0");
}
this.maxOpenBlockParsers = maxOpenBlockParsers;
return this;
}

/**
* Add a custom block parser factory.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,38 @@
import org.commonmark.node.*;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class AbstractVisitorTest {
@Test
public void maxDepthMustBeZeroOrGreater() {
assertThatThrownBy(() -> new RecordingVisitor(-1))
.isInstanceOf(IllegalArgumentException.class);
}

@Test
public void maxDepthZeroVisitsOnlyRoot() {
var paragraph = paragraphTree();
var visitor = new RecordingVisitor(0);

paragraph.accept(visitor);

assertThat(visitor.visited).containsExactly("paragraph");
}

@Test
public void maxDepthOneVisitsDirectChildrenButNotGrandchildren() {
var paragraph = paragraphTree();
var visitor = new RecordingVisitor(1);

paragraph.accept(visitor);

assertThat(visitor.visited).containsExactly("paragraph", "emphasis", "text:tail");
}

@Test
public void replacingNodeInVisitorShouldNotDestroyVisitOrder() {
Expand Down Expand Up @@ -34,4 +63,39 @@ private static void assertCode(String expectedLiteral, Node node) {
Code code = (Code) node;
assertThat(code.getLiteral()).isEqualTo(expectedLiteral);
}

private static Paragraph paragraphTree() {
var paragraph = new Paragraph();
var emphasis = new Emphasis();
emphasis.appendChild(new Text("nested"));
paragraph.appendChild(emphasis);
paragraph.appendChild(new Text("tail"));
return paragraph;
}

private static final class RecordingVisitor extends AbstractVisitor {
private final List<String> visited = new ArrayList<>();

private RecordingVisitor(int maxDepth) {
super(maxDepth);
}

@Override
public void visit(Paragraph paragraph) {
visited.add("paragraph");
super.visit(paragraph);
}

@Override
public void visit(Emphasis emphasis) {
visited.add("emphasis");
super.visit(emphasis);
}

@Override
public void visit(Text text) {
visited.add("text:" + text.getLiteral());
super.visit(text);
}
}
}
102 changes: 102 additions & 0 deletions commonmark/src/test/java/org/commonmark/test/ParserTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.commonmark.parser.*;
import org.commonmark.parser.block.*;
import org.commonmark.renderer.html.HtmlRenderer;
import org.commonmark.renderer.text.TextContentRenderer;
import org.commonmark.testutil.TestResources;
import org.junit.jupiter.api.Test;

Expand Down Expand Up @@ -135,11 +136,112 @@ public void threading() throws Exception {
}
}

@Test
public void maxOpenBlockParsersMustBeZeroOrGreater() {
assertThatThrownBy(() ->
Parser.builder().maxOpenBlockParsers(-1)).isInstanceOf(IllegalArgumentException.class);
}

@Test
public void maxOpenBlockParsersIsOptIn() {
var parser = Parser.builder().build();

var document = parser.parse(alternatingNestedList(9));

assertThat(renderText(deepestStructuredParagraph(document, 9))).isEqualTo("level9");
}

@Test
public void maxOpenBlockParsersPreservesSevenLogicalListLevelsAtSeventeenBlocks() {
var parser = Parser.builder().maxOpenBlockParsers(17).build();

var document = parser.parse(alternatingNestedList(7));

assertThat(renderText(deepestStructuredParagraph(document, 7))).isEqualTo("level7");
}

@Test
public void maxOpenBlockParsersPreservesEightLogicalListLevelsAtSeventeenBlocks() {
var parser = Parser.builder().maxOpenBlockParsers(17).build();

var document = parser.parse(alternatingNestedList(8));

assertThat(renderText(deepestStructuredParagraph(document, 8))).isEqualTo("level8");
}

@Test
public void maxOpenBlockParsersDegradesTheNinthLogicalListLevelToPlainText() {
var parser = Parser.builder().maxOpenBlockParsers(17).build();

var document = parser.parse(alternatingNestedList(9));
var deepestParagraph = deepestStructuredParagraph(document, 8);

assertThat(renderText(deepestParagraph)).isEqualTo("level8\n- level9");
assertThat(deepestParagraph.getNext()).isNull();
}

@Test
public void maxOpenBlockParsersAlsoLimitsMixedListAndBlockQuoteNesting() {
var parser = Parser.builder().maxOpenBlockParsers(5).build();

var document = parser.parse(String.join("\n",
"- level1",
" > level2",
" > > level3",
" > > > level4"));

var listBlock = document.getFirstChild();
assertThat(listBlock).isInstanceOf(BulletList.class);

var listItem = listBlock.getFirstChild();
var blockQuote1 = listItem.getLastChild();
assertThat(blockQuote1).isInstanceOf(BlockQuote.class);

var blockQuote2 = blockQuote1.getLastChild();
assertThat(blockQuote2).isInstanceOf(BlockQuote.class);

var deepestParagraph = blockQuote2.getLastChild();
assertThat(deepestParagraph).isInstanceOf(Paragraph.class);
assertThat(renderText(deepestParagraph)).isEqualTo("level3\n> level4");
assertThat(deepestParagraph.getNext()).isNull();
}

private String firstText(Node n) {
while (!(n instanceof Text)) {
assertThat(n).isNotNull();
n = n.getFirstChild();
}
return ((Text) n).getLiteral();
}

private Paragraph deepestStructuredParagraph(Node document, int levels) {
Node node = document.getFirstChild();
for (int level = 1; level <= levels; level++) {
assertThat(node).isInstanceOf(ListBlock.class);
var listItem = node.getFirstChild();
assertThat(listItem).isNotNull();
if (level == levels) {
assertThat(listItem.getFirstChild()).isInstanceOf(Paragraph.class);
return (Paragraph) listItem.getFirstChild();
}
node = listItem.getLastChild();
}
throw new AssertionError("unreachable");
}

private String renderText(Node node) {
return TextContentRenderer.builder().build().render(node).trim();
}

private String alternatingNestedList(int levels) {
int indent = 0;
var lines = new ArrayList<String>();
for (int level = 1; level <= levels; level++) {
var ordered = level % 2 == 0;
var marker = ordered ? "1. " : "- ";
lines.add(" ".repeat(indent) + marker + "level" + level);
indent += marker.length();
}
return String.join("\n", lines);
}
}
Loading