diff --git a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java index f575938cd4c..39089a1e801 100644 --- a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java +++ b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java @@ -67,6 +67,7 @@ import org.eclipse.jetty.server.handler.AbstractHandler; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.component.Dumpable; import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; @@ -88,8 +89,9 @@ import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.condition.OS.LINUX; import static org.junit.jupiter.api.condition.OS.WINDOWS; -// This whole test is very specific to how TLS < 1.3 works. -@EnabledOnJre({JRE.JAVA_8, JRE.JAVA_9, JRE.JAVA_10}) +// Other JREs have slight differences in how TLS work +// and this test expects a very specific TLS behavior. +@EnabledOnJre(JRE.JAVA_11) public class SslBytesServerTest extends SslBytesTest { private final AtomicInteger sslFills = new AtomicInteger(); @@ -108,9 +110,9 @@ public class SslBytesServerTest extends SslBytesTest @BeforeEach public void init() throws Exception { - - threadPool = Executors.newCachedThreadPool(); - server = new Server(); + QueuedThreadPool serverThreads = new QueuedThreadPool(); + serverThreads.setName("server"); + server = new Server(serverThreads); sslFills.set(0); sslFlushes.set(0); @@ -119,6 +121,8 @@ public class SslBytesServerTest extends SslBytesTest File keyStore = MavenTestingUtils.getTestResourceFile("keystore.p12"); sslContextFactory = new SslContextFactory.Server(); + // This whole test is very specific to how TLS < 1.3 works. + sslContextFactory.setIncludeProtocols("TLSv1.2"); sslContextFactory.setKeyStorePath(keyStore.getAbsolutePath()); sslContextFactory.setKeyStorePassword("storepwd"); @@ -238,6 +242,7 @@ public class SslBytesServerTest extends SslBytesTest sslContext = sslContextFactory.getSslContext(); + threadPool = Executors.newCachedThreadPool(); proxy = new SimpleProxy(threadPool, "localhost", serverPort); proxy.start(); logger.info("proxy:{} <==> server:{}", proxy.getPort(), serverPort); @@ -1128,6 +1133,66 @@ public class SslBytesServerTest extends SslBytesTest client.close(); } + @Test + public void testRequestResponseServerIdleTimeoutClientResets() throws Exception + { + SSLSocket client = newClient(); + + SimpleProxy.AutomaticFlow automaticProxyFlow = proxy.startAutomaticFlow(); + client.startHandshake(); + assertTrue(automaticProxyFlow.stop(5, TimeUnit.SECONDS)); + + Future request = threadPool.submit(() -> + { + OutputStream clientOutput = client.getOutputStream(); + clientOutput.write(( + "GET / HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n").getBytes(StandardCharsets.UTF_8)); + clientOutput.flush(); + return null; + }); + + // Application data + TLSRecord record = proxy.readFromClient(); + proxy.flushToServer(record); + assertNull(request.get(5, TimeUnit.SECONDS)); + + // Application data + record = proxy.readFromServer(); + assertEquals(TLSRecord.Type.APPLICATION, record.getType()); + proxy.flushToClient(record); + + BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + String line = reader.readLine(); + assertNotNull(line); + assertTrue(line.startsWith("HTTP/1.1 200 ")); + while ((line = reader.readLine()) != null) + { + if (line.trim().length() == 0) + break; + } + + // Wait for the server idle timeout. + Thread.sleep(idleTimeout); + + // We expect that the server sends the TLS Alert. + record = proxy.readFromServer(); + assertNotNull(record); + assertEquals(TLSRecord.Type.ALERT, record.getType()); + + // Send a RST to the server. + proxy.sendRSTToServer(); + + // Wait for the RST to be processed by the server. + Thread.sleep(1000); + + // The server EndPoint must be closed. + assertFalse(serverEndPoint.get().isOpen()); + + client.close(); + } + @Test @EnabledOnOs(LINUX) // see message below public void testRequestWithCloseAlertWithSplitBoundary() throws Exception diff --git a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java index 72748ffded6..abc47d56f2c 100644 --- a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java +++ b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java @@ -234,6 +234,7 @@ public abstract class SslBytesTest public void flushToServer(TLSRecord record, long sleep) throws Exception { + logger.debug("P --> S {}", record); if (record == null) { server.shutdownOutput(); @@ -272,6 +273,7 @@ public abstract class SslBytesTest public void flushToClient(TLSRecord record) throws Exception { + logger.debug("C <-- P {}", record); if (record == null) { client.shutdownOutput(); diff --git a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java new file mode 100644 index 00000000000..a16d28bb041 --- /dev/null +++ b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java @@ -0,0 +1,1389 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.util.ajax; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jetty.util.ArrayTernaryTrie; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Loader; +import org.eclipse.jetty.util.Trie; +import org.eclipse.jetty.util.TypeUtil; +import org.eclipse.jetty.util.Utf8StringBuilder; +import org.eclipse.jetty.util.ajax.JSON.Convertible; +import org.eclipse.jetty.util.ajax.JSON.Convertor; + +/** + *

A non-blocking JSON parser that can parse partial JSON strings.

+ *

Usage:

+ *
+ * AsyncJSON parser = new AsyncJSON.Factory().newAsyncJSON();
+ *
+ * // Feed the parser with partial JSON string content.
+ * parser.parse(chunk1);
+ * parser.parse(chunk2);
+ *
+ * // Tell the parser that the JSON string content
+ * // is terminated and get the JSON object back.
+ * Map<String, Object> object = parser.complete();
+ * 
+ *

After the call to {@link #complete()} the parser can be reused to parse + * another JSON string.

+ *

Custom objects can be created by specifying a {@code "class"} or + * {@code "x-class"} field:

+ *
+ * String json = """
+ * {
+ *   "x-class": "com.acme.Person",
+ *   "firstName": "John",
+ *   "lastName": "Doe",
+ *   "age": 42
+ * }
+ * """
+ *
+ * parser.parse(json);
+ * com.acme.Person person = parser.complete();
+ * 
+ *

Class {@code com.acme.Person} must either implement {@link Convertible}, + * or be mapped with a {@link Convertor} via {@link Factory#putConvertor(String, Convertor)}.

+ */ +public class AsyncJSON +{ + /** + *

The factory that creates AsyncJSON instances.

+ *

The factory can be configured with custom {@link Convertor}s, + * and with cached strings that will not be allocated if they can + * be looked up from the cache.

+ */ + public static class Factory + { + private Trie cache; + private Map convertors; + private boolean detailedParseException; + + /** + * @return whether a parse failure should report the whole JSON string or just the last chunk + */ + public boolean isDetailedParseException() + { + return detailedParseException; + } + + /** + * @param detailedParseException whether a parse failure should report the whole JSON string or just the last chunk + */ + public void setDetailedParseException(boolean detailedParseException) + { + this.detailedParseException = detailedParseException; + } + + /** + * @param value the string to cache + * @return whether the value can be cached + */ + public boolean cache(String value) + { + if (cache == null) + cache = new ArrayTernaryTrie.Growing<>(false, 64, 64); + + CachedString cached = new CachedString(value); + if (cached.isCacheable()) + { + cache.put(cached.encoded, cached); + return true; + } + return false; + } + + /** + *

Attempts to return a cached string from the buffer bytes.

+ *

In case of a cache hit, the string is returned and the buffer + * position updated.

+ *

In case of cache miss, {@code null} is returned and the buffer + * position is left unaltered.

+ * + * @param buffer the buffer to lookup the string from + * @return a cached string or {@code null} + */ + protected String cached(ByteBuffer buffer) + { + if (cache != null) + { + CachedString result = cache.getBest(buffer, 0, buffer.remaining()); + if (result != null) + { + buffer.position(buffer.position() + result.encoded.length()); + return result.value; + } + } + return null; + } + + /** + * @return a new parser instance + */ + public AsyncJSON newAsyncJSON() + { + return new AsyncJSON(this); + } + + /** + *

Associates the given {@link Convertor} to the given class name.

+ * + * @param className the domain class name such as {@code com.acme.Person} + * @param convertor the {@link Convertor} that converts {@code Map} to domain objects + */ + public void putConvertor(String className, Convertor convertor) + { + if (convertors == null) + convertors = new ConcurrentHashMap<>(); + convertors.put(className, convertor); + } + + /** + *

Removes the {@link Convertor} associated with the given class name.

+ * + * @param className the class name associated with the {@link Convertor} + * @return the {@link Convertor} associated with the class name, or {@code null} + */ + public Convertor removeConvertor(String className) + { + if (convertors != null) + return convertors.remove(className); + return null; + } + + /** + *

Returns the {@link Convertor} associated with the given class name, if any.

+ * + * @param className the class name associated with the {@link Convertor} + * @return the {@link Convertor} associated with the class name, or {@code null} + */ + public Convertor getConvertor(String className) + { + return convertors == null ? null : convertors.get(className); + } + + private static class CachedString + { + private final String encoded; + private final String value; + + private CachedString(String value) + { + this.encoded = new JSON().toJSON(value); + this.value = value; + } + + private boolean isCacheable() + { + for (int i = encoded.length(); i-- > 0;) + { + char c = encoded.charAt(i); + if (c > 127) + return false; + } + return true; + } + } + } + + private static final Object UNSET = new Object(); + + private final FrameStack stack = new FrameStack(); + private final NumberBuilder numberBuilder = new NumberBuilder(); + private final Utf8StringBuilder stringBuilder = new Utf8StringBuilder(32); + private final Factory factory; + private List chunks; + + public AsyncJSON(Factory factory) + { + this.factory = factory; + } + + // Used by tests only. + boolean isEmpty() + { + return stack.isEmpty(); + } + + /** + *

Feeds the parser with the given bytes chunk.

+ * + * @param bytes the bytes to parse + * @return whether the JSON parsing was complete + * @throws IllegalArgumentException if the JSON is malformed + */ + public boolean parse(byte[] bytes) + { + return parse(bytes, 0, bytes.length); + } + + /** + *

Feeds the parser with the given bytes chunk.

+ * + * @param bytes the bytes to parse + * @param offset the offset to start parsing from + * @param length the number of bytes to parse + * @return whether the JSON parsing was complete + * @throws IllegalArgumentException if the JSON is malformed + */ + public boolean parse(byte[] bytes, int offset, int length) + { + return parse(ByteBuffer.wrap(bytes, offset, length)); + } + + /** + *

Feeds the parser with the given buffer chunk.

+ * + * @param buffer the buffer to parse + * @return whether the JSON parsing was complete + * @throws IllegalArgumentException if the JSON is malformed + */ + public boolean parse(ByteBuffer buffer) + { + try + { + if (factory.isDetailedParseException()) + { + if (chunks == null) + chunks = new ArrayList<>(); + ByteBuffer copy = buffer.isDirect() + ? ByteBuffer.allocateDirect(buffer.remaining()) + : ByteBuffer.allocate(buffer.remaining()); + copy.put(buffer).flip(); + chunks.add(copy); + buffer.flip(); + } + + if (stack.isEmpty()) + stack.push(State.COMPLETE, UNSET); + + while (true) + { + Frame frame = stack.peek(); + State state = frame.state; + switch (state) + { + case COMPLETE: + { + if (frame.value == UNSET) + { + if (parseAny(buffer)) + break; + return false; + } + else + { + while (buffer.hasRemaining()) + { + int position = buffer.position(); + byte peek = buffer.get(position); + if (isWhitespace(peek)) + buffer.position(position + 1); + else + throw newInvalidJSON(buffer, "invalid character after JSON data"); + } + return true; + } + } + case NULL: + { + if (parseNull(buffer)) + break; + return false; + } + case TRUE: + { + if (parseTrue(buffer)) + break; + return false; + } + case FALSE: + { + if (parseFalse(buffer)) + break; + return false; + } + case NUMBER: + { + if (parseNumber(buffer)) + break; + return false; + } + case STRING: + { + if (parseString(buffer)) + break; + return false; + } + case ESCAPE: + { + if (parseEscape(buffer)) + break; + return false; + } + case UNICODE: + { + if (parseUnicode(buffer)) + break; + return false; + } + case ARRAY: + { + if (parseArray(buffer)) + break; + return false; + } + case OBJECT: + { + if (parseObject(buffer)) + break; + return false; + } + case OBJECT_FIELD: + { + if (parseObjectField(buffer)) + break; + return false; + } + case OBJECT_FIELD_NAME: + { + if (parseObjectFieldName(buffer)) + break; + return false; + } + case OBJECT_FIELD_VALUE: + { + if (parseObjectFieldValue(buffer)) + break; + return false; + } + default: + { + throw new IllegalStateException("invalid state " + state); + } + } + } + } + catch (Throwable x) + { + reset(); + throw x; + } + } + + /** + *

Signals to the parser that the parse data is complete, and returns + * the object parsed from the JSON chunks passed to the {@code parse()} + * methods.

+ * + * @param the type the result is cast to + * @return the result of the JSON parsing + * @throws IllegalArgumentException if the JSON is malformed + * @throws IllegalStateException if the no JSON was passed to the {@code parse()} methods + */ + public R complete() + { + try + { + if (stack.isEmpty()) + throw new IllegalStateException("no JSON parsed"); + + while (true) + { + State state = stack.peek().state; + switch (state) + { + case NUMBER: + { + Number value = numberBuilder.value(); + stack.pop(); + stack.peek().value(value); + break; + } + case COMPLETE: + { + if (stack.peek().value == UNSET) + throw new IllegalStateException("invalid state " + state); + return (R)end(); + } + default: + { + throw newInvalidJSON(BufferUtil.EMPTY_BUFFER, "incomplete JSON"); + } + } + } + } + catch (Throwable x) + { + reset(); + throw x; + } + } + + /** + *

When a JSON { is encountered during parsing, + * this method is called to create a new {@code Map} instance.

+ *

Subclasses may override to return a custom {@code Map} instance.

+ * + * @param context the parsing context + * @return a {@code Map} instance + */ + protected Map newObject(Context context) + { + return new HashMap<>(); + } + + /** + *

When a JSON [ is encountered during parsing, + * this method is called to create a new {@code List} instance.

+ *

Subclasses may override to return a custom {@code List} instance.

+ * + * @param context the parsing context + * @return a {@code List} instance + */ + protected List newArray(Context context) + { + return new ArrayList<>(); + } + + private Object end() + { + Object result = stack.peek().value; + reset(); + return result; + } + + private void reset() + { + stack.clear(); + chunks = null; + } + + private boolean parseAny(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + case '[': + if (parseArray(buffer)) + return true; + break; + case '{': + if (parseObject(buffer)) + return true; + break; + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + if (parseNumber(buffer)) + return true; + break; + case '"': + if (parseString(buffer)) + return true; + break; + case 'f': + if (parseFalse(buffer)) + return true; + break; + case 'n': + if (parseNull(buffer)) + return true; + break; + case 't': + if (parseTrue(buffer)) + return true; + break; + default: + if (isWhitespace(peek)) + { + buffer.get(); + break; + } + throw newInvalidJSON(buffer, "unrecognized JSON value"); + } + } + return false; + } + + private boolean parseNull(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case 'n': + if (stack.peek().state != State.NULL) + { + stack.push(State.NULL, 0); + parseNullCharacter(buffer, 0); + break; + } + throw newInvalidJSON(buffer, "invalid 'null' literal"); + case 'u': + parseNullCharacter(buffer, 1); + break; + case 'l': + int index = (Integer)stack.peek().value; + if (index == 2 || index == 3) + parseNullCharacter(buffer, index); + else + throw newInvalidJSON(buffer, "invalid 'null' literal"); + if (index == 3) + { + stack.pop(); + stack.peek().value(null); + return true; + } + break; + default: + throw newInvalidJSON(buffer, "invalid 'null' literal"); + } + } + return false; + } + + private void parseNullCharacter(ByteBuffer buffer, int index) + { + Frame frame = stack.peek(); + int value = (Integer)frame.value; + if (value == index) + frame.value = ++value; + else + throw newInvalidJSON(buffer, "invalid 'null' literal"); + } + + private boolean parseTrue(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case 't': + if (stack.peek().state != State.TRUE) + { + stack.push(State.TRUE, 0); + parseTrueCharacter(buffer, 0); + break; + } + throw newInvalidJSON(buffer, "invalid 'true' literal"); + case 'r': + parseTrueCharacter(buffer, 1); + break; + case 'u': + parseTrueCharacter(buffer, 2); + break; + case 'e': + parseTrueCharacter(buffer, 3); + stack.pop(); + stack.peek().value(Boolean.TRUE); + return true; + default: + throw newInvalidJSON(buffer, "invalid 'true' literal"); + } + } + return false; + } + + private void parseTrueCharacter(ByteBuffer buffer, int index) + { + Frame frame = stack.peek(); + int value = (Integer)frame.value; + if (value == index) + frame.value = ++value; + else + throw newInvalidJSON(buffer, "invalid 'true' literal"); + } + + private boolean parseFalse(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case 'f': + if (stack.peek().state != State.FALSE) + { + stack.push(State.FALSE, 0); + parseFalseCharacter(buffer, 0); + break; + } + throw newInvalidJSON(buffer, "invalid 'false' literal"); + case 'a': + parseFalseCharacter(buffer, 1); + break; + case 'l': + parseFalseCharacter(buffer, 2); + break; + case 's': + parseFalseCharacter(buffer, 3); + break; + case 'e': + parseFalseCharacter(buffer, 4); + stack.pop(); + stack.peek().value(Boolean.FALSE); + return true; + default: + throw newInvalidJSON(buffer, "invalid 'false' literal"); + } + } + return false; + } + + private void parseFalseCharacter(ByteBuffer buffer, int index) + { + Frame frame = stack.peek(); + int value = (Integer)frame.value; + if (value == index) + frame.value = ++value; + else + throw newInvalidJSON(buffer, "invalid 'false' literal"); + } + + private boolean parseNumber(ByteBuffer buffer) + { + if (stack.peek().state != State.NUMBER) + stack.push(State.NUMBER, numberBuilder); + + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case '+': + case '-': + if (numberBuilder.appendSign(currentByte)) + break; + throw newInvalidJSON(buffer, "invalid number"); + case '.': + case 'E': + case 'e': + if (numberBuilder.appendAlpha(currentByte)) + break; + throw newInvalidJSON(buffer, "invalid number"); + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + numberBuilder.appendDigit(currentByte); + break; + default: + buffer.position(buffer.position() - 1); + Number value = numberBuilder.value(); + stack.pop(); + stack.peek().value(value); + return true; + } + } + return false; + } + + private boolean parseString(ByteBuffer buffer) + { + Frame frame = stack.peek(); + if (buffer.hasRemaining() && frame.state != State.STRING) + { + String result = factory.cached(buffer); + if (result != null) + { + frame.value(result); + return true; + } + } + + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + // Explicit delimiter, handle push and pop in this method. + case '"': + { + if (stack.peek().state != State.STRING) + { + stack.push(State.STRING, stringBuilder); + break; + } + else + { + String string = stringBuilder.toString(); + stringBuilder.reset(); + stack.pop(); + stack.peek().value(string); + return true; + } + } + case '\\': + { + buffer.position(buffer.position() - 1); + if (parseEscape(buffer)) + break; + return false; + } + default: + { + stringBuilder.append(currentByte); + break; + } + } + } + return false; + } + + private boolean parseEscape(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case '\\': + if (stack.peek().state != State.ESCAPE) + { + stack.push(State.ESCAPE, stringBuilder); + break; + } + else + { + return parseEscapeCharacter((char)currentByte); + } + case '"': + case '/': + return parseEscapeCharacter((char)currentByte); + case 'b': + return parseEscapeCharacter('\b'); + case 'f': + return parseEscapeCharacter('\f'); + case 'n': + return parseEscapeCharacter('\n'); + case 'r': + return parseEscapeCharacter('\r'); + case 't': + return parseEscapeCharacter('\t'); + case 'u': + stack.push(State.UNICODE, ByteBuffer.allocate(4)); + return parseUnicode(buffer); + default: + throw newInvalidJSON(buffer, "invalid escape sequence"); + } + } + return false; + } + + private boolean parseEscapeCharacter(char escape) + { + stack.pop(); + stringBuilder.append(escape); + return true; + } + + private boolean parseUnicode(ByteBuffer buffer) + { + // Expect 4 hex digits. + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + ByteBuffer hex = (ByteBuffer)stack.peek().value; + hex.put(hexToByte(buffer, currentByte)); + if (!hex.hasRemaining()) + { + int result = (hex.get(0) << 12) + + (hex.get(1) << 8) + + (hex.get(2) << 4) + + (hex.get(3)); + stack.pop(); + // Also done with escape parsing. + stack.pop(); + stringBuilder.append((char)result); + return true; + } + } + return false; + } + + private byte hexToByte(ByteBuffer buffer, byte currentByte) + { + try + { + return TypeUtil.convertHexDigit(currentByte); + } + catch (Throwable x) + { + throw newInvalidJSON(buffer, "invalid hex digit"); + } + } + + private boolean parseArray(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + // Explicit delimiters, handle push and pop in this method. + case '[': + { + buffer.get(); + stack.push(State.ARRAY, newArray(stack)); + break; + } + case ']': + { + buffer.get(); + Object array = stack.peek().value; + stack.pop(); + stack.peek().value(array); + return true; + } + case ',': + { + buffer.get(); + break; + } + default: + { + if (isWhitespace(peek)) + { + buffer.get(); + break; + } + else + { + if (parseAny(buffer)) + { + break; + } + return false; + } + } + } + } + return false; + } + + private boolean parseObject(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + // Explicit delimiters, handle push and pop in this method. + case '{': + { + if (stack.peek().state != State.OBJECT) + { + stack.push(State.OBJECT, newObject(stack)); + break; + } + throw newInvalidJSON(buffer, "invalid object"); + } + case '}': + { + @SuppressWarnings("unchecked") + Map object = (Map)stack.peek().value; + stack.pop(); + stack.peek().value(convertObject(object)); + return true; + } + case ',': + { + break; + } + default: + { + if (isWhitespace(currentByte)) + { + break; + } + else + { + buffer.position(buffer.position() - 1); + if (parseObjectField(buffer)) + break; + return false; + } + } + } + } + return false; + } + + private boolean parseObjectField(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + case '"': + { + if (stack.peek().state == State.OBJECT) + { + stack.push(State.OBJECT_FIELD, UNSET); + if (parseObjectFieldName(buffer)) + { + // We are not done yet, parse the value. + break; + } + return false; + } + else + { + return parseObjectFieldValue(buffer); + } + } + default: + { + if (isWhitespace(peek)) + { + buffer.get(); + break; + } + else if (stack.peek().state == State.OBJECT_FIELD_VALUE) + { + return parseObjectFieldValue(buffer); + } + else + { + throw newInvalidJSON(buffer, "invalid object field"); + } + } + } + } + return false; + } + + private boolean parseObjectFieldName(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + case '"': + { + if (stack.peek().state == State.OBJECT_FIELD) + { + stack.push(State.OBJECT_FIELD_NAME, UNSET); + if (parseString(buffer)) + { + // We are not done yet, parse until the ':'. + break; + } + return false; + } + else + { + throw newInvalidJSON(buffer, "invalid object field"); + } + } + case ':': + { + buffer.get(); + // We are done with the field name. + String fieldName = (String)stack.peek().value; + stack.pop(); + // Change state to parse the field value. + stack.push(fieldName, State.OBJECT_FIELD_VALUE, UNSET); + return true; + } + default: + { + if (isWhitespace(peek)) + { + buffer.get(); + break; + } + else + { + throw newInvalidJSON(buffer, "invalid object field"); + } + } + } + } + return false; + } + + private boolean parseObjectFieldValue(ByteBuffer buffer) + { + if (stack.peek().value == UNSET) + { + if (!parseAny(buffer)) + return false; + } + + // We are done with the field value. + Frame frame = stack.peek(); + Object value = frame.value; + String name = frame.name; + stack.pop(); + // We are done with the field. + stack.pop(); + @SuppressWarnings("unchecked") + Map map = (Map)stack.peek().value; + map.put(name, value); + + return true; + } + + private Object convertObject(Map object) + { + Object result = convertObject("x-class", object); + if (result == null) + { + result = convertObject("class", object); + if (result == null) + return object; + } + return result; + } + + private Object convertObject(String fieldName, Map object) + { + String className = (String)object.get(fieldName); + if (className == null) + return null; + + Convertible convertible = toConvertible(className); + if (convertible != null) + { + convertible.fromJSON(object); + return convertible; + } + + Convertor convertor = factory.getConvertor(className); + if (convertor != null) + return convertor.fromJSON(object); + + return null; + } + + private Convertible toConvertible(String className) + { + try + { + Class klass = Loader.loadClass(className); + if (Convertible.class.isAssignableFrom(klass)) + return (Convertible)klass.getConstructor().newInstance(); + return null; + } + catch (Throwable x) + { + throw new IllegalArgumentException(x); + } + } + + protected RuntimeException newInvalidJSON(ByteBuffer buffer, String message) + { + Utf8StringBuilder builder = new Utf8StringBuilder(); + builder.append(System.lineSeparator()); + int position = buffer.position(); + if (factory.isDetailedParseException()) + { + chunks.forEach(chunk -> builder.append(buffer)); + } + else + { + buffer.position(0); + builder.append(buffer); + buffer.position(position); + } + builder.append(System.lineSeparator()); + String indent = ""; + if (position > 1) + { + char[] chars = new char[position - 1]; + Arrays.fill(chars, ' '); + indent = new String(chars); + } + builder.append(indent); + builder.append("^ "); + builder.append(message); + return new IllegalArgumentException(builder.toString()); + } + + private static boolean isWhitespace(byte ws) + { + switch (ws) + { + case ' ': + case '\n': + case '\r': + case '\t': + return true; + default: + return false; + } + } + + /** + *

The state of JSON parsing.

+ */ + public interface Context + { + /** + * @return the depth in the JSON structure + */ + public int depth(); + } + + private enum State + { + COMPLETE, NULL, TRUE, FALSE, NUMBER, STRING, ESCAPE, UNICODE, ARRAY, OBJECT, OBJECT_FIELD, OBJECT_FIELD_NAME, OBJECT_FIELD_VALUE + } + + private static class Frame + { + private String name; + private State state; + private Object value; + + private void value(Object value) + { + switch (state) + { + case COMPLETE: + case STRING: + case OBJECT_FIELD_NAME: + case OBJECT_FIELD_VALUE: + { + this.value = value; + break; + } + case ARRAY: + { + @SuppressWarnings("unchecked") + List array = (List)this.value; + array.add(value); + break; + } + default: + { + throw new IllegalStateException("invalid state " + state); + } + } + } + } + + private static class NumberBuilder + { + // 1 => positive integer + // 0 => non-integer + // -1 => negative integer + private int integer = 1; + private long value; + private StringBuilder builder; + + private boolean appendSign(byte b) + { + if (integer == 0) + { + if (builder.length() == 0) + { + builder.append((char)b); + return true; + } + else + { + char c = builder.charAt(builder.length() - 1); + if (c == 'E' || c == 'e') + { + builder.append((char)b); + return true; + } + } + return false; + } + else + { + if (value == 0) + { + if (b == '-') + { + if (integer == 1) + { + integer = -1; + return true; + } + } + else + { + return true; + } + } + } + return false; + } + + private void appendDigit(byte b) + { + if (integer == 0) + builder.append((char)b); + else + value = value * 10 + (b - '0'); + } + + private boolean appendAlpha(byte b) + { + if (integer == 0) + { + char c = builder.charAt(builder.length() - 1); + if ('0' <= c && c <= '9' && builder.indexOf("" + (char)b) < 0) + { + builder.append((char)b); + return true; + } + } + else + { + if (builder == null) + builder = new StringBuilder(16); + if (integer == -1) + builder.append('-'); + integer = 0; + builder.append(value); + builder.append((char)b); + return true; + } + return false; + } + + private Number value() + { + try + { + if (integer == 0) + return Double.parseDouble(builder.toString()); + return integer * value; + } + finally + { + reset(); + } + } + + private void reset() + { + integer = 1; + value = 0; + if (builder != null) + builder.setLength(0); + } + } + + private static class FrameStack implements AsyncJSON.Context + { + private final List stack = new ArrayList<>(); + private int cursor; + + private FrameStack() + { + grow(6); + } + + private void grow(int grow) + { + for (int i = 0; i < grow; i++) + { + stack.add(new Frame()); + } + } + + private void clear() + { + while (!isEmpty()) + { + pop(); + } + } + + private boolean isEmpty() + { + return cursor == 0; + } + + @Override + public int depth() + { + return cursor - 1; + } + + private Frame peek() + { + if (isEmpty()) + throw new IllegalStateException("empty stack"); + return stack.get(depth()); + } + + private void push(AsyncJSON.State state, Object value) + { + push(null, state, value); + } + + private void push(String name, AsyncJSON.State state, Object value) + { + if (cursor == stack.size()) + grow(2); + ++cursor; + Frame frame = stack.get(depth()); + frame.name = name; + frame.state = state; + frame.value = value; + } + + private void pop() + { + if (isEmpty()) + throw new IllegalStateException("empty stack"); + Frame frame = stack.get(depth()); + --cursor; + frame.name = null; + frame.value = null; + frame.state = null; + } + } +} diff --git a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java new file mode 100644 index 00000000000..c2de64fb282 --- /dev/null +++ b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java @@ -0,0 +1,528 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.util.ajax; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AsyncJSONTest +{ + private static AsyncJSON newAsyncJSON() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + factory.setDetailedParseException(true); + return factory.newAsyncJSON(); + } + + @ParameterizedTest + @ValueSource(strings = {"|", "}", "]", "{]", "[}", "+", ".", "{} []"}) + public void testParseInvalidJSON(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> parser.parse(bytes)); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + }); + assertTrue(parser.isEmpty()); + } + + @ParameterizedTest(name = "[{index}] ''{0}'' -> ''{1}''") + @MethodSource("validStrings") + public void testParseString(String string, String expected) + { + String json = "\"${value}\"".replace("${value}", string); + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertTrue(parser.parse(bytes)); + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (int i = 0; i < bytes.length; ++i) + { + byte b = bytes[i]; + if (i == bytes.length - 1) + assertTrue(parser.parse(new byte[]{b})); + else + assertFalse(parser.parse(new byte[]{b})); + } + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + } + + public static List validStrings() + { + List result = new ArrayList<>(); + result.add(new Object[]{"", ""}); + result.add(new Object[]{" \t\r\n", " \t\r\n"}); + result.add(new Object[]{"\u20AC", "\u20AC"}); // euro symbol + result.add(new Object[]{"\\u20AC", "\u20AC"}); // euro symbol + result.add(new Object[]{"/foo", "/foo"}); + result.add(new Object[]{"123E+01", "123E+01"}); + result.add(new Object[]{"A\\u20AC/foo\\t\\n", "A\u20AC/foo\t\n"}); // euro symbol + result.add(new Object[]{" ABC ", " ABC "}); + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"\\u", "\\u0", "\\x"}) + public void testParseInvalidString(String value) + { + String json = "\"${value}\"".replace("${value}", value); + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> parser.parse(bytes)); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + }); + assertTrue(parser.isEmpty()); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource("validArrays") + public void testParseArray(String json, List expected) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertTrue(parser.parse(bytes)); + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + assertTrue(parser.parse(buffer)); + assertFalse(buffer.hasRemaining()); + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + } + + public static List validArrays() + { + List result = new ArrayList<>(); + + List expected = Collections.emptyList(); + result.add(new Object[]{"[]", expected}); + result.add(new Object[]{"[] \n", expected}); + + expected = new ArrayList<>(); + expected.add(Collections.emptyList()); + result.add(new Object[]{"[[]]", expected}); + + expected = new ArrayList<>(); + expected.add("first"); + expected.add(5D); + expected.add(null); + expected.add(true); + expected.add(false); + expected.add(new HashMap<>()); + HashMap last = new HashMap<>(); + last.put("a", new ArrayList<>()); + expected.add(last); + result.add(new Object[]{"[\"first\", 5E+0, null, true, false, {}, {\"a\":[]}]", expected}); + + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"[", "]", "[[,]", " [ 1,2,[ "}) + public void testParseInvalidArray(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> + { + parser.parse(bytes); + parser.complete(); + }); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + parser.complete(); + }); + assertTrue(parser.isEmpty()); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource("validObjects") + public void testParseObject(String json, Object expected) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertTrue(parser.parse(bytes)); + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (int i = 0; i < bytes.length; ++i) + { + byte b = bytes[i]; + if (i == bytes.length - 1) + { + assertTrue(parser.parse(new byte[]{b})); + } + else + { + assertFalse(parser.parse(new byte[]{b})); + } + } + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + } + + public static List validObjects() + { + List result = new ArrayList<>(); + + HashMap expected = new HashMap<>(); + result.add(new Object[]{"{}", expected}); + + expected = new HashMap<>(); + expected.put("", 0L); + result.add(new Object[]{"{\"\":0}", expected}); + + expected = new HashMap<>(); + expected.put("name", "value"); + result.add(new Object[]{"{ \"name\": \"value\" }", expected}); + + expected = new HashMap<>(); + expected.put("name", null); + expected.put("valid", true); + expected.put("secure", false); + expected.put("value", 42L); + result.add(new Object[]{ + "{, \"name\": null, \"valid\": true\n , \"secure\": false\r\n,\n \"value\":42, }", expected + }); + + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"{", "}", "{{,}", "{:\"s\"}", "{[]:0}", "{1:0}", " {\": 0} ", "{\"a: \"b\"}"}) + public void testParseInvalidObject(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> + { + parser.parse(bytes); + parser.complete(); + }); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + parser.complete(); + }); + assertTrue(parser.isEmpty()); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource("validNumbers") + public void testParseNumber(String json, Number expected) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + parser.parse(bytes); + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + parser.parse(buffer); + assertEquals(expected, parser.complete()); + assertFalse(buffer.hasRemaining()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + assertEquals(expected, parser.complete()); + assertTrue(parser.isEmpty()); + } + + public static List validNumbers() + { + List result = new ArrayList<>(); + + result.add(new Object[]{"0", 0L}); + result.add(new Object[]{"-0", -0L}); + result.add(new Object[]{"13\n", 13L}); + result.add(new Object[]{"-42", -42L}); + result.add(new Object[]{"123.456", 123.456D}); + result.add(new Object[]{"-234.567", -234.567D}); + result.add(new Object[]{"9e0", 9D}); + result.add(new Object[]{"8E+1\t", 80D}); + result.add(new Object[]{"-7E-2 ", -0.07D}); + result.add(new Object[]{"70.5E-1", 7.05D}); + + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"--", "--1", ".5", "e0", "1a1", "3-7", "1+2", "1e0e1", "1.2.3"}) + public void testParseInvalidNumber(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> + { + parser.parse(bytes); + parser.complete(); + }); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + parser.complete(); + }); + assertTrue(parser.isEmpty()); + } + + @Test + public void testParseObjectWithConvertor() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + CustomConvertor convertor = new CustomConvertor(); + factory.putConvertor(CustomConvertor.class.getName(), convertor); + + String json = "{" + + "\"f1\": {\"class\":\"" + CustomConvertible.class.getName() + "\", \"field\": \"value\"}," + + "\"f2\": {\"class\":\"" + CustomConvertor.class.getName() + "\"}" + + "}"; + + AsyncJSON parser = factory.newAsyncJSON(); + assertTrue(parser.parse(UTF_8.encode(json))); + Map result = parser.complete(); + + Object value1 = result.get("f1"); + assertTrue(value1 instanceof CustomConvertible); + assertEquals("value", ((CustomConvertible)value1).field); + Object value2 = result.get("f2"); + assertTrue(value2 instanceof CustomConvertor.Custom); + + assertSame(convertor, factory.removeConvertor(CustomConvertor.class.getName())); + assertTrue(parser.parse(UTF_8.encode(json))); + result = parser.complete(); + + value1 = result.get("f1"); + assertTrue(value1 instanceof CustomConvertible); + assertEquals("value", ((CustomConvertible)value1).field); + value2 = result.get("f2"); + assertTrue(value2 instanceof Map); + @SuppressWarnings("unchecked") + Map map2 = (Map)value2; + assertEquals(CustomConvertor.class.getName(), map2.get("class")); + } + + public static class CustomConvertible implements JSON.Convertible + { + private Object field; + + @Override + public void toJSON(JSON.Output out) + { + } + + @Override + public void fromJSON(Map map) + { + this.field = map.get("field"); + } + } + + public static class CustomConvertor implements JSON.Convertor + { + @Override + public void toJSON(Object obj, JSON.Output out) + { + } + + @Override + public Object fromJSON(Map map) + { + return new Custom(); + } + + public static class Custom + { + } + } + + @Test + public void testContext() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory() + { + @Override + public AsyncJSON newAsyncJSON() + { + return new AsyncJSON(this) + { + @Override + protected Map newObject(Context context) + { + if (context.depth() == 1) + { + return new CustomMap(); + } + return super.newObject(context); + } + }; + } + }; + AsyncJSON parser = factory.newAsyncJSON(); + + String json = "[{" + + "\"channel\": \"/meta/handshake\"," + + "\"version\": \"1.0\"," + + "\"supportedConnectionTypes\": [\"long-polling\"]," + + "\"advice\": {\"timeout\": 0}" + + "}]"; + + assertTrue(parser.parse(UTF_8.encode(json))); + List messages = parser.complete(); + + for (CustomMap message : messages) + { + @SuppressWarnings("unchecked") + Map advice = (Map)message.get("advice"); + assertFalse(advice instanceof CustomMap); + } + } + + public static class CustomMap extends HashMap + { + } + + @Test + public void testCaching() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + String foo = "foo"; + factory.cache(foo); + AsyncJSON parser = factory.newAsyncJSON(); + + String json = "{\"foo\": [\"foo\", \"foo\"]}"; + parser.parse(UTF_8.encode(json)); + Map object = parser.complete(); + + Map.Entry entry = object.entrySet().iterator().next(); + assertSame(foo, entry.getKey()); + @SuppressWarnings("unchecked") + List array = (List)entry.getValue(); + for (String item : array) + { + assertSame(foo, item); + } + } + + @Test + public void testEncodedCaching() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + assertFalse(factory.cache("yèck")); + String foo = "foo\\yuck"; + assertTrue(factory.cache(foo)); + AsyncJSON parser = factory.newAsyncJSON(); + + String json = "{\"foo\\\\yuck\": [\"foo\\\\yuck\", \"foo\\\\yuck\"]}"; + parser.parse(UTF_8.encode(json)); + Map object = parser.complete(); + + Map.Entry entry = object.entrySet().iterator().next(); + assertSame(foo, entry.getKey()); + @SuppressWarnings("unchecked") + List array = (List)entry.getValue(); + for (String item : array) + { + assertSame(foo, item); + } + } +} diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java b/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java index 0c7ecb2de0e..f7f14278671 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java @@ -614,7 +614,7 @@ public class ArrayTernaryTrie extends AbstractTrie boolean added = _trie.put(s, v); while (!added && _growby > 0) { - ArrayTernaryTrie bigger = new ArrayTernaryTrie<>(_trie._key.length + _growby); + ArrayTernaryTrie bigger = new ArrayTernaryTrie<>(_trie.isCaseInsensitive(), _trie._key.length + _growby); for (Map.Entry entry : _trie.entrySet()) { bigger.put(entry.getKey(), entry.getValue());