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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 143993ad0c fix(marshall): YAML parser buffer-underflow on large 
OpenAPI 3.1 docs — ParserReader unread-lookback 10 → 256 (TODO-88)
143993ad0c is described below

commit 143993ad0cd76bb2acb1a4cfe89bb19a06779852
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 16:45:12 2026 -0400

    fix(marshall): YAML parser buffer-underflow on large OpenAPI 3.1 docs — 
ParserReader unread-lookback 10 → 256 (TODO-88)
---
 .../org/apache/juneau/parser/ParserReader.java     |  20 ++-
 .../rest/openapi/OpenApiYamlRoundTrip_Test.java    |  12 +-
 .../juneau/yaml/YamlBufferUnderflow_Test.java      | 187 +++++++++++++++++++++
 3 files changed, 212 insertions(+), 7 deletions(-)

diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserReader.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserReader.java
index 216d87a4c4..fafb70990a 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserReader.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parser/ParserReader.java
@@ -51,6 +51,19 @@ public class ParserReader extends Reader implements 
Positionable {
        // Error message constants
        private static final String MSG_bufferUnderflow = "Buffer underflow.";
 
+       /**
+        * Maximum number of previously-read characters preserved as unread 
lookback when the buffer is
+        * refilled <em>without</em> an active {@link #mark()}. Originally 10, 
which was sufficient for
+        * JSON's short tokens but too small for YAML's indent-aware parser: a 
deeply-nested OpenAPI 3.1
+        * document can have indent depth well past 10 chars on a single line, 
and the YAML parser's
+        * {@code skipBlanksAndCountIndent} / {@code unreadSpaces} pair (and 
similar peek-then-unread
+        * patterns in block-scalar handling) tries to unread the entire indent 
at once. When the indent
+        * read crossed the buffer boundary, the rest underflowed. 256 chars 
covers any realistic YAML
+        * document indent depth (128 levels at 2-space indent) while still 
leaving 768 chars per
+        * 1024-char refill for forward-progress reads.
+        */
+       private static final int UNMARKED_LOOKBACK_CHARS = 256;
+
        /** Wrapped reader */
        protected final Reader r;
 
@@ -427,8 +440,11 @@ public class ParserReader extends Reader implements 
Positionable {
                                        }
                                        iEnd = iCurrent + x;
                                } else {
-                                       // Copy the last 10 chars in the buffer 
to the beginning of the buffer.
-                                       int copyBuff = Math.min(iCurrent, 10);
+                                       // Copy the last 
UNMARKED_LOOKBACK_CHARS chars in the buffer to the beginning so
+                                       // callers can still unread() that far 
across a buffer-boundary refill. See the
+                                       // constant's javadoc for why 256 (not 
10) — widened to handle YAML's deep-indent
+                                       // peek-then-unread patterns where a 
single line's indent can exceed 10 chars.
+                                       int copyBuff = Math.min(iCurrent, 
UNMARKED_LOOKBACK_CHARS);
                                        System.arraycopy(buff, iCurrent - 
copyBuff, buff, 0, copyBuff);
 
                                        // Number of characters we expect to 
copy on the next read.
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/openapi/OpenApiYamlRoundTrip_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/openapi/OpenApiYamlRoundTrip_Test.java
index 97172aa107..fe2097a955 100644
--- 
a/juneau-utest/src/test/java/org/apache/juneau/rest/openapi/OpenApiYamlRoundTrip_Test.java
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/openapi/OpenApiYamlRoundTrip_Test.java
@@ -87,11 +87,13 @@ class OpenApiYamlRoundTrip_Test extends TestBase {
                @RestPost(path="/pet") public Pet createPet(@Content Pet pet) { 
return pet; }
        }
 
-       // noInherit applied here too so the /openapi endpoint document (served 
via the docs mixins) stays
-       // small enough to round-trip cleanly through the YAML parser. The full 
mixin surface produces a
-       // larger document that exposes a separate YAML parser limitation 
(deeply nested/complex docs hit a
-       // buffer-underflow on the round-trip path) — tracked separately from 
this TODO.
-       @Rest(noInherit={"mixins"}, 
mixins={org.apache.juneau.rest.docs.BasicOpenApiResource.class})
+       // Inherits the FULL BasicRestServlet api-docs mixin surface — six 
api-docs URLs, full schema
+       // set, the works. Earlier this resource shipped with 
noInherit={"mixins"} + mixins={
+       // BasicOpenApiResource} because round-tripping the full doc through 
the YAML parser threw
+       // IOException: Buffer underflow; that latent ParserReader limitation 
was fixed by widening the
+       // reader's no-mark unread-lookback window (see 
ParserReader.UNMARKED_LOOKBACK_CHARS), so c01
+       // now asserts against the FULL mixin-pack mount surface.
+       @Rest
        public static class B extends BasicRestServlet {
                private static final long serialVersionUID = 1L;
                @RestGet(path="/pet") public Pet getPet() { return new Pet(); }
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/yaml/YamlBufferUnderflow_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/yaml/YamlBufferUnderflow_Test.java
new file mode 100644
index 0000000000..3f920e782f
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/yaml/YamlBufferUnderflow_Test.java
@@ -0,0 +1,187 @@
+/*
+ * 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 org.apache.juneau.yaml;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.bean.openapi3.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.MockServletRequest;
+import org.apache.juneau.rest.mock.MockServletResponse;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Focused, parser-only regression coverage for the YAML buffer-underflow fix: 
the YAML
+ * parser used to throw {@code IOException: Buffer underflow} when 
round-tripping documents
+ * that crossed the underlying {@code ParserReader}'s 1024-char buffer 
boundary while a single
+ * block-mapping line had more than ~10 chars of leading indent.
+ *
+ * <p>Root cause: {@code ParserReader.readFromBuff()} retained only 10 chars 
of unread
+ * lookback when refilling the buffer without an active mark, but {@code 
YamlParserSession}
+ * routinely reads N indent spaces (where N can be 12+ for deeply nested 
OpenAPI 3.1 docs)
+ * and then unreads all of them via {@code unreadSpaces(r, N)}. Once the read 
crossed a
+ * buffer boundary, only 10 of those unreads were honored — the rest threw 
underflow. The
+ * fix widens the unmarked-refill lookback window (see
+ * {@code ParserReader.UNMARKED_LOOKBACK_CHARS}).
+ *
+ * <p>Symptom (pre-fix): the live OpenAPI 3.1 doc emitted by {@code 
BasicRestServlet} with
+ * the full api-docs mixin pack (six api-docs URLs + components.schemas) 
reliably tripped
+ * the underflow on YAML round-trip; this test reproduces the underflow at the 
parser layer
+ * with no REST stack, so a future regression of the buffer-management bug 
surfaces here
+ * before it reaches the live-doc test.
+ */
+class YamlBufferUnderflow_Test extends TestBase {
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Synthesized minimal repro: many top-level keys whose values are 
deeply-nested maps,
+       // emitted with 2-space indents. Total document size deliberately 
exceeds the 1024-char
+       // ParserReader buffer; nested indent depth exceeds the 10-char unread 
lookback.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       private static Map<String,Object> nestedMap(int depth) {
+               Map<String,Object> head = new LinkedHashMap<>();
+               Map<String,Object> cur = head;
+               for (int i = 0; i < depth; i++) {
+                       Map<String,Object> next = new LinkedHashMap<>();
+                       next.put("leaf", "value-" + i);
+                       cur.put("level" + i, next);
+                       cur = next;
+               }
+               return head;
+       }
+
+       @Test void a01_largeNestedMap_jsonYamlJsonRoundTrip() throws Exception {
+               // Build a document that's > 1024 chars when serialized to YAML 
and has nested
+               // indent depth > 10 spaces (to trip the unread-lookback 
underflow).
+               var top = new LinkedHashMap<String,Object>();
+               for (int i = 0; i < 30; i++)
+                       top.put("entry" + i, nestedMap(8));
+
+               String yaml = YamlSerializer.DEFAULT_READABLE.toString(top);
+               assertTrue(yaml.length() > 1024, () -> "YAML too small for test 
(got " + yaml.length() + " chars)");
+               assertTrue(yaml.contains("                "), () -> "YAML must 
contain >10-space indent line for repro");
+
+               Map<?,?> parsed = YamlParser.DEFAULT.parse(yaml, Map.class);
+
+               assertEquals(top.size(), parsed.size(), "Top-level entry count 
must match");
+               assertEquals(top.keySet(), parsed.keySet(), "Top-level keys 
must match");
+       }
+
+       @Test void a02_jsonToYamlToJson_structuralEquality() throws Exception {
+               // Same shape but driven through JSON to YAML to JSON to verify 
the round-trip
+               // path the OpenApiYamlRoundTrip_Test#c01 workaround was 
avoiding.
+               var top = new LinkedHashMap<String,Object>();
+               for (int i = 0; i < 20; i++)
+                       top.put("k" + i, nestedMap(7));
+
+               String json = JsonSerializer.DEFAULT.toString(top);
+               Map<?,?> fromJson = JsonParser.DEFAULT.parse(json, Map.class);
+               String yaml = 
YamlSerializer.DEFAULT_READABLE.toString(fromJson);
+               assertTrue(yaml.length() > 1024);
+
+               // This is the line that used to throw IOException: Buffer 
underflow.
+               Map<?,?> fromYaml = YamlParser.DEFAULT.parse(yaml, Map.class);
+               String json2 = JsonSerializer.DEFAULT.toString(fromYaml);
+
+               // Sanity: the second JSON has all the top-level keys.
+               Map<?,?> reparsed = JsonParser.DEFAULT.parse(json2, Map.class);
+               assertEquals(top.keySet(), reparsed.keySet());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Pathological cases that exercise the same code paths but with fewer 
moving parts.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_deepIndentAcrossBufferBoundary() throws Exception {
+               // Construct a YAML doc whose first 1000+ chars are a single 
long string value,
+               // followed by a nested block-mapping that requires >10-char 
indent unreads at
+               // the boundary.
+               var top = new LinkedHashMap<String,Object>();
+               var bigStr = new StringBuilder();
+               for (int i = 0; i < 500; i++)
+                       bigStr.append('x');
+               top.put("filler", bigStr.toString());
+               top.put("nested", nestedMap(10));
+
+               String yaml = YamlSerializer.DEFAULT_READABLE.toString(top);
+               Map<?,?> parsed = YamlParser.DEFAULT.parse(yaml, Map.class);
+
+               assertEquals(top.keySet(), parsed.keySet());
+               assertEquals(bigStr.toString(), parsed.get("filler"));
+       }
+
+       @Test void b02_manyTopLevelKeysWithDeepIndent() throws Exception {
+               // Many shallow keys whose values are a deep nested map, so 
that the sequence of
+               // {top-level-key newline + indent + key}-pairs accumulates 
past the buffer
+               // boundary and the indent length flips between 0 (top-level) 
and >10 (nested).
+               var top = new LinkedHashMap<String,Object>();
+               for (int i = 0; i < 40; i++)
+                       top.put("key_" + i, nestedMap(6));
+
+               String yaml = YamlSerializer.DEFAULT_READABLE.toString(top);
+               Map<?,?> parsed = YamlParser.DEFAULT.parse(yaml, Map.class);
+
+               assertEquals(40, parsed.size());
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Live-resource repro: the FULL BasicRestServlet OpenAPI doc — six 
api-docs URLs and the
+       // full components.schemas set — is the document shape that originally 
tripped the
+       // underflow. We re-use the same RestContext-driven path that 
OpenApiYamlRoundTrip_Test
+       // uses.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       public static class Pet {
+               public int id;
+               public String name;
+       }
+
+       @Rest
+       public static class FullSurface extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               @RestGet(path="/pet") public Pet getPet() { return new Pet(); }
+       }
+
+       public void testMethod() { /* no-op */ }
+
+       private OpenApi getOpenApi(Object resource) throws Exception {
+               var rc = new RestContext(new 
RestContext.Args(resource.getClass(), null, null, () -> resource, "", null, 
null, null, false));
+               var roc = new 
RestOpContext(YamlBufferUnderflow_Test.class.getMethod("testMethod"), rc);
+               var call = RestSession.create(rc).resource(resource).req(new 
MockServletRequest()).res(new MockServletResponse()).build();
+               var req = roc.createRequest(call);
+               return rc.getOpenApiProvider().getOpenApi(rc, req.getLocale());
+       }
+
+       @Test void c01_basicRestServletFullMixinSurface_yamlRoundTrip() throws 
Exception {
+               var doc = getOpenApi(new FullSurface());
+               String yaml = YamlSerializer.DEFAULT_READABLE.toString(doc);
+               // The full mixin surface produces a multi-KB YAML doc that 
crosses the
+               // ParserReader buffer boundary; deep schema nesting puts 
indent depth well past the
+               // reader's no-mark unread-lookback limit. Pre-fix this throws 
IOException: Buffer
+               // underflow.
+               var parsed = YamlParser.DEFAULT.parse(yaml, OpenApi.class);
+               assertEquals(doc.getOpenapi(), parsed.getOpenapi());
+               assertNotNull(parsed.getPaths());
+       }
+}

Reply via email to