You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/09/02 20:50:14 UTC

(groovy) 01/02: GROOVY-12330: JsonOutput: bound the nesting depth prettyPrint will format

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 04df7498534495c395d0b990131048633b16cf54
Author: Paul King <pa...@asert.com.au>
AuthorDate: Wed Sep 2 17:09:49 2026 +1000

    GROOVY-12330: JsonOutput: bound the nesting depth prettyPrint will format
    
    prettyPrint indents every nesting level, so each level adds four
    characters to every line it encloses and the output grows with the square
    of the document's nesting. Nothing bounded that: 8KB of '[' expanded into
    128MB of output, and 16KB exhausted a 3GB heap. The indent cache
    compounded it, retaining a distinct array per depth reached, which is the
    same quadratic again in retained memory.
    
    Depth is now bounded by groovy.json.maxNestingDepth, the property the
    slurpers already use, defaulting to the same 1000 and throwing a
    JsonException past it. Sharing the knob is what makes the pair coherent:
    a payload prettyPrint will format is exactly one JsonSlurper would parse.
    Bounding the depth also bounds the indent cache and the output, since
    both are driven by it - a document at the limit now costs about 4MB
    rather than an unbounded amount, and the earlier blow-ups are rejected in
    single-digit milliseconds.
    
    prettyPrint lexes rather than parses, so it cannot tell an unclosed run
    of brackets from real nesting; both are bounded the same way, which is
    what stops the unbalanced case. That is the behaviour change to note: a
    run of more than 1000 unclosed brackets, or a document nested deeper than
    that, is now rejected where it previously produced output. Setting the
    property to 0 or less disables the check.
    
    Since prettyPrint is static there is no per-instance setter to mirror the
    one on JsonSlurper; the property is read per call so it can still be
    changed at runtime.
---
 .../src/main/java/groovy/json/JsonOutput.java      |  46 +++++++
 .../json/JsonOutputPrettyPrintDepthTest.groovy     | 132 +++++++++++++++++++++
 2 files changed, 178 insertions(+)

diff --git a/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java b/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java
index cd61f336b9..9ed38e57ad 100644
--- a/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java
+++ b/subprojects/groovy-json/src/main/java/groovy/json/JsonOutput.java
@@ -18,6 +18,7 @@
  */
 package groovy.json;
 
+import org.apache.groovy.json.internal.BaseJsonParser;
 import org.apache.groovy.json.internal.CharBuf;
 import org.apache.groovy.json.internal.Chr;
 import groovy.lang.Closure;
@@ -170,6 +171,14 @@ public class JsonOutput {
     /**
      * Pretty print a JSON payload.
      *
+     * <p>
+     * Nesting depth is bounded by the {@code groovy.json.maxNestingDepth} system property
+     * (default {@link BaseJsonParser#DEFAULT_MAX_NESTING_DEPTH}), matching the bound the slurpers
+     * enforce, so a payload this method will format is one {@link JsonSlurper} would parse. A
+     * payload nested deeper &mdash; or left unbalanced, which the lexer cannot distinguish from
+     * nesting &mdash; is rejected with a {@link JsonException} rather than expanded into an output
+     * quadratically larger than itself. A property value of {@code 0} or less disables the check.
+     *
      * @param jsonPayload a JSON payload
      * @return a pretty representation of JSON payload.
      */
@@ -180,13 +189,24 @@ public class JsonOutput {
     /**
      * Pretty print a JSON payload.
      *
+     * <p>
+     * Nesting depth is bounded by the {@code groovy.json.maxNestingDepth} system property
+     * (default {@link BaseJsonParser#DEFAULT_MAX_NESTING_DEPTH}), matching the bound the slurpers
+     * enforce, so a payload this method will format is one {@link JsonSlurper} would parse. A
+     * payload nested deeper &mdash; or left unbalanced, which the lexer cannot distinguish from
+     * nesting &mdash; is rejected with a {@link JsonException} rather than expanded into an output
+     * quadratically larger than itself. A property value of {@code 0} or less disables the check.
+     *
      * @param jsonPayload a JSON payload
      * @param disableUnicodeEscaping whether to disable unicode escaping
      * @return a pretty representation of JSON payload.
      * @since 4.0.19
      */
     public static String prettyPrint(String jsonPayload, boolean disableUnicodeEscaping) {
+        final int maxNestingDepth = Integer.getInteger("groovy.json.maxNestingDepth",
+                BaseJsonParser.DEFAULT_MAX_NESTING_DEPTH);
         int indentSize = 0;
+        int nestingDepth = 0;
         // Just a guess that the pretty view will take 20 percent more than original.
         final CharBuf output = CharBuf.create((int) (jsonPayload.length() * 1.2));
 
@@ -197,11 +217,15 @@ public class JsonOutput {
             JsonToken token = lexer.next();
             switch (token.getType()) {
                 case OPEN_CURLY:
+                    nestingDepth = enterNesting(nestingDepth, maxNestingDepth);
                     indentSize += 4;
                     output.addChars(Chr.array(OPEN_BRACE, NEW_LINE)).addChars(getIndent(indentSize, indentCache));
 
                     break;
                 case CLOSE_CURLY:
+                    if (nestingDepth > 0) {
+                        nestingDepth--;
+                    }
                     indentSize -= 4;
                     output.addChar(NEW_LINE);
                     if (indentSize > 0) {
@@ -211,11 +235,15 @@ public class JsonOutput {
 
                     break;
                 case OPEN_BRACKET:
+                    nestingDepth = enterNesting(nestingDepth, maxNestingDepth);
                     indentSize += 4;
                     output.addChars(Chr.array(OPEN_BRACKET, NEW_LINE)).addChars(getIndent(indentSize, indentCache));
 
                     break;
                 case CLOSE_BRACKET:
+                    if (nestingDepth > 0) {
+                        nestingDepth--;
+                    }
                     indentSize -= 4;
                     output.addChar(NEW_LINE);
                     if (indentSize > 0) {
@@ -250,6 +278,24 @@ public class JsonOutput {
         return output.toString();
     }
 
+    /**
+     * Records entry into a nested array or object, enforcing the configured maximum nesting depth.
+     * Bounding the depth also bounds the indent cache and the size of the output: each level adds
+     * four characters to every indent it encloses, so an unbounded document costs memory quadratic
+     * in its own nesting.
+     *
+     * @param nestingDepth the depth before this token
+     * @param maxNestingDepth the maximum permitted depth; {@code 0} or less disables the check
+     * @return the depth after this token
+     * @throws JsonException if the configured maximum nesting depth would be exceeded
+     */
+    private static int enterNesting(int nestingDepth, int maxNestingDepth) {
+        if (maxNestingDepth > 0 && nestingDepth + 1 > maxNestingDepth) {
+            throw new JsonException("Maximum JSON nesting depth of " + maxNestingDepth + " exceeded");
+        }
+        return nestingDepth + 1;
+    }
+
     /**
      * Creates a new indent if it doesn't exist in the indent cache.
      *
diff --git a/subprojects/groovy-json/src/test/groovy/groovy/json/JsonOutputPrettyPrintDepthTest.groovy b/subprojects/groovy-json/src/test/groovy/groovy/json/JsonOutputPrettyPrintDepthTest.groovy
new file mode 100644
index 0000000000..41664d2d9c
--- /dev/null
+++ b/subprojects/groovy-json/src/test/groovy/groovy/json/JsonOutputPrettyPrintDepthTest.groovy
@@ -0,0 +1,132 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 groovy.json
+
+import org.apache.groovy.json.internal.BaseJsonParser
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.shouldFail
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * {@code prettyPrint} indents each nesting level, so every enclosed line grows with the depth
+ * around it and an unbounded document expands quadratically in its own nesting. It lexes rather
+ * than parses, so an unbalanced run of opening brackets is indistinguishable from real nesting and
+ * is bounded the same way.
+ */
+class JsonOutputPrettyPrintDepthTest {
+
+    private static final int DEFAULT = BaseJsonParser.DEFAULT_MAX_NESTING_DEPTH
+    private static final String PROPERTY = 'groovy.json.maxNestingDepth'
+
+    private static String nested(int depth) {
+        '[' * depth + ']' * depth
+    }
+
+    private static withMaxNestingDepth(Object value, Closure body) {
+        String previous = System.getProperty(PROPERTY)
+        if (value == null) System.clearProperty(PROPERTY) else System.setProperty(PROPERTY, value.toString())
+        try {
+            body()
+        } finally {
+            if (previous == null) System.clearProperty(PROPERTY) else System.setProperty(PROPERTY, previous)
+        }
+    }
+
+    // GROOVY-12330
+    @Test
+    void testDocumentAtTheDefaultLimitStillPrints() {
+        String pretty = JsonOutput.prettyPrint(nested(DEFAULT))
+        assertEquals(DEFAULT, pretty.count('['))
+        assertEquals(DEFAULT, pretty.count(']'))
+    }
+
+    // GROOVY-12330
+    @Test
+    void testDocumentPastTheDefaultLimitIsRejected() {
+        def e = shouldFail(JsonException) { JsonOutput.prettyPrint(nested(DEFAULT + 1)) }
+        assertTrue(e.message.contains("Maximum JSON nesting depth of ${DEFAULT} exceeded"), e.message)
+    }
+
+    // GROOVY-12330
+    @Test
+    void testUnbalancedOpeningRunIsRejectedRatherThanAmplified() {
+        // 16KB of '[' used to expand into an OutOfMemoryError; the lexer cannot tell an unclosed
+        // run from genuine nesting, so the depth bound is what stops it
+        shouldFail(JsonException) { JsonOutput.prettyPrint('[' * 16384) }
+        shouldFail(JsonException) { JsonOutput.prettyPrint('{' * 16384) }
+    }
+
+    // GROOVY-12330
+    @Test
+    void testDepthIsCountedPerNestingNotPerToken() {
+        // siblings repeatedly enter and leave depth 2 -- only concurrent nesting counts
+        String siblings = '[' + (['[1,2]'] * 500).join(',') + ']'
+        String pretty = JsonOutput.prettyPrint(siblings)
+        assertEquals(501, pretty.count('['))
+    }
+
+    // GROOVY-12330
+    @Test
+    void testLimitIsConfigurableBySystemProperty() {
+        withMaxNestingDepth(10) {
+            assertEquals(10, JsonOutput.prettyPrint(nested(10)).count('['))
+            shouldFail(JsonException) { JsonOutput.prettyPrint(nested(11)) }
+        }
+    }
+
+    // GROOVY-12330
+    @Test
+    void testCheckIsDisabledByNonPositiveLimit() {
+        withMaxNestingDepth(0) {
+            assertEquals(DEFAULT + 50, JsonOutput.prettyPrint(nested(DEFAULT + 50)).count('['))
+        }
+    }
+
+    // GROOVY-12330
+    @Test
+    void testPrettyPrintAcceptsWhateverTheSlurperParses() {
+        // the two bounds are the same knob, so the formatter never rejects a document the parser took
+        withMaxNestingDepth(25) {
+            String document = nested(25)
+            new JsonSlurper().parseText(document)
+            JsonOutput.prettyPrint(document)
+
+            shouldFail(JsonException) { new JsonSlurper().parseText(nested(26)) }
+            shouldFail(JsonException) { JsonOutput.prettyPrint(nested(26)) }
+        }
+    }
+
+    // GROOVY-12330
+    @Test
+    void testOrdinaryDocumentIsFormattedUnchanged() {
+        String pretty = JsonOutput.prettyPrint('{"a":[1,2,{"b":"c"}],"d":null}')
+        assertEquals('''{
+    "a": [
+        1,
+        2,
+        {
+            "b": "c"
+        }
+    ],
+    "d": null
+}'''.replace('\r\n', '\n'), pretty.replace('\r\n', '\n'))
+    }
+}