diff --git a/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java b/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java index 38d588750..2e9aa1473 100644 --- a/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/ast/builder/AstBuilder.java @@ -299,6 +299,7 @@ public class AstBuilder extends AbstractAstBuilder { private final ExternalMemberRegistry externalMemberRegistry; private final SymbolTable symbolTable; private final boolean isMethodReturnTypeChecked; + private @Nullable Node lastVisited; public AstBuilder( Source source, VmLanguage language, ModuleInfo moduleInfo, ModuleResolver moduleResolver) { @@ -2327,13 +2328,20 @@ public class AstBuilder extends AbstractAstBuilder { } public UnresolvedTypeNode visitType(Type type) { + lastVisited = type; return (UnresolvedTypeNode) type.accept(this); } public ExpressionNode visitExpr(Expr expr) { + lastVisited = expr; return (ExpressionNode) expr.accept(this); } + /** Returns the source section of the last type or expression that was entered. */ + public @Nullable SourceSection getLastVisitedSection() { + return lastVisited == null ? null : createSourceSection(lastVisited); + } + public TypeParameter[] visitTypeParameterList( @Nullable TypeParameterList ctx, FrameSlotVariable @Nullable [] typeBindings) { if (ctx == null) return new TypeParameter[0]; diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/VmLanguage.java b/pkl-core/src/main/java/org/pkl/core/runtime/VmLanguage.java index 961026a9a..2b34d8426 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/VmLanguage.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/VmLanguage.java @@ -23,6 +23,7 @@ import com.oracle.truffle.api.instrumentation.ProvidedTags; import com.oracle.truffle.api.nodes.Node; import com.oracle.truffle.api.source.Source; import org.jspecify.annotations.Nullable; +import org.pkl.core.ast.PklRootNode; import org.pkl.core.ast.builder.AstBuilder; import org.pkl.core.module.ModuleKey; import org.pkl.core.module.ResolvedModuleKey; @@ -107,10 +108,21 @@ public final class VmLanguage extends TruffleLanguage { throw VmUtils.toVmException(e, source, moduleName); } + PklRootNode moduleNode; var builder = AstBuilder.create( source, this, moduleContext, moduleKey, resolvedModuleKey, moduleResolver); - var moduleNode = builder.visitModule(moduleContext); + try { + moduleNode = builder.visitModule(moduleContext); + } catch (StackOverflowError e) { + // the syntax tree is too deep for the AST builder + var section = builder.getLastVisitedSection(); + throw new VmExceptionBuilder() + .evalError("moduleTooDeeplyNested") + .withSourceSection(section != null ? section : source.createSection(1)) + .withMemberName(IoUtils.inferModuleName(moduleKey)) + .build(); + } moduleNode.getCallTarget().call(emptyModule, emptyModule); MinPklVersionChecker.check(emptyModule, importNode); } diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index 505141297..eda95bd55 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -846,6 +846,9 @@ Microbenchmark is a constant expression. stackOverflow=\ A stack overflow occurred. +moduleTooDeeplyNested=\ +Code is nested too deeply, or contains too many chained operations. + cannotHaveCircularProjectDependenciesSingle=\ Local project dependencies cannot be circular.\n\ \n\ diff --git a/pkl-core/src/test/kotlin/org/pkl/core/parser/EvaluatorDepthTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/parser/EvaluatorDepthTest.kt new file mode 100644 index 000000000..8c2f8e73f --- /dev/null +++ b/pkl-core/src/test/kotlin/org/pkl/core/parser/EvaluatorDepthTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * 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 + * + * https://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 org.pkl.core.parser + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.TestFactory +import org.pkl.core.Evaluator +import org.pkl.core.ModuleSource +import org.pkl.core.PklException + +/** Maliciously deep modules must fail with a [PklException] rather than a [StackOverflowError]. */ +class EvaluatorDepthTest { + companion object { + private const val STACK_SIZE = 512L * 1024 + private const val HUGE = 100_000 + + private val nesting: Map String> = + mapOf( + "parens" to { n -> "x = " + "(".repeat(n) + "1" + ")".repeat(n) }, + "if" to { n -> "x = " + "if (true) 1 else ".repeat(n) + "2" }, + "objects" to { n -> "x " + "{ y ".repeat(n) + "{}" + " }".repeat(n) }, + ) + + private val chains: Map String> = + mapOf( + "binary operator" to { n -> "x = " + "1 + ".repeat(n) + "1" }, + "qualified access" to { n -> "x = new Listing {}" + ".toList()".repeat(n) }, + "subscript" to { n -> "x = a" + "[1]".repeat(n) }, + "non-null" to { n -> "x = a" + "!!".repeat(n) }, + "constrained type" to { n -> "x: Int" + "(isPositive)".repeat(n) + " = 1" }, + ) + + private fun runOnSmallStack(action: () -> Unit): Throwable? { + var thrown: Throwable? = null + val thread = + Thread(null, { thrown = runCatching(action).exceptionOrNull() }, "test", STACK_SIZE) + thread.start() + thread.join() + return thrown + } + + private fun evaluate(source: String) { + Evaluator.preconfigured().use { it.evaluate(ModuleSource.text(source)) } + } + } + + @TestFactory + fun `deep nesting fails with a PklException`(): List = nesting.map { (shape, gen) -> + dynamicTest(shape) { + val thrown = runOnSmallStack { evaluate(gen(HUGE)) } + assertThat(thrown).isInstanceOf(PklException::class.java) + assertThat(thrown).hasMessageContaining("nested too deeply") + } + } + + @TestFactory + fun `long chains do not overflow the stack`(): List = chains.map { (shape, gen) -> + dynamicTest(shape) { + val thrown = runOnSmallStack { evaluate(gen(HUGE)) } + assertThat(thrown).isInstanceOf(PklException::class.java) + assertThat(thrown).hasMessageContaining("nested too deeply") + } + } +} diff --git a/pkl-formatter/src/main/java/org/pkl/formatter/Formatter.java b/pkl-formatter/src/main/java/org/pkl/formatter/Formatter.java index 8f37f9d00..728334cc7 100644 --- a/pkl-formatter/src/main/java/org/pkl/formatter/Formatter.java +++ b/pkl-formatter/src/main/java/org/pkl/formatter/Formatter.java @@ -21,7 +21,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.pkl.parser.GenericParser; +import org.pkl.parser.GenericParserError; import org.pkl.parser.syntax.generic.Node; +import org.pkl.parser.util.ErrorMessages; /** * A formatter for Pkl files that applies canonical formatting rules. @@ -144,8 +146,15 @@ public class Formatter { } private void format(String input, Appendable output) { - var ast = new GenericParser().parseModule(input); - generate(new Builder(input, grammarVersion).format(ast), output); + format(input, new GenericParser().parseModule(input), output); + } + + void format(String input, Node ast, Appendable output) { + try { + generate(new Builder(input, grammarVersion).format(ast), output); + } catch (StackOverflowError e) { + throw new GenericParserError(ErrorMessages.create("tooDeeplyNested"), ast.span); + } } private static void generate(FormatNode formatAst, Appendable output) { diff --git a/pkl-formatter/src/test/kotlin/org/pkl/formatter/FormatterDepthTest.kt b/pkl-formatter/src/test/kotlin/org/pkl/formatter/FormatterDepthTest.kt new file mode 100644 index 000000000..503099ae2 --- /dev/null +++ b/pkl-formatter/src/test/kotlin/org/pkl/formatter/FormatterDepthTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * 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 + * + * https://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 org.pkl.formatter + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.TestFactory +import org.pkl.parser.GenericParser +import org.pkl.parser.GenericParserError + +/** Maliciously deep input must not make the formatter throw a [StackOverflowError]. */ +class FormatterDepthTest { + companion object { + private const val STACK_SIZE = 512L * 1024 + private const val LARGE_STACK_SIZE = 512L * 1024 * 1024 + private const val NESTING_DEPTH = 10_000 + private const val CHAIN_LENGTH = 100_000 + + private val nesting: Map String> = + mapOf( + "parens" to { n -> "x = " + "(".repeat(n) + "1" + ")".repeat(n) }, + "types" to { n -> "x: " + "List<".repeat(n) + "Int" + ">".repeat(n) }, + "objects" to { n -> "x " + "{ y ".repeat(n) + "{}" + " }".repeat(n) }, + ) + + private val chains: Map String> = + mapOf( + "binary operator" to { n -> "x = " + "1 + ".repeat(n) + "1" }, + "qualified access" to { n -> "x = a" + ".b".repeat(n) }, + "subscript" to { n -> "x = a" + "[1]".repeat(n) }, + "constrained type" to { n -> "x: Int" + "(isPositive)".repeat(n) }, + ) + + private fun runOn(stackSize: Long, action: () -> T): Result { + var result: Result? = null + val thread = Thread(null, { result = runCatching(action) }, "test", stackSize) + thread.start() + thread.join() + return result!! + } + } + + @TestFactory + fun `deep nesting fails with a formatter error`(): List = + nesting.map { (shape, gen) -> + dynamicTest(shape) { assertFormatFails(gen(NESTING_DEPTH)) } + } + + @TestFactory + fun `long chains fail with a formatter error`(): List = chains.map { (shape, gen) -> + dynamicTest(shape) { assertFormatFails(gen(CHAIN_LENGTH)) } + } + + private fun assertFormatFails(source: String) { + val ast = runOn(LARGE_STACK_SIZE) { GenericParser().parseModule(source) }.getOrThrow() + val thrown = + runOn(STACK_SIZE) { Formatter().format(source, ast, StringBuilder()) }.exceptionOrNull() + assertThat(thrown).isInstanceOf(GenericParserError::class.java) + assertThat(thrown).hasMessageContaining("nested too deeply") + } +} diff --git a/pkl-parser/src/main/java/org/pkl/parser/GenericParser.java b/pkl-parser/src/main/java/org/pkl/parser/GenericParser.java index a770c6a47..b6bad75a7 100644 --- a/pkl-parser/src/main/java/org/pkl/parser/GenericParser.java +++ b/pkl-parser/src/main/java/org/pkl/parser/GenericParser.java @@ -19,7 +19,12 @@ import org.pkl.parser.syntax.generic.Node; public final class GenericParser { public Node parseModule(String source) { - return new GenericParserImpl(source).parseModule(); + var parser = new GenericParserImpl(source); + try { + return parser.parseModule(); + } catch (StackOverflowError e) { + throw parser.tooDeeplyNested(); + } } public Node parseExpressionInput(String source) { diff --git a/pkl-parser/src/main/java/org/pkl/parser/GenericParserImpl.java b/pkl-parser/src/main/java/org/pkl/parser/GenericParserImpl.java index 865b500ff..8718b86b5 100644 --- a/pkl-parser/src/main/java/org/pkl/parser/GenericParserImpl.java +++ b/pkl-parser/src/main/java/org/pkl/parser/GenericParserImpl.java @@ -944,7 +944,7 @@ class GenericParserImpl { @SuppressWarnings("DuplicatedCode") private Node parseExprRest(Node expr) { // amends - if (lookahead() == Token.LBRACE) { + while (lookahead() == Token.LBRACE) { var children = new ArrayList(); children.add(expr); ff(children); @@ -952,7 +952,8 @@ class GenericParserImpl { || expr.type == NodeType.AMENDS_EXPR || expr.type == NodeType.NEW_EXPR) { children.add(parseObjectBody()); - return parseExprRest(new Node(NodeType.AMENDS_EXPR, children)); + expr = new Node(NodeType.AMENDS_EXPR, children); + continue; } throw parserError("unexpectedCurlyProbablyAmendsExpression", expr.text(lexer.getSource())); } @@ -1251,31 +1252,33 @@ class GenericParserImpl { } private Node parseTypeEnd(Node type) { - var children = new ArrayList(); - children.add(type); - // nullable types - if (lookahead() == Token.QUESTION) { - ff(children); - children.add(makeTerminal(next())); - var res = new Node(NodeType.NULLABLE_TYPE, children); - return parseTypeEnd(res); + while (true) { + var children = new ArrayList(); + children.add(type); + // nullable types + if (lookahead() == Token.QUESTION) { + ff(children); + children.add(makeTerminal(next())); + type = new Node(NodeType.NULLABLE_TYPE, children); + continue; + } + // constrained types: have to start in the same line as the type + var fla = fullLookahead(); + if (fla.tk.token == Token.LPAREN && noSemicolonInbetween() && fla.tk.newLinesBetween == 0) { + ff(children); + var constraint = new ArrayList(); + constraint.add(makeTerminal(next())); + var elements = new ArrayList(); + ff(elements); + parseListOf(Token.RPAREN, elements, () -> parseExpr(")")); + constraint.add(new Node(NodeType.CONSTRAINED_TYPE_ELEMENTS, elements)); + expect(Token.RPAREN, constraint, "unexpectedToken2", ",", ")"); + children.add(new Node(NodeType.CONSTRAINED_TYPE_CONSTRAINT, constraint)); + type = new Node(NodeType.CONSTRAINED_TYPE, children); + continue; + } + return type; } - // constrained types: have to start in the same line as the type - var fla = fullLookahead(); - if (fla.tk.token == Token.LPAREN && noSemicolonInbetween() && fla.tk.newLinesBetween == 0) { - ff(children); - var constraint = new ArrayList(); - constraint.add(makeTerminal(next())); - var elements = new ArrayList(); - ff(elements); - parseListOf(Token.RPAREN, elements, () -> parseExpr(")")); - constraint.add(new Node(NodeType.CONSTRAINED_TYPE_ELEMENTS, elements)); - expect(Token.RPAREN, constraint, "unexpectedToken2", ",", ")"); - children.add(new Node(NodeType.CONSTRAINED_TYPE_CONSTRAINT, constraint)); - var res = new Node(NodeType.CONSTRAINED_TYPE, children); - return parseTypeEnd(res); - } - return type; } private Node parseAnnotation() { @@ -1470,6 +1473,10 @@ class GenericParserImpl { } } + GenericParserError tooDeeplyNested() { + return parserError("tooDeeplyNested"); + } + private GenericParserError parserError(String messageKey, Object... args) { return new GenericParserError(ErrorMessages.create(messageKey, args), spanLookahead); } diff --git a/pkl-parser/src/main/java/org/pkl/parser/Parser.java b/pkl-parser/src/main/java/org/pkl/parser/Parser.java index ff12f537b..edfa76203 100644 --- a/pkl-parser/src/main/java/org/pkl/parser/Parser.java +++ b/pkl-parser/src/main/java/org/pkl/parser/Parser.java @@ -23,14 +23,29 @@ public final class Parser { public Parser() {} public Module parseModule(String source) { - return new ParserImpl(source).parseModule(); + var parser = new ParserImpl(source); + try { + return parser.parseModule(); + } catch (StackOverflowError e) { + throw parser.tooDeeplyNested(); + } } public Expr parseExpressionInput(String source) { - return new ParserImpl(source).parseExpressionInput(); + var parser = new ParserImpl(source); + try { + return parser.parseExpressionInput(); + } catch (StackOverflowError e) { + throw parser.tooDeeplyNested(); + } } public ReplInput parseReplInput(String source) { - return new ParserImpl(source).parseReplInput(); + var parser = new ParserImpl(source); + try { + return parser.parseReplInput(); + } catch (StackOverflowError e) { + throw parser.tooDeeplyNested(); + } } } diff --git a/pkl-parser/src/main/java/org/pkl/parser/ParserImpl.java b/pkl-parser/src/main/java/org/pkl/parser/ParserImpl.java index c262deb13..614401503 100644 --- a/pkl-parser/src/main/java/org/pkl/parser/ParserImpl.java +++ b/pkl-parser/src/main/java/org/pkl/parser/ParserImpl.java @@ -1089,58 +1089,62 @@ final class ParserImpl { @SuppressWarnings("DuplicatedCode") private Expr parseExprRest(Expr expr) { - // non-null - if (lookahead == Token.NON_NULL) { - var end = next().span; - var res = new NonNullExpr(expr, expr.span().endWith(end)); - return parseExprRest(res); - } - // amends - if (lookahead == Token.LBRACE) { - if (expr instanceof ParenthesizedExpr - || expr instanceof AmendsExpr - || expr instanceof NewExpr) { - var body = parseObjectBody(); - return parseExprRest(new AmendsExpr(expr, body, expr.span().endWith(body.span()))); + while (true) { + // non-null + if (lookahead == Token.NON_NULL) { + var end = next().span; + expr = new NonNullExpr(expr, expr.span().endWith(end)); } - throw parserError("unexpectedCurlyProbablyAmendsExpression", expr.text(lexer.getSource())); - } - // qualified access - if (lookahead == Token.DOT || lookahead == Token.QDOT) { - var isNullable = next().token == Token.QDOT; - var identifier = parseIdentifier(); - TypeArgumentList typeArgumentList = null; - if (lookahead == Token.DCOLON && !precededBySemicolon && _lookahead.newLinesBetween == 0) { + // amends + else if (lookahead == Token.LBRACE) { + if (expr instanceof ParenthesizedExpr + || expr instanceof AmendsExpr + || expr instanceof NewExpr) { + var body = parseObjectBody(); + expr = new AmendsExpr(expr, body, expr.span().endWith(body.span())); + } else { + throw parserError( + "unexpectedCurlyProbablyAmendsExpression", expr.text(lexer.getSource())); + } + } + // qualified access + else if (lookahead == Token.DOT || lookahead == Token.QDOT) { + var isNullable = next().token == Token.QDOT; + var identifier = parseIdentifier(); + TypeArgumentList typeArgumentList = null; + if (lookahead == Token.DCOLON && !precededBySemicolon && _lookahead.newLinesBetween == 0) { + next(); + typeArgumentList = parseTypeArgumentList(); + } + ArgumentList argumentList = null; + if (lookahead == Token.LPAREN && !precededBySemicolon && _lookahead.newLinesBetween == 0) { + argumentList = parseArgumentList(); + } else if (typeArgumentList != null) { + // type args but no args + throw new ParserError(ErrorMessages.create("unexpectedToken", "("), spanLookahead); + } + var lastSpan = argumentList != null ? argumentList.span() : identifier.span(); + expr = + new QualifiedAccessExpr( + expr, + identifier, + isNullable, + typeArgumentList, + argumentList, + expr.span().endWith(lastSpan)); + } + // subscript (needs to be in the same line as the expression) + else if (lookahead == Token.LBRACK + && !precededBySemicolon + && _lookahead.newLinesBetween == 0) { next(); - typeArgumentList = parseTypeArgumentList(); + var exp = parseExpr("]"); + var end = expect(Token.RBRACK, "unexpectedToken", "]").span; + expr = new SubscriptExpr(expr, exp, expr.span().endWith(end)); + } else { + return expr; } - ArgumentList argumentList = null; - if (lookahead == Token.LPAREN && !precededBySemicolon && _lookahead.newLinesBetween == 0) { - argumentList = parseArgumentList(); - } else if (typeArgumentList != null) { - // type args but no args - throw new ParserError(ErrorMessages.create("unexpectedToken", "("), spanLookahead); - } - var lastSpan = argumentList != null ? argumentList.span() : identifier.span(); - var res = - new QualifiedAccessExpr( - expr, - identifier, - isNullable, - typeArgumentList, - argumentList, - expr.span().endWith(lastSpan)); - return parseExprRest(res); } - // subscript (needs to be in the same line as the expression) - if (lookahead == Token.LBRACK && !precededBySemicolon && _lookahead.newLinesBetween == 0) { - next(); - var exp = parseExpr("]"); - var end = expect(Token.RBRACK, "unexpectedToken", "]").span; - var res = new SubscriptExpr(expr, exp, expr.span().endWith(end)); - return parseExprRest(res); - } - return expr; } private Expr parseSingleLineStringLiteralExpr() { @@ -1493,25 +1497,28 @@ final class ParserImpl { } private Type parseTypeEnd(Type type) { - // nullable types - if (lookahead == Token.QUESTION) { - var end = spanLookahead; - next(); - var res = new Type.NullableType(type, type.span().endWith(end)); - return parseTypeEnd(res); + while (true) { + // nullable types + if (lookahead == Token.QUESTION) { + var end = spanLookahead; + next(); + type = new Type.NullableType(type, type.span().endWith(end)); + } + // constrained types: have to start in the same line as the type + else if (lookahead == Token.LPAREN + && !precededBySemicolon + && _lookahead.newLinesBetween == 0) { + next(); + var constraints = parseListOf(Token.COMMA, Token.RPAREN, () -> parseExpr(")")); + var end = expect(Token.RPAREN, "unexpectedToken2", ",", ")").span; + var children = new ArrayList(constraints.size() + 1); + children.add(type); + children.addAll(constraints); + type = new Type.ConstrainedType(children, type.span().endWith(end)); + } else { + return type; + } } - // constrained types: have to start in the same line as the type - if (lookahead == Token.LPAREN && !precededBySemicolon && _lookahead.newLinesBetween == 0) { - next(); - var constraints = parseListOf(Token.COMMA, Token.RPAREN, () -> parseExpr(")")); - var end = expect(Token.RPAREN, "unexpectedToken2", ",", ")").span; - var children = new ArrayList(constraints.size() + 1); - children.add(type); - children.addAll(constraints); - var res = new Type.ConstrainedType(children, type.span().endWith(end)); - return parseTypeEnd(res); - } - return type; } private Annotation parseAnnotation() { @@ -1829,6 +1836,10 @@ final class ParserImpl { return res; } + ParserError tooDeeplyNested() { + return parserError("tooDeeplyNested"); + } + private ParserError parserError(String messageKey, Object... args) { return new ParserError(ErrorMessages.create(messageKey, args), spanLookahead); } diff --git a/pkl-parser/src/main/resources/org/pkl/parser/errorMessages.properties b/pkl-parser/src/main/resources/org/pkl/parser/errorMessages.properties index 3f0997f25..254c94ab0 100644 --- a/pkl-parser/src/main/resources/org/pkl/parser/errorMessages.properties +++ b/pkl-parser/src/main/resources/org/pkl/parser/errorMessages.properties @@ -19,6 +19,9 @@ Unexpected token `{0}`. Expected a type. unexpectedTokenForType2=\ Unexpected token `{0}`. Expected a type or `{1}`. +tooDeeplyNested=\ +Code is nested too deeply, or contains too many chained operations. + unexpectedCurlyProbablyAmendsExpression=\ Unexpected token: `'{'`.\n\ \n\ diff --git a/pkl-parser/src/test/kotlin/org/pkl/parser/ParserDepthTest.kt b/pkl-parser/src/test/kotlin/org/pkl/parser/ParserDepthTest.kt new file mode 100644 index 000000000..32ac040f4 --- /dev/null +++ b/pkl-parser/src/test/kotlin/org/pkl/parser/ParserDepthTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * 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 + * + * https://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 org.pkl.parser + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.TestFactory + +/** + * Malicious input must produce a regular parse error instead of a [StackOverflowError], even on a + * small thread stack. + */ +class ParserDepthTest { + companion object { + private const val STACK_SIZE = 512L * 1024 + private const val HUGE = 100_000 + + private val nesting: Map String> = + mapOf( + "parens" to { n -> "x = " + "(".repeat(n) + "1" + ")".repeat(n) }, + "unary minus" to { n -> "x = " + "-".repeat(n) + "1" }, + "types" to { n -> "x: " + "List<".repeat(n) + "Int" + ">".repeat(n) }, + "parenthesized types" to { n -> "x: " + "(".repeat(n) + "Int" + ")".repeat(n) }, + "objects" to { n -> "x " + "{ y ".repeat(n) + "{}" + " }".repeat(n) }, + "string interpolation" to { n -> "x = " + "\"\\(".repeat(n) + "1" + ")\"".repeat(n) }, + "if" to { n -> "x = " + "if (true) 1 else ".repeat(n) + "2" }, + "let" to { n -> "x = " + "let (a = 1) ".repeat(n) + "2" }, + "function literal" to { n -> "x = " + "(a) -> ".repeat(n) + "2" }, + "right associative operator" to { n -> "x = " + "a ?? ".repeat(n) + "b" }, + "pow" to { n -> "x = " + "a ** ".repeat(n) + "b" }, + ) + + // shapes that build deep, left-nested trees without recursing in the parser + private val chains: Map String> = + mapOf( + "binary operator" to { n -> "x = " + "1 + ".repeat(n) + "1" }, + "qualified access" to { n -> "x = a" + ".b".repeat(n) }, + "subscript" to { n -> "x = a" + "[1]".repeat(n) }, + "non-null" to { n -> "x = a" + "!!".repeat(n) }, + "constrained type" to { n -> "x: Int" + "(isPositive)".repeat(n) }, + "amends" to { n -> "x = (y) " + "{} ".repeat(n) }, + ) + + private fun parsers(): Map Unit> = + mapOf( + "Parser" to { src -> Parser().parseModule(src) }, + "GenericParser" to { src -> GenericParser().parseModule(src) }, + ) + + /** Runs [action] on a thread with a small stack, and returns what it threw (if anything). */ + private fun runOnSmallStack(action: () -> Unit): Throwable? { + var thrown: Throwable? = null + val thread = + Thread(null, { thrown = runCatching(action).exceptionOrNull() }, "test", STACK_SIZE) + thread.start() + thread.join() + return thrown + } + } + + @TestFactory + fun `deep nesting fails with a parse error`(): List = + nesting.flatMap { (shape, gen) -> + parsers().map { (parser, parse) -> + dynamicTest("$parser: $shape x $HUGE") { + val thrown = runOnSmallStack { parse(gen(HUGE)) } + assertThat(thrown).isNotNull + assertThat(thrown).isNotInstanceOf(StackOverflowError::class.java) + assertThat(thrown).hasMessageContaining("nested too deeply") + } + } + } + + @TestFactory + fun `long chains are parsed iteratively`(): List = chains.flatMap { (shape, gen) -> + parsers().map { (parser, parse) -> + dynamicTest("$parser: $shape x $HUGE") { + assertThat(runOnSmallStack { parse(gen(HUGE)) }).isNull() + } + } + } + + @TestFactory + fun `flat repetition is unaffected`(): List = + mapOf( + "union type" to "x: " + "Int|".repeat(HUGE) + "Int", + "listing elements" to "x = new Listing { " + "1\n".repeat(HUGE) + "}", + "properties" to (1..10_000).joinToString("\n") { "p$it = 1" }, + ) + .flatMap { (shape, src) -> + parsers().map { (parser, parse) -> + dynamicTest("$parser: $shape") { assertThat(runOnSmallStack { parse(src) }).isNull() } + } + } +}