diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java index 84535027eb4..7d11bd42c94 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Headers.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2005, 2021, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2005, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -109,29 +109,69 @@ public class Headers implements Map> { } /** - * Normalize the key by converting to following form. - * First {@code char} upper case, rest lower case. - * key is presumed to be {@code ASCII}. + * {@return the normalized header name of the following form: the first + * character in upper-case, the rest in lower-case} + * The input header name is assumed to be encoded in ASCII. + * + * @implSpec + * This method is performance-sensitive; update with care. + * + * @param key an ASCII-encoded header name + * @throws NullPointerException on null {@code key} + * @throws IllegalArgumentException if {@code key} contains {@code \r} or {@code \n} */ - private String normalize(String key) { + private static String normalize(String key) { + + // Fast path for the empty key Objects.requireNonNull(key); - int len = key.length(); - if (len == 0) { + int l = key.length(); + if (l == 0) { return key; } - char[] b = key.toCharArray(); - if (b[0] >= 'a' && b[0] <= 'z') { - b[0] = (char)(b[0] - ('a' - 'A')); - } else if (b[0] == '\r' || b[0] == '\n') - throw new IllegalArgumentException("illegal character in key"); - for (int i=1; i= 'A' && b[i] <= 'Z') { - b[i] = (char) (b[i] + ('a' - 'A')); - } else if (b[i] == '\r' || b[i] == '\n') - throw new IllegalArgumentException("illegal character in key"); + // Find the first non-normalized `char` + int i = 0; + char c = key.charAt(i); + if (!(c == '\r' || c == '\n' || (c >= 'a' && c <= 'z'))) { + i++; + for (; i < l; i++) { + c = key.charAt(i); + if (c == '\r' || c == '\n' || (c >= 'A' && c <= 'Z')) { + break; + } + } } - return new String(b); + + // Fast path for the already normalized key + if (i == l) { + return key; + } + + // Upper-case the first `char` + char[] cs = key.toCharArray(); + int o = 'a' - 'A'; + if (i == 0) { + if (c == '\r' || c == '\n') { + throw new IllegalArgumentException("illegal character in key at index " + i); + } + if (c >= 'a' && c <= 'z') { + cs[0] = (char) (c - o); + } + i++; + } + + // Lower-case the secondary `char`s + for (; i < l; i++) { + c = cs[i]; + if (c >= 'A' && c <= 'Z') { + cs[i] = (char) (c + o); + } else if (c == '\r' || c == '\n') { + throw new IllegalArgumentException("illegal character in key at index " + i); + } + } + + return new String(cs); + } @Override diff --git a/test/jdk/com/sun/net/httpserver/HeadersTest.java b/test/jdk/com/sun/net/httpserver/HeadersTest.java index dffa4143c0f..c37aba0424f 100644 --- a/test/jdk/com/sun/net/httpserver/HeadersTest.java +++ b/test/jdk/com/sun/net/httpserver/HeadersTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2021, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -49,6 +49,9 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.stream.IntStream; +import java.util.stream.Stream; + import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; @@ -62,6 +65,8 @@ import static java.net.http.HttpClient.Builder.NO_PROXY; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotEquals; +import static org.testng.Assert.assertNotSame; +import static org.testng.Assert.assertSame; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -288,7 +293,14 @@ public class HeadersTest { final var list = new ArrayList(); list.add(null); assertThrows(NPE, () -> h0.putAll(Map.of("a", list))); + assertThrows(IAE, () -> h0.putAll(Map.of("a", List.of("\r")))); assertThrows(IAE, () -> h0.putAll(Map.of("a", List.of("\n")))); + assertThrows(IAE, () -> h0.putAll(Map.of("a", List.of("a\r")))); + assertThrows(IAE, () -> h0.putAll(Map.of("a", List.of("a\n")))); + assertThrows(IAE, () -> h0.putAll(Map.of("\r", List.of("a")))); + assertThrows(IAE, () -> h0.putAll(Map.of("\n", List.of("a")))); + assertThrows(IAE, () -> h0.putAll(Map.of("a\r", List.of("a")))); + assertThrows(IAE, () -> h0.putAll(Map.of("a\n", List.of("a")))); final var h1 = new Headers(); h1.put("a", List.of("1")); @@ -443,5 +455,81 @@ public class HeadersTest { List.of(List.of("1"), List.of("1", "2", "3")).forEach(v -> assertTrue(h.containsValue(v))); } + @Test + public static void testNormalizeOnNull() { + assertThrows(NullPointerException.class, () -> normalize(null)); + } + + @DataProvider + public static Object[][] illegalKeys() { + var illegalChars = List.of('\r', '\n'); + var illegalStrings = Stream + // Insert an illegal char at every possible position of following strings + .of("Ab", "ab", "_a", "2a") + .flatMap(s -> IntStream + .range(0, s.length() + 1) + .boxed() + .flatMap(i -> illegalChars + .stream() + .map(c -> s.substring(0, i) + c + s.substring(i)))); + return Stream + .concat(illegalChars.stream().map(c -> "" + c), illegalStrings) + .map(s -> new Object[]{s}) + .toArray(Object[][]::new); + } + + @Test(dataProvider = "illegalKeys") + public static void testNormalizeOnIllegalKeys(String illegalKey) { + assertThrows(IllegalArgumentException.class, () -> normalize(illegalKey)); + } + + @DataProvider + public static Object[][] normalizedKeys() { + return new Object[][]{ + // Empty string + {""}, + // Non-alpha prefix + {"_"}, + {"0"}, + {"_xy-@"}, + {"0xy-@"}, + // Upper-case prefix + {"A"}, + {"B"}, + {"Ayz-@"}, + {"Byz-@"}, + }; + } + + @Test(dataProvider = "normalizedKeys") + public static void testNormalizeOnNormalizedKeys(String normalizedKey) { + // Verify that the fast-path is taken + assertSame(normalize(normalizedKey), normalizedKey); + } + + @DataProvider + public static Object[][] notNormalizedKeys() { + return new Object[][]{ + {"a"}, + {"b"}, + {"axy-@"}, + {"bxy-@"}, + }; + } + + @Test(dataProvider = "notNormalizedKeys") + public static void testNormalizeOnNotNormalizedKeys(String notNormalizedKey) { + var normalizedKey = normalize(notNormalizedKey); + // Verify that the fast-path is *not* taken + assertNotSame(normalizedKey, notNormalizedKey); + // Verify the result + var expectedNormalizedKey = normalizedKey.substring(0, 1).toUpperCase() + normalizedKey.substring(1); + assertEquals(normalizedKey, expectedNormalizedKey); + } + + private static String normalize(String key) { + return Headers.of(key, "foo").keySet().iterator().next(); + } + // Immutability tests in UnmodifiableHeadersTest.java } diff --git a/test/micro/org/openjdk/bench/sun/net/httpserver/HeaderNormalization.java b/test/micro/org/openjdk/bench/sun/net/httpserver/HeaderNormalization.java new file mode 100644 index 00000000000..19470f77271 --- /dev/null +++ b/test/micro/org/openjdk/bench/sun/net/httpserver/HeaderNormalization.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package org.openjdk.bench.sun.net.httpserver; + +import com.sun.net.httpserver.Headers; +import org.openjdk.jmh.annotations.*; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * Benchmarks {@code jdk.httpserver} header normalization. + *

+ * You can run this benchmark as follows: + *

{@code
+ * make run-test TEST="micro:HeaderNormalization" MICRO="OPTIONS=-prof gc"
+ * }
+ */ +@BenchmarkMode(Mode.AverageTime) +@Warmup(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) +@State(org.openjdk.jmh.annotations.Scope.Thread) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(value = 3, jvmArgs = { + "--add-exports", "jdk.httpserver/com.sun.net.httpserver=ALL-UNNAMED", + "--add-opens", "jdk.httpserver/com.sun.net.httpserver=ALL-UNNAMED", +}) +public class HeaderNormalization { + + private static final Function NORMALIZE = findNormalize(); + + private static Function findNormalize() { + var lookup = MethodHandles.lookup(); + MethodHandle handle; + try { + handle = MethodHandles + .privateLookupIn(Headers.class, lookup) + .findStatic( + Headers.class, "normalize", + MethodType.methodType(String.class, String.class)); + } catch (Exception e) { + throw new RuntimeException(e); + } + return key -> { + try { + return (String) handle.invokeExact(key); + } catch (Throwable e) { + throw new RuntimeException(e); + } + }; + } + + @Param({ + "Accept-charset", // Already normalized + "4ccept-charset", // Already normalized with a non-alpha first letter + "accept-charset", // Only the first `a` must be upper-cased + "Accept-Charset", // Only `c` must be lower-cased + "ACCEPT-CHARSET", // All secondary must be lower-cased + }) + private String key; + + @Benchmark + public String n26() { + return NORMALIZE.apply(key); + } + + @Benchmark + public String n25() { + return normalize25(key); + } + + /** + * The {@code com.sun.net.httpserver.Headers::normalize} method used in Java 25 and before. + */ + private static String normalize25(String key) { + Objects.requireNonNull(key); + int len = key.length(); + if (len == 0) { + return key; + } + char[] b = key.toCharArray(); + if (b[0] >= 'a' && b[0] <= 'z') { + b[0] = (char)(b[0] - ('a' - 'A')); + } else if (b[0] == '\r' || b[0] == '\n') + throw new IllegalArgumentException("illegal character in key"); + + for (int i=1; i= 'A' && b[i] <= 'Z') { + b[i] = (char) (b[i] + ('a' - 'A')); + } else if (b[i] == '\r' || b[i] == '\n') + throw new IllegalArgumentException("illegal character in key"); + } + return new String(b); + } + +}