001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import com.soklet.exception.IllegalRequestException;
020import com.soklet.internal.spring.LinkedCaseInsensitiveMap;
021import com.soklet.internal.util.HostHeaderValidator;
022import org.jspecify.annotations.NonNull;
023import org.jspecify.annotations.Nullable;
024
025import javax.annotation.concurrent.ThreadSafe;
026import java.io.ByteArrayOutputStream;
027import java.lang.Thread.UncaughtExceptionHandler;
028import java.lang.invoke.MethodHandle;
029import java.lang.invoke.MethodHandles;
030import java.lang.invoke.MethodHandles.Lookup;
031import java.lang.invoke.MethodType;
032import java.net.InetAddress;
033import java.net.InetSocketAddress;
034import java.net.URI;
035import java.net.URISyntaxException;
036import java.net.URLEncoder;
037import java.net.UnknownHostException;
038import java.nio.charset.Charset;
039import java.nio.charset.IllegalCharsetNameException;
040import java.nio.charset.StandardCharsets;
041import java.nio.charset.UnsupportedCharsetException;
042import java.util.ArrayDeque;
043import java.util.ArrayList;
044import java.util.Arrays;
045import java.util.Collections;
046import java.util.Comparator;
047import java.util.Deque;
048import java.util.LinkedHashMap;
049import java.util.LinkedHashSet;
050import java.util.List;
051import java.util.Locale;
052import java.util.Locale.LanguageRange;
053import java.util.Map;
054import java.util.Map.Entry;
055import java.util.Optional;
056import java.util.Set;
057import java.util.concurrent.ExecutorService;
058import java.util.concurrent.Executors;
059import java.util.concurrent.ThreadFactory;
060import java.util.function.Predicate;
061import java.util.regex.Matcher;
062import java.util.regex.Pattern;
063import java.util.stream.Collectors;
064
065import static java.lang.String.format;
066import static java.util.Objects.requireNonNull;
067
068/**
069 * A non-instantiable collection of utility methods.
070 *
071 * @author <a href="https://www.revetkn.com">Mark Allen</a>
072 */
073@ThreadSafe
074public final class Utilities {
075        private static final boolean VIRTUAL_THREADS_AVAILABLE;
076        private static final byte @NonNull [] EMPTY_BYTE_ARRAY;
077        @NonNull
078        private static final Pattern HEAD_WHITESPACE_PATTERN;
079        @NonNull
080        private static final Pattern TAIL_WHITESPACE_PATTERN;
081        @NonNull
082        private static final Pattern HEADER_PERCENT_ENCODING_PATTERN;
083
084        static {
085                EMPTY_BYTE_ARRAY = new byte[0];
086
087                boolean virtualThreadsAvailable = false;
088
089                try {
090                        // Detect if Virtual Threads are usable by feature testing via reflection.
091                        // Hat tip to https://github.com/javalin/javalin for this technique
092                        Class.forName("java.lang.Thread$Builder$OfVirtual");
093                        virtualThreadsAvailable = true;
094                } catch (Exception ignored) {
095                        // We don't care why this failed, but if we're here we know JVM does not support virtual threads
096                }
097
098                VIRTUAL_THREADS_AVAILABLE = virtualThreadsAvailable;
099
100                // See https://www.regular-expressions.info/unicode.html
101                // \p{Z} or \p{Separator}: Unicode space-separator characters.
102                //
103                // First pattern matches those separator characters at the head of a string, second matches the same for tail.
104                // Useful for a "stronger" trim() function, which is almost always what we want in a web context
105                // with user-supplied input.
106                HEAD_WHITESPACE_PATTERN = Pattern.compile("^(\\p{Z})+");
107                TAIL_WHITESPACE_PATTERN = Pattern.compile("(\\p{Z})+$");
108
109                HEADER_PERCENT_ENCODING_PATTERN = Pattern.compile("%([0-9A-Fa-f]{2})");
110        }
111
112        private Utilities() {
113                // Non-instantiable
114        }
115
116        /**
117         * Does the platform runtime support virtual threads (either Java 19 and 20 w/preview enabled or Java 21+)?
118         *
119         * @return {@code true} if the runtime supports virtual threads, {@code false} otherwise
120         */
121        @NonNull
122        static Boolean virtualThreadsAvailable() {
123                return VIRTUAL_THREADS_AVAILABLE;
124        }
125
126        /**
127         * Provides a virtual thread factory if supported by the runtime.
128         * <p>
129         * In order to support Soklet users who are not yet ready to enable virtual threads (those <strong>not</strong> running either Java 19 and 20 w/preview enabled or Java 21+),
130         * we compile Soklet with a source level &lt; 19 and avoid any hard references to virtual threads by dynamically creating our {@link ThreadFactory} via {@link MethodHandle} references.
131         * <p>
132         * <strong>You should not call this method if {@link Utilities#virtualThreadsAvailable()} is {@code false}.</strong>
133         *
134         * @param threadNamePrefix         thread name prefix for the virtual thread factory builder
135         * @param uncaughtExceptionHandler uncaught exception handler for the virtual thread factory builder
136         * @return a virtual thread factory
137         * @throws IllegalStateException if the runtime environment does not support virtual threads
138         */
139        @NonNull
140        static ThreadFactory createVirtualThreadFactory(@NonNull String threadNamePrefix,
141                                                                                                                                                                                                        @NonNull UncaughtExceptionHandler uncaughtExceptionHandler) {
142                requireNonNull(threadNamePrefix);
143                requireNonNull(uncaughtExceptionHandler);
144
145                if (!virtualThreadsAvailable())
146                        throw new IllegalStateException("Virtual threads are not available. Please confirm you are using Java 19-20 with the '--enable-preview' javac parameter specified or Java 21+");
147
148                // Hat tip to https://github.com/javalin/javalin for this technique
149                Class<?> threadBuilderOfVirtualClass;
150
151                try {
152                        threadBuilderOfVirtualClass = Class.forName("java.lang.Thread$Builder$OfVirtual");
153                } catch (ClassNotFoundException e) {
154                        throw new IllegalStateException("Unable to load virtual thread builder class", e);
155                }
156
157                Lookup lookup = MethodHandles.publicLookup();
158
159                MethodHandle methodHandleThreadOfVirtual;
160                MethodHandle methodHandleThreadBuilderOfVirtualName;
161                MethodHandle methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler;
162                MethodHandle methodHandleThreadBuilderOfVirtualFactory;
163
164                try {
165                        methodHandleThreadOfVirtual = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(threadBuilderOfVirtualClass));
166                        methodHandleThreadBuilderOfVirtualName = lookup.findVirtual(threadBuilderOfVirtualClass, "name", MethodType.methodType(threadBuilderOfVirtualClass, String.class, long.class));
167                        methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler = lookup.findVirtual(threadBuilderOfVirtualClass, "uncaughtExceptionHandler", MethodType.methodType(threadBuilderOfVirtualClass, UncaughtExceptionHandler.class));
168                        methodHandleThreadBuilderOfVirtualFactory = lookup.findVirtual(threadBuilderOfVirtualClass, "factory", MethodType.methodType(ThreadFactory.class));
169                } catch (NoSuchMethodException | IllegalAccessException e) {
170                        throw new IllegalStateException("Unable to load method handle for virtual thread factory", e);
171                }
172
173                try {
174                        // Thread.ofVirtual()
175                        Object virtualThreadBuilder = methodHandleThreadOfVirtual.invoke();
176                        // .name(threadNamePrefix, start)
177                        methodHandleThreadBuilderOfVirtualName.invoke(virtualThreadBuilder, threadNamePrefix, 1);
178                        // .uncaughtExceptionHandler(uncaughtExceptionHandler)
179                        methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler.invoke(virtualThreadBuilder, uncaughtExceptionHandler);
180                        // .factory();
181                        return (ThreadFactory) methodHandleThreadBuilderOfVirtualFactory.invoke(virtualThreadBuilder);
182                } catch (Throwable t) {
183                        throw new IllegalStateException("Unable to create virtual thread factory", t);
184                }
185        }
186
187        /**
188         * Provides a virtual-thread-per-task executor service if supported by the runtime.
189         * <p>
190         * In order to support Soklet users who are not yet ready to enable virtual threads (those <strong>not</strong> running either Java 19 and 20 w/preview enabled or Java 21+),
191         * we compile Soklet with a source level &lt; 19 and avoid any hard references to virtual threads by dynamically creating our executor service via {@link MethodHandle} references.
192         * <p>
193         * <strong>You should not call this method if {@link Utilities#virtualThreadsAvailable()} is {@code false}.</strong>
194         * <pre>{@code // This method is effectively equivalent to this code
195         * return Executors.newThreadPerTaskExecutor(
196         *   Thread.ofVirtual()
197         *    .name(threadNamePrefix)
198         *    .uncaughtExceptionHandler(uncaughtExceptionHandler)
199         *    .factory()
200         * );}</pre>
201         *
202         * @param threadNamePrefix         thread name prefix for the virtual thread factory builder
203         * @param uncaughtExceptionHandler uncaught exception handler for the virtual thread factory builder
204         * @return a virtual-thread-per-task executor service
205         * @throws IllegalStateException if the runtime environment does not support virtual threads
206         */
207        @NonNull
208        static ExecutorService createVirtualThreadsNewThreadPerTaskExecutor(@NonNull String threadNamePrefix,
209                                                                                                                                                                                                                                                                                        @NonNull UncaughtExceptionHandler uncaughtExceptionHandler) {
210                requireNonNull(threadNamePrefix);
211                requireNonNull(uncaughtExceptionHandler);
212
213                if (!virtualThreadsAvailable())
214                        throw new IllegalStateException("Virtual threads are not available. Please confirm you are using Java 19-20 with the '--enable-preview' javac parameter specified or Java 21+");
215
216                ThreadFactory threadFactory = createVirtualThreadFactory(threadNamePrefix, uncaughtExceptionHandler);
217
218                Lookup lookup = MethodHandles.publicLookup();
219                MethodHandle methodHandleExecutorsNewThreadPerTaskExecutor;
220
221                try {
222                        methodHandleExecutorsNewThreadPerTaskExecutor = lookup.findStatic(Executors.class, "newThreadPerTaskExecutor", MethodType.methodType(ExecutorService.class, ThreadFactory.class));
223                } catch (NoSuchMethodException | IllegalAccessException e) {
224                        throw new IllegalStateException("Unable to load method handle for virtual thread factory", e);
225                }
226
227                try {
228                        // return Executors.newThreadPerTaskExecutor(threadFactory);
229                        return (ExecutorService) methodHandleExecutorsNewThreadPerTaskExecutor.invoke(threadFactory);
230                } catch (Throwable t) {
231                        throw new IllegalStateException("Unable to create virtual thread executor service", t);
232                }
233        }
234
235        /**
236         * Returns a shared zero-length {@code byte[]} instance.
237         * <p>
238         * Useful as a sentinel when you need a non-{@code null} byte array but have no content.
239         *
240         * @return a zero-length byte array (never {@code null})
241         */
242        static byte @NonNull [] emptyByteArray() {
243                return EMPTY_BYTE_ARRAY;
244        }
245
246        /**
247         * Parses a query string such as {@code "a=1&b=2&c=%20"} into a multimap of names to values.
248         * <p>
249         * Decodes percent-escapes using UTF-8, which is usually what you want (see {@link #extractQueryParametersFromQuery(String, QueryFormat, Charset)} if you need to specify a different charset).
250         * <p>
251         * Pairs missing a name are ignored.
252         * <p>
253         * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated).
254         *
255         * @param query       a raw query string such as {@code "a=1&b=2&c=%20"}
256         * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986
257         * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none
258         * @throws IllegalRequestException if the query string contains malformed percent-encoding
259         */
260        @NonNull
261        public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromQuery(@NonNull String query,
262                                                                                                                                                                                                                                                                                                                                                                                                                @NonNull QueryFormat queryFormat) {
263                requireNonNull(query);
264                requireNonNull(queryFormat);
265
266                return extractQueryParametersFromQuery(query, queryFormat, StandardCharsets.UTF_8);
267        }
268
269        /**
270         * Parses a query string such as {@code "a=1&b=2&c=%20"} into a multimap of names to values.
271         * <p>
272         * Decodes percent-escapes using the specified charset.
273         * <p>
274         * Pairs missing a name are ignored.
275         * <p>
276         * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated).
277         *
278         * @param query       a raw query string such as {@code "a=1&b=2&c=%20"}
279         * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986
280         * @param charset     the charset to use when decoding percent-escapes
281         * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none
282         * @throws IllegalRequestException if the query string contains malformed percent-encoding
283         */
284        @NonNull
285        public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromQuery(@NonNull String query,
286                                                                                                                                                                                                                                                                                                                                                                                                                @NonNull QueryFormat queryFormat,
287                                                                                                                                                                                                                                                                                                                                                                                                                @NonNull Charset charset) {
288                requireNonNull(query);
289                requireNonNull(queryFormat);
290                requireNonNull(charset);
291
292                // For form parameters, body will look like "One=Two&Three=Four" ...a query string.
293                String syntheticUrl = format("https://soklet.invalid?%s", query); // avoid referencing real domain
294                return extractQueryParametersFromUrl(syntheticUrl, queryFormat, charset);
295        }
296
297        @NonNull
298        static Optional<Set<@NonNull String>> extractQueryParameterValuesFromQuery(@NonNull String query,
299                                                                                                                                                                                                                                                                                                                 @NonNull String name,
300                                                                                                                                                                                                                                                                                                                 @NonNull QueryFormat queryFormat,
301                                                                                                                                                                                                                                                                                                                 @NonNull Charset charset) {
302                requireNonNull(query);
303                requireNonNull(name);
304                requireNonNull(queryFormat);
305                requireNonNull(charset);
306
307                query = trimAggressivelyToEmpty(query);
308
309                if (query.isEmpty())
310                        return Optional.empty();
311
312                String singleValue = null;
313                Set<String> values = null;
314                boolean matched = false;
315                int pairStart = 0;
316
317                while (pairStart <= query.length()) {
318                        int pairEnd = query.indexOf('&', pairStart);
319                        if (pairEnd == -1)
320                                pairEnd = query.length();
321
322                        if (pairEnd > pairStart) {
323                                int separator = query.indexOf('=', pairStart);
324                                if (separator == -1 || separator > pairEnd)
325                                        separator = pairEnd;
326
327                                String rawName = trimAggressivelyToNull(query.substring(pairStart, separator));
328
329                                if (rawName != null) {
330                                        String decodedName = decodeQueryComponent(rawName, queryFormat, charset);
331
332                                        if (decodedName.equals(name)) {
333                                                String rawValue = separator < pairEnd ? trimAggressivelyToNull(query.substring(separator + 1, pairEnd)) : null;
334
335                                                if (rawValue == null)
336                                                        rawValue = "";
337
338                                                String value = decodeQueryComponent(rawValue, queryFormat, charset);
339
340                                                if (!matched) {
341                                                        singleValue = value;
342                                                        matched = true;
343                                                } else {
344                                                        if (values == null) {
345                                                                values = new LinkedHashSet<>();
346                                                                values.add(singleValue);
347                                                        }
348
349                                                        values.add(value);
350                                                }
351                                        }
352                                }
353                        }
354
355                        if (pairEnd == query.length())
356                                break;
357
358                        pairStart = pairEnd + 1;
359                }
360
361                if (!matched)
362                        return Optional.empty();
363
364                if (values == null)
365                        return Optional.of(Set.of(singleValue));
366
367                return Optional.of(Collections.unmodifiableSet(values));
368        }
369
370        /**
371         * Parses query strings from relative or absolute URLs such as {@code "/example?a=a=1&b=2&c=%20"} or {@code "https://www.soklet.com/example?a=1&b=2&c=%20"} into a multimap of names to values.
372         * <p>
373         * Decodes percent-escapes using UTF-8, which is usually what you want (see {@link #extractQueryParametersFromUrl(String, QueryFormat, Charset)} if you need to specify a different charset).
374         * <p>
375         * Pairs missing a name are ignored.
376         * <p>
377         * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated).
378         *
379         * @param url         a relative or absolute URL/URI string
380         * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986
381         * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none
382         * @throws IllegalRequestException if the URL or query contains malformed percent-encoding
383         */
384        @NonNull
385        public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromUrl(@NonNull String url,
386                                                                                                                                                                                                                                                                                                                                                                                                        @NonNull QueryFormat queryFormat) {
387                requireNonNull(url);
388                requireNonNull(queryFormat);
389
390                return extractQueryParametersFromUrl(url, queryFormat, StandardCharsets.UTF_8);
391        }
392
393        /**
394         * Parses query strings from relative or absolute URLs such as {@code "/example?a=a=1&b=2&c=%20"} or {@code "https://www.soklet.com/example?a=1&b=2&c=%20"} into a multimap of names to values.
395         * <p>
396         * Decodes percent-escapes using the specified charset.
397         * <p>
398         * Pairs missing a name are ignored.
399         * <p>
400         * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated).
401         *
402         * @param url         a relative or absolute URL/URI string
403         * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986
404         * @param charset     the charset to use when decoding percent-escapes
405         * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none
406         * @throws IllegalRequestException if the URL or query contains malformed percent-encoding
407         */
408        @NonNull
409        public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromUrl(@NonNull String url,
410                                                                                                                                                                                                                                                                                                                                                                                                        @NonNull QueryFormat queryFormat,
411                                                                                                                                                                                                                                                                                                                                                                                                        @NonNull Charset charset) {
412                requireNonNull(url);
413                requireNonNull(queryFormat);
414                requireNonNull(charset);
415
416                URI uri;
417
418                try {
419                        uri = new URI(url);
420                } catch (URISyntaxException e) {
421                        throw new IllegalRequestException(format("Invalid URL '%s'", url), e);
422                }
423
424                String query = trimAggressivelyToNull(uri.getRawQuery());
425
426                if (query == null)
427                        return Map.of();
428
429                Map<String, Set<String>> queryParameters = new LinkedHashMap<>();
430                for (String pair : query.split("&", -1)) {
431                        if (pair.isEmpty())
432                                continue;
433
434                        String[] nv = pair.split("=", 2);
435                        String rawName = trimAggressivelyToNull(nv.length > 0 ? nv[0] : null);
436                        String rawValue = trimAggressivelyToNull(nv.length > 1 ? nv[1] : null);
437
438                        if (rawName == null)
439                                continue;
440
441                        // Preserve empty values; it's what users probably expect
442                        if (rawValue == null)
443                                rawValue = "";
444
445                        String name = decodeQueryComponent(rawName, queryFormat, charset);
446                        String value = decodeQueryComponent(rawValue, queryFormat, charset);
447
448                        addStringValue(queryParameters, name, value);
449                }
450
451                freezeStringValueSets(queryParameters);
452                return queryParameters;
453        }
454
455        /**
456         * Decodes a single key or value using the given mode and charset.
457         */
458        @NonNull
459        private static String decodeQueryComponent(@NonNull String string,
460                                                                                                                                                                                 @NonNull QueryFormat queryFormat,
461                                                                                                                                                                                 @NonNull Charset charset) {
462                requireNonNull(string);
463                requireNonNull(queryFormat);
464                requireNonNull(charset);
465
466                if (string.isEmpty())
467                        return "";
468
469                // Step 1: in form mode, '+' means space
470                String prepped = (queryFormat == QueryFormat.X_WWW_FORM_URLENCODED) ? string.replace('+', ' ') : string;
471                // Step 2: percent-decode bytes, then interpret bytes with the provided charset
472                return percentDecode(prepped, charset);
473        }
474
475        /**
476         * Percent-decodes a string into bytes, then constructs a String using the provided charset.
477         * One pass only: invalid %xy sequences trigger an exception.
478         */
479        @NonNull
480        private static String percentDecode(@NonNull String s, @NonNull Charset charset) {
481                requireNonNull(s);
482                requireNonNull(charset);
483
484                if (s.isEmpty())
485                        return "";
486
487                StringBuilder sb = new StringBuilder(s.length());
488                ByteArrayOutputStream bytes = new ByteArrayOutputStream();
489
490                for (int i = 0; i < s.length(); ) {
491                        char c = s.charAt(i);
492
493                        if (c == '%') {
494                                // Consume one or more consecutive %xx triplets into bytes
495                                bytes.reset();
496                                int j = i;
497
498                                while (j < s.length() && s.charAt(j) == '%') {
499                                        if (j + 2 >= s.length())
500                                                throw new IllegalRequestException("Invalid percent-encoding in URL component");
501
502                                        int hi = hex(s.charAt(j + 1));
503                                        int lo = hex(s.charAt(j + 2));
504                                        if (hi < 0 || lo < 0)
505                                                throw new IllegalRequestException("Invalid percent-encoding in URL component");
506
507                                        bytes.write((hi << 4) | lo);
508                                        j += 3;
509                                }
510
511                                sb.append(new String(bytes.toByteArray(), charset));
512                                i = j;
513                                continue;
514                        }
515
516                        // Non-'%' char: append it as-is.
517                        // This preserves surrogate pairs naturally as the loop hits both chars.
518                        sb.append(c);
519                        i++;
520                }
521
522                return sb.toString();
523        }
524
525        static void validatePercentEncodingInUrlComponent(@NonNull String urlComponent) {
526                requireNonNull(urlComponent);
527
528                for (int i = 0; i < urlComponent.length(); i++) {
529                        if (urlComponent.charAt(i) != '%')
530                                continue;
531
532                        if (i + 2 >= urlComponent.length())
533                                throw new IllegalRequestException("Invalid percent-encoding in URL component");
534
535                        int hi = hex(urlComponent.charAt(i + 1));
536                        int lo = hex(urlComponent.charAt(i + 2));
537                        if (hi < 0 || lo < 0)
538                                throw new IllegalRequestException("Invalid percent-encoding in URL component");
539
540                        i += 2;
541                }
542        }
543
544        private static int hex(char c) {
545                if (c >= '0' && c <= '9') return c - '0';
546                if (c >= 'A' && c <= 'F') return c - 'A' + 10;
547                if (c >= 'a' && c <= 'f') return c - 'a' + 10;
548                return -1;
549        }
550
551        /**
552         * Parses {@code Cookie} request headers into a map of cookie names to values.
553         * <p>
554         * Header name matching is case-insensitive ({@code "Cookie"} vs {@code "cookie"}), but <em>cookie names are case-sensitive</em>.
555         * Values are parsed per the following liberal rules:
556         * <ul>
557         *   <li>Components are split on {@code ';'} unless inside a quoted string.</li>
558         *   <li>Quoted values have surrounding quotes removed and common backslash escapes unescaped.</li>
559         *   <li>Percent-escapes are decoded as UTF-8. {@code '+'} is <strong>not</strong> treated specially.</li>
560         * </ul>
561         * Multiple occurrences of the same cookie name are collected into a {@link Set} in insertion order.
562         *
563         * @param headers request headers as a multimap of header name to values (must be non-{@code null})
564         * @return a map of cookie name to distinct values; empty if no valid cookies are present
565         */
566        @NonNull
567        public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractCookiesFromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
568                requireNonNull(headers);
569
570                // Cookie *names* must be case-sensitive; keep LinkedHashMap (NOT case-insensitive)
571                Map<String, Set<String>> cookies = new LinkedHashMap<>();
572
573                for (Entry<String, Set<String>> entry : headers.entrySet()) {
574                        String headerName = entry.getKey();
575                        if (headerName == null || !"cookie".equalsIgnoreCase(headerName.trim()))
576                                continue;
577
578                        Set<String> values = entry.getValue();
579                        if (values == null) continue;
580
581                        for (String headerValue : values) {
582                                headerValue = trimAggressivelyToNull(headerValue);
583                                if (headerValue == null) continue;
584
585                                // Split on ';' only when NOT inside a quoted string
586                                List<String> cookieComponents = splitCookieHeaderRespectingQuotes(headerValue);
587
588                                for (String cookieComponent : cookieComponents) {
589                                        cookieComponent = trimAggressivelyToNull(cookieComponent);
590                                        if (cookieComponent == null) continue;
591
592                                        String[] cookiePair = cookieComponent.split("=", 2);
593                                        String rawName = trimAggressivelyToNull(cookiePair[0]);
594                                        String rawValue = (cookiePair.length == 2 ? trimAggressivelyToNull(cookiePair[1]) : null);
595
596                                        if (rawName == null) continue;
597
598                                        // DO NOT decode the name; cookie names are case-sensitive and rarely encoded
599                                        String cookieName = rawName;
600
601                                        String cookieValue = null;
602                                        if (rawValue != null) {
603                                                // If it's quoted, unquote+unescape first, then percent-decode (still no '+' -> space)
604                                                String unquoted = unquoteCookieValueIfNeeded(rawValue);
605                                                cookieValue = percentDecodeCookieValue(unquoted);
606                                        }
607
608                                        cookies.putIfAbsent(cookieName, Set.of());
609                                        if (cookieValue != null)
610                                                addStringValue(cookies, cookieName, cookieValue);
611                                }
612                        }
613                }
614
615                freezeStringValueSets(cookies);
616                return cookies;
617        }
618
619        /**
620         * Percent-decodes %HH to bytes->UTF-8. Does NOT treat '+' specially.
621         */
622        @NonNull
623        private static String percentDecodeCookieValue(@NonNull String cookieValue) {
624                requireNonNull(cookieValue);
625
626                ByteArrayOutputStream out = new ByteArrayOutputStream(cookieValue.length());
627
628                for (int i = 0; i < cookieValue.length(); ) {
629                        char c = cookieValue.charAt(i);
630                        if (c == '%') {
631                                if (i + 2 >= cookieValue.length())
632                                        throw new IllegalRequestException("Invalid percent-encoding in Cookie header");
633
634                                int hi = Character.digit(cookieValue.charAt(i + 1), 16);
635                                int lo = Character.digit(cookieValue.charAt(i + 2), 16);
636                                if (hi < 0 || lo < 0)
637                                        throw new IllegalRequestException("Invalid percent-encoding in Cookie header");
638
639                                out.write((hi << 4) + lo);
640                                i += 3;
641                                continue;
642                        }
643
644                        String rawCharacter;
645
646                        if (Character.isHighSurrogate(c) && i + 1 < cookieValue.length() && Character.isLowSurrogate(cookieValue.charAt(i + 1))) {
647                                rawCharacter = cookieValue.substring(i, i + 2);
648                                i += 2;
649                        } else {
650                                rawCharacter = Character.toString(c);
651                                i++;
652                        }
653
654                        byte[] encoded = rawCharacter.getBytes(StandardCharsets.UTF_8);
655                        out.write(encoded, 0, encoded.length);
656                }
657
658                return out.toString(StandardCharsets.UTF_8);
659        }
660
661        /**
662         * Splits a Cookie header string into components on ';' but ONLY when not inside a quoted value.
663         * Supports backslash-escaped quotes within quoted strings.
664         */
665        private static List<@NonNull String> splitCookieHeaderRespectingQuotes(@NonNull String headerValue) {
666                List<String> parts = new ArrayList<>();
667                StringBuilder cur = new StringBuilder(headerValue.length());
668                boolean inQuotes = false;
669                boolean escape = false;
670
671                for (int i = 0; i < headerValue.length(); i++) {
672                        char c = headerValue.charAt(i);
673
674                        if (escape) {
675                                // keep escaped char literally (e.g., \" \; \\)
676                                cur.append(c);
677                                escape = false;
678                                continue;
679                        }
680
681                        if (c == '\\') {
682                                escape = true;
683                                // keep the backslash for now; unquote step will handle unescaping
684                                cur.append(c);
685                                continue;
686                        }
687
688                        if (c == '"') {
689                                inQuotes = !inQuotes;
690                                cur.append(c);
691                                continue;
692                        }
693
694                        if (c == ';' && !inQuotes) {
695                                parts.add(cur.toString());
696                                cur.setLength(0);
697                                continue;
698                        }
699
700                        cur.append(c);
701                }
702
703                if (cur.length() > 0)
704                        parts.add(cur.toString());
705
706                return parts;
707        }
708
709        /**
710         * If the cookie value is a quoted-string, remove surrounding quotes and unescape \" \\ and \; .
711         * Otherwise returns the input as-is.
712         */
713        @NonNull
714        private static String unquoteCookieValueIfNeeded(@NonNull String rawValue) {
715                requireNonNull(rawValue);
716
717                if (rawValue.length() >= 2 && rawValue.charAt(0) == '"' && rawValue.charAt(rawValue.length() - 1) == '"') {
718                        // Strip the surrounding quotes
719                        String inner = rawValue.substring(1, rawValue.length() - 1);
720
721                        // Unescape \" \\ and \; (common patterns seen in the wild)
722                        // Order matters: unescape backslash-escape sequences, then leave other chars intact.
723                        StringBuilder sb = new StringBuilder(inner.length());
724                        boolean escape = false;
725
726                        for (int i = 0; i < inner.length(); i++) {
727                                char c = inner.charAt(i);
728                                if (escape) {
729                                        // Keep the escaped character literally (liberal in what we accept).
730                                        sb.append(c);
731
732                                        escape = false;
733                                } else if (c == '\\') {
734                                        escape = true;
735                                } else {
736                                        sb.append(c);
737                                }
738                        }
739
740                        // If string ended with a dangling backslash, keep it literally
741                        if (escape)
742                                sb.append('\\');
743
744                        return sb.toString();
745                }
746
747                return rawValue;
748        }
749
750        /**
751         * Normalizes a URL or path into a canonical request path and optionally performs percent-decoding on the path.
752         * <p>
753         * For example, {@code "https://www.soklet.com/ab%20c?one=two"} would be normalized to {@code "/ab c"}.
754         * <p>
755         * The {@code OPTIONS *} special case returns {@code "*"}.
756         * <p>
757         * Behavior:
758         * <ul>
759         *   <li>If input starts with {@code http://} or {@code https://}, the path portion is extracted.</li>
760         *   <li>Ensures the result begins with {@code '/'}.</li>
761         *   <li>Removes any trailing {@code '/'} (except for the root path {@code '/'}).</li>
762         *   <li>Safely normalizes path traversals, e.g. path {@code '/a/../b'} would be normalized to {@code '/b'}</li>
763         *   <li>Strips any query string.</li>
764         *   <li>Applies aggressive trimming of Unicode whitespace.</li>
765         *   <li>Rejects malformed percent-encoding when decoding is enabled.</li>
766         * </ul>
767         *
768         * @param url             a URL or path to normalize
769         * @param performDecoding {@code true} if decoding should be performed on the path (e.g. replace {@code %20} with a space character), {@code false} otherwise
770         * @return the normalized path, {@code "/"} for empty input
771         */
772        @NonNull
773        public static String extractPathFromUrl(@NonNull String url,
774                                                                                                                                                                        @NonNull Boolean performDecoding) {
775                requireNonNull(url);
776
777                url = trimAggressivelyToEmpty(url);
778
779                // Special case for OPTIONS * requests
780                if (url.equals("*"))
781                        return "*";
782
783                // Parse with java.net.URI to isolate raw path; then percent-decode only the path
784                try {
785                        URI uri = new URI(url);
786
787                        String rawPath = uri.getRawPath(); // null => "/"
788
789                        if (rawPath == null || rawPath.isEmpty())
790                                rawPath = "/";
791
792                        if (!performDecoding)
793                                return rawPath;
794
795                        String decodedPath = percentDecode(rawPath, StandardCharsets.UTF_8);
796
797                        // Sanitize path traversal (e.g. /a/../b -> /b)
798                        decodedPath = removeDotSegments(decodedPath);
799
800                        // Normalize trailing slashes like normalizedPathForUrl currently does
801                        if (!decodedPath.startsWith("/"))
802                                decodedPath = "/" + decodedPath;
803
804                        if (!"/".equals(decodedPath))
805                                while (decodedPath.endsWith("/"))
806                                        decodedPath = decodedPath.substring(0, decodedPath.length() - 1);
807
808                        return decodedPath;
809                } catch (URISyntaxException e) {
810                        // If it's not an absolute URL, treat the whole string as a path and percent-decode
811                        String path = url;
812                        int q = path.indexOf('?');
813
814                        if (q != -1)
815                                path = path.substring(0, q);
816
817                        if (path.isEmpty())
818                                path = "/";
819
820                        if (!performDecoding)
821                                return path;
822
823                        String decodedPath = percentDecode(path, StandardCharsets.UTF_8);
824
825                        // Sanitize path traversal (e.g. /a/../b -> /b)
826                        decodedPath = removeDotSegments(decodedPath);
827
828                        if (!decodedPath.startsWith("/"))
829                                decodedPath = "/" + decodedPath;
830
831                        if (!"/".equals(decodedPath))
832                                while (decodedPath.endsWith("/"))
833                                        decodedPath = decodedPath.substring(0, decodedPath.length() - 1);
834
835                        return decodedPath;
836                }
837        }
838
839        /**
840         * Extracts the raw (un-decoded) query component from a URL.
841         * <p>
842         * For example, {@code "/path?a=b&c=d%20e"} would return {@code "a=b&c=d%20e"}.
843         *
844         * @param url a raw URL or path
845         * @return the raw query component, or {@link Optional#empty()} if none
846         */
847        @NonNull
848        public static Optional<String> extractRawQueryFromUrl(@NonNull String url) {
849                requireNonNull(url);
850
851                url = trimAggressivelyToEmpty(url);
852
853                if ("*".equals(url))
854                        return Optional.empty();
855
856                try {
857                        URI uri = new URI(url);
858                        return Optional.ofNullable(trimAggressivelyToNull(uri.getRawQuery()));
859                } catch (URISyntaxException e) {
860                        // Not a valid URI, try to extract query manually
861                        int q = url.indexOf('?');
862                        if (q == -1)
863                                return Optional.empty();
864
865                        String query = trimAggressivelyToNull(url.substring(q + 1));
866                        return Optional.ofNullable(query);
867                }
868        }
869
870        @NonNull
871        static Optional<String> extractRawQueryFromUrlStrict(@NonNull String url) {
872                requireNonNull(url);
873
874                url = trimAggressivelyToEmpty(url);
875
876                if ("*".equals(url))
877                        return Optional.empty();
878
879                try {
880                        URI uri = new URI(url);
881                        return Optional.ofNullable(trimAggressivelyToNull(uri.getRawQuery()));
882                } catch (URISyntaxException e) {
883                        throw new IllegalRequestException(format("Invalid URL '%s'", url), e);
884                }
885        }
886
887        /**
888         * Encodes decoded query parameters into a raw query string.
889         * <p>
890         * For example, given {@code {a=[b], c=[d e]}} and {@link QueryFormat#RFC_3986_STRICT},
891         * returns {@code "a=b&c=d%20e"}.
892         *
893         * @param queryParameters the decoded query parameters
894         * @param queryFormat     the encoding strategy
895         * @return the encoded query string, or the empty string if no parameters
896         */
897        @NonNull
898        public static String encodeQueryParameters(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> queryParameters,
899                                                                                                                                                                                 @NonNull QueryFormat queryFormat) {
900                requireNonNull(queryParameters);
901                requireNonNull(queryFormat);
902
903                if (queryParameters.isEmpty())
904                        return "";
905
906                StringBuilder sb = new StringBuilder();
907                boolean first = true;
908
909                for (Entry<String, Set<String>> entry : queryParameters.entrySet()) {
910                        String encodedName = encodeQueryComponent(entry.getKey(), queryFormat);
911
912                        for (String value : entry.getValue()) {
913                                if (!first)
914                                        sb.append('&');
915
916                                sb.append(encodedName);
917                                sb.append('=');
918                                sb.append(encodeQueryComponent(value, queryFormat));
919
920                                first = false;
921                        }
922                }
923
924                return sb.toString();
925        }
926
927        @NonNull
928        static String encodeQueryComponent(@NonNull String queryComponent,
929                                                                                                                                                 @NonNull QueryFormat queryFormat) {
930                requireNonNull(queryComponent);
931                requireNonNull(queryFormat);
932
933                String encoded = URLEncoder.encode(queryComponent, StandardCharsets.UTF_8);
934
935                if (queryFormat == QueryFormat.RFC_3986_STRICT)
936                        encoded = encoded.replace("+", "%20");
937
938                return encoded;
939        }
940
941        @NonNull
942        static String encodePath(@NonNull String path) {
943                requireNonNull(path);
944
945                if ("*".equals(path))
946                        return path;
947
948                // Encode each path segment individually, preserving '/' separators.
949                // RFC 3986 is used for path encoding (spaces as %20, not +).
950                return Arrays.stream(path.split("/", -1))
951                                .map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"))
952                                .collect(Collectors.joining("/"));
953        }
954
955        /**
956         * Parses an {@code Accept-Language} header value into a best-effort ordered list of {@link Locale}s.
957         * <p>
958         * Quality weights are honored by {@link Locale.LanguageRange#parse(String)}; results are then converted to
959         * {@link Locale} instances that represent the client-supplied language tags. Wildcard ranges are ignored unless
960         * they include a language component (e.g. {@code en-*} becomes {@code en}). On parse failure, an empty list is
961         * returned.
962         *
963         * @param acceptLanguageHeaderValue the raw header value (must be non-{@code null})
964         * @return locales in descending preference order; empty if none could be resolved
965         */
966        @NonNull
967        public static List<@NonNull Locale> extractLocalesFromAcceptLanguageHeaderValue(@NonNull String acceptLanguageHeaderValue) {
968                requireNonNull(acceptLanguageHeaderValue);
969
970                try {
971                        List<LanguageRange> languageRanges = LanguageRange.parse(acceptLanguageHeaderValue);
972                        List<Locale> locales = new ArrayList<>(languageRanges.size());
973
974                        for (LanguageRange languageRange : languageRanges) {
975                                if (!(languageRange.getWeight() > 0.0))
976                                        continue;
977
978                                String range = languageRange.getRange();
979                                String languageTag = range;
980
981                                if (range.indexOf('*') != -1) {
982                                        int wildcardIndex = range.indexOf('*');
983
984                                        if (wildcardIndex == 0)
985                                                continue;
986
987                                        int languageEndIndex = range.indexOf('-');
988
989                                        if (languageEndIndex == -1 || languageEndIndex > wildcardIndex)
990                                                languageEndIndex = wildcardIndex;
991
992                                        languageTag = range.substring(0, languageEndIndex);
993                                }
994
995                                if (languageTag.isBlank())
996                                        continue;
997
998                                Locale locale = Locale.forLanguageTag(languageTag);
999
1000                                if (!locale.getLanguage().isBlank() && !locales.contains(locale))
1001                                        locales.add(locale);
1002                        }
1003
1004                        return Collections.unmodifiableList(locales);
1005                } catch (Exception ignored) {
1006                        return List.of();
1007                }
1008        }
1009
1010        /**
1011         * Parses an {@code Accept} header value into a best-effort ordered list of {@link MediaRange}s.
1012         * <p>
1013         * Media ranges are ordered by descending {@code q} weight, then by descending specificity:
1014         * a concrete {@code type/subtype} outranks {@code type/*}, which outranks {@code *}{@code /*};
1015         * within the same wildcard specificity, ranges with more media-type parameters outrank ranges with fewer.
1016         * When both are equal, original header order is preserved (the sort is stable). See
1017         * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">RFC 9110, Section 12.5.1</a>.
1018         * Malformed media ranges are skipped.
1019         *
1020         * @param acceptHeaderValue the raw header value (must be non-{@code null})
1021         * @return media ranges in descending preference order; empty if none could be resolved
1022         */
1023        @NonNull
1024        public static List<@NonNull MediaRange> extractMediaRangesFromAcceptHeaderValue(@NonNull String acceptHeaderValue) {
1025                requireNonNull(acceptHeaderValue);
1026
1027                List<MediaRange> mediaRanges = new ArrayList<>(4);
1028
1029                for (String fragment : splitCommaAware(acceptHeaderValue)) {
1030                        MediaRange mediaRange = MediaRange.fromHeaderRepresentation(fragment).orElse(null);
1031
1032                        if (mediaRange != null)
1033                                mediaRanges.add(mediaRange);
1034                }
1035
1036                // Stable sort: q weight descending, then specificity descending; original order breaks ties
1037                mediaRanges.sort(Comparator
1038                                .comparing(MediaRange::getQuality, Comparator.reverseOrder())
1039                                .thenComparing(Utilities::mediaRangeWildcardSpecificity, Comparator.reverseOrder())
1040                                .thenComparing(Utilities::mediaRangeParameterSpecificity, Comparator.reverseOrder()));
1041
1042                return Collections.unmodifiableList(mediaRanges);
1043        }
1044
1045        @NonNull
1046        private static Integer mediaRangeWildcardSpecificity(@NonNull MediaRange mediaRange) {
1047                requireNonNull(mediaRange);
1048
1049                if (mediaRange.isWildcardType())
1050                        return 0;
1051
1052                if (mediaRange.isWildcardSubtype())
1053                        return 1;
1054
1055                return 2;
1056        }
1057
1058        @NonNull
1059        private static Integer mediaRangeParameterSpecificity(@NonNull MediaRange mediaRange) {
1060                requireNonNull(mediaRange);
1061                return mediaRange.getParameters().size();
1062        }
1063
1064        @Nullable
1065        private static String firstHeaderValue(@Nullable Set<String> headerValues) {
1066                if (headerValues == null || headerValues.isEmpty())
1067                        return null;
1068
1069                for (String value : headerValues) {
1070                        String trimmed = trimAggressivelyToNull(value);
1071                        if (trimmed == null)
1072                                continue;
1073
1074                        for (String part : splitCommaAware(trimmed)) {
1075                                String candidate = trimAggressivelyToNull(part);
1076                                if (candidate != null)
1077                                        return candidate;
1078                        }
1079                }
1080
1081                return null;
1082        }
1083
1084        /**
1085         * Best-effort attempt to determine a client's effective origin by examining request headers.
1086         * <p>
1087         * An effective origin in this context is defined as {@code <scheme>://host<:optional port>}, but no path or query components.
1088         * <p>
1089         * Soklet is generally the "last hop" behind a load balancer/reverse proxy but may also be accessed directly by clients.
1090         * <p>
1091         * Normally a load balancer/reverse proxy/other upstream proxies will provide information about the true source of the
1092         * request through headers like the following:
1093         * <ul>
1094         *   <li>{@code Host}</li>
1095         *   <li>{@code Forwarded}</li>
1096         *   <li>{@code Origin}</li>
1097         *   <li>{@code X-Forwarded-Proto}</li>
1098         *   <li>{@code X-Forwarded-Protocol}</li>
1099         *   <li>{@code X-Url-Scheme}</li>
1100         *   <li>{@code Front-End-Https}</li>
1101         *   <li>{@code X-Forwarded-Ssl}</li>
1102         *   <li>{@code X-Forwarded-Host}</li>
1103         *   <li>{@code X-Forwarded-Port}</li>
1104         * </ul>
1105         * <p>
1106         * This method may take these and other headers into account when determining an effective origin.
1107         * <p>
1108         * For example, the following would be legal effective origins returned from this method:
1109         * <ul>
1110         *   <li>{@code https://www.soklet.com}</li>
1111         *   <li>{@code http://www.fake.com:1234}</li>
1112         * </ul>
1113         * <p>
1114         * The following would NOT be legal effective origins:
1115         * <ul>
1116         *   <li>{@code www.soklet.com} (missing protocol) </li>
1117         *   <li>{@code https://www.soklet.com/} (trailing slash)</li>
1118         *   <li>{@code https://www.soklet.com/test} (trailing slash, path)</li>
1119         *   <li>{@code https://www.soklet.com/test?abc=1234} (trailing slash, path, query)</li>
1120         * </ul>
1121         * <p>
1122         * {@code Origin} is treated as a fallback signal only and will not override a conflicting {@code Host} or forwarded host value.
1123         * <p>
1124         * Forwarded headers are only used when permitted by {@link EffectiveOriginResolver.TrustPolicy}. When using
1125         * {@link EffectiveOriginResolver.TrustPolicy#TRUST_PROXY_ALLOWLIST}, you must provide a trusted proxy predicate or allowlist.
1126         * If the remote address is missing or not trusted, forwarded headers are ignored.
1127         * <p>
1128         * Extraction order is: trusted forwarded headers → {@code Host} → (optional) {@code Origin} fallback.
1129         * If {@link EffectiveOriginResolver#allowOriginFallback(Boolean)} is unset, {@code Origin} fallback is enabled only for
1130         * {@link EffectiveOriginResolver.TrustPolicy#TRUST_ALL}.
1131         *
1132         * @param effectiveOriginResolver request headers and trust settings
1133         * @return the effective origin, or {@link Optional#empty()} if it could not be determined
1134         */
1135        @NonNull
1136        static Optional<String> extractEffectiveOrigin(@NonNull EffectiveOriginResolver effectiveOriginResolver) {
1137                requireNonNull(effectiveOriginResolver);
1138                requireNonNull(effectiveOriginResolver.getHeaders());
1139                requireNonNull(effectiveOriginResolver.getTrustPolicy());
1140
1141                if (effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST
1142                                && effectiveOriginResolver.getTrustedProxyPredicate() == null) {
1143                        throw new IllegalStateException(format("%s policy requires a trusted proxy predicate or allowlist.",
1144                                        EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST));
1145                }
1146
1147                Map<String, Set<String>> headers = effectiveOriginResolver.getHeaders();
1148                boolean trustForwardedHeaders = shouldTrustForwardedHeaders(effectiveOriginResolver);
1149                boolean allowOriginFallback = effectiveOriginResolver.getAllowOriginFallback() != null
1150                                ? effectiveOriginResolver.getAllowOriginFallback()
1151                                : effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL;
1152
1153                // Host                   developer.mozilla.org OR developer.mozilla.org:443 OR [2001:db8::1]:8443
1154                // Forwarded              by=<identifier>;for=<identifier>;host=<host>;proto=<http|https> (can be repeated if comma-separated, e.g. for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89)
1155                // Origin                 null OR <scheme>://<hostname> OR <scheme>://<hostname>:<port>
1156                // X-Forwarded-Proto      https
1157                // X-Forwarded-Protocol   https (Microsoft's alternate name)
1158                // X-Url-Scheme           https (Microsoft's alternate name)
1159                // Front-End-Https        on (Microsoft's alternate name)
1160                // X-Forwarded-Ssl        on (Microsoft's alternate name)
1161                // X-Forwarded-Host       id42.example-cdn.com
1162                // X-Forwarded-Port       443
1163
1164                String protocol = null;
1165                String host = null;
1166                String portAsString = null;
1167                Boolean portExplicit = false;
1168
1169                // Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
1170                if (trustForwardedHeaders) {
1171                        Set<String> forwardedHeaders = headers.get("Forwarded");
1172                        if (forwardedHeaders != null) {
1173                                forwardedHeaderLoop:
1174                                for (String forwardedHeader : forwardedHeaders) {
1175                                        String trimmed = trimAggressivelyToNull(forwardedHeader);
1176                                        if (trimmed == null)
1177                                                continue;
1178
1179                                        for (String forwardedEntry : splitCommaAware(trimmed)) {
1180                                                String entry = trimAggressivelyToNull(forwardedEntry);
1181                                                if (entry == null)
1182                                                        continue;
1183
1184                                                String entryHost = null;
1185                                                String entryProtocol = null;
1186                                                String entryPortAsString = null;
1187                                                Boolean entryPortExplicit = false;
1188
1189                                                // Each field component might look like "by=<identifier>"
1190                                                List<String> forwardedHeaderFieldComponents = splitSemicolonAware(entry);
1191                                                for (String forwardedHeaderFieldComponent : forwardedHeaderFieldComponents) {
1192                                                        forwardedHeaderFieldComponent = trimAggressivelyToNull(forwardedHeaderFieldComponent);
1193                                                        if (forwardedHeaderFieldComponent == null)
1194                                                                continue;
1195
1196                                                        // Break "by=<identifier>" into "by" and "<identifier>" pieces
1197                                                        String[] forwardedHeaderFieldNameAndValue = forwardedHeaderFieldComponent.split(Pattern.quote("=" /* escape special Regex char */), 2);
1198                                                        if (forwardedHeaderFieldNameAndValue.length != 2)
1199                                                                continue;
1200
1201                                                        String name = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[0]);
1202                                                        String value = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[1]);
1203                                                        if (name == null || value == null)
1204                                                                continue;
1205
1206                                                        if ("host".equalsIgnoreCase(name)) {
1207                                                                if (entryHost == null) {
1208                                                                        HostPort hostPort = parseForwardedHostPort(value).orElse(null);
1209
1210                                                                        if (hostPort != null) {
1211                                                                                entryHost = hostPort.getHost();
1212
1213                                                                                if (hostPort.getPort().isPresent()) {
1214                                                                                        entryPortAsString = String.valueOf(hostPort.getPort().get());
1215                                                                                        entryPortExplicit = true;
1216                                                                                }
1217                                                                        }
1218                                                                }
1219                                                        } else if ("proto".equalsIgnoreCase(name)) {
1220                                                                if (entryProtocol == null)
1221                                                                        entryProtocol = stripOptionalQuotes(value);
1222                                                        }
1223                                                }
1224
1225                                                if (entryHost != null || entryProtocol != null) {
1226                                                        host = entryHost;
1227                                                        protocol = entryProtocol;
1228                                                        if (entryPortAsString != null) {
1229                                                                portAsString = entryPortAsString;
1230                                                                portExplicit = entryPortExplicit;
1231                                                        }
1232                                                        break forwardedHeaderLoop;
1233                                                }
1234                                        }
1235                                }
1236                        }
1237                }
1238
1239                // X-Forwarded-Proto: https
1240                if (trustForwardedHeaders && protocol == null) {
1241                        String xForwardedProtoHeader = firstHeaderValue(headers.get("X-Forwarded-Proto"));
1242                        if (xForwardedProtoHeader != null)
1243                                protocol = stripOptionalQuotes(xForwardedProtoHeader);
1244                }
1245
1246                // X-Forwarded-Protocol: https (Microsoft's alternate name)
1247                if (trustForwardedHeaders && protocol == null) {
1248                        String xForwardedProtocolHeader = firstHeaderValue(headers.get("X-Forwarded-Protocol"));
1249                        if (xForwardedProtocolHeader != null)
1250                                protocol = stripOptionalQuotes(xForwardedProtocolHeader);
1251                }
1252
1253                // X-Url-Scheme: https (Microsoft's alternate name)
1254                if (trustForwardedHeaders && protocol == null) {
1255                        String xUrlSchemeHeader = firstHeaderValue(headers.get("X-Url-Scheme"));
1256                        if (xUrlSchemeHeader != null)
1257                                protocol = stripOptionalQuotes(xUrlSchemeHeader);
1258                }
1259
1260                // Front-End-Https: on (Microsoft's alternate name)
1261                if (trustForwardedHeaders && protocol == null) {
1262                        String frontEndHttpsHeader = firstHeaderValue(headers.get("Front-End-Https"));
1263                        if (frontEndHttpsHeader != null)
1264                                protocol = "on".equalsIgnoreCase(frontEndHttpsHeader) ? "https" : "http";
1265                }
1266
1267                // X-Forwarded-Ssl: on (Microsoft's alternate name)
1268                if (trustForwardedHeaders && protocol == null) {
1269                        String xForwardedSslHeader = firstHeaderValue(headers.get("X-Forwarded-Ssl"));
1270                        if (xForwardedSslHeader != null)
1271                                protocol = "on".equalsIgnoreCase(xForwardedSslHeader) ? "https" : "http";
1272                }
1273
1274                // X-Forwarded-Host: id42.example-cdn.com (or with port / IPv6)
1275                if (trustForwardedHeaders && host == null) {
1276                        String xForwardedHostHeader = firstHeaderValue(headers.get("X-Forwarded-Host"));
1277                        if (xForwardedHostHeader != null) {
1278                                HostPort hostPort = parseForwardedHostPort(xForwardedHostHeader).orElse(null);
1279
1280                                if (hostPort != null) {
1281                                        host = hostPort.getHost();
1282
1283                                        if (hostPort.getPort().isPresent() && portAsString == null) {
1284                                                portAsString = String.valueOf(hostPort.getPort().get());
1285                                                portExplicit = true;
1286                                        }
1287                                }
1288                        }
1289                }
1290
1291                // X-Forwarded-Port: 443
1292                if (trustForwardedHeaders && portAsString == null) {
1293                        String xForwardedPortHeader = firstHeaderValue(headers.get("X-Forwarded-Port"));
1294                        if (xForwardedPortHeader != null) {
1295                                portAsString = stripOptionalQuotes(xForwardedPortHeader);
1296                                portExplicit = true;
1297                        }
1298                }
1299
1300                // Host: developer.mozilla.org OR developer.mozilla.org:443 OR [2001:db8::1]:8443
1301                if (host == null) {
1302                        String hostHeader = firstHeaderValue(headers.get("Host"));
1303
1304                        if (hostHeader != null) {
1305                                HostPort hostPort = parseHostPort(hostHeader).orElse(null);
1306
1307                                if (hostPort != null) {
1308                                        host = hostPort.getHost();
1309
1310                                        if (hostPort.getPort().isPresent() && portAsString == null) {
1311                                                portAsString = String.valueOf(hostPort.getPort().get());
1312                                                portExplicit = true;
1313                                        }
1314                                }
1315                        }
1316                }
1317
1318                // Origin: null OR <scheme>://<hostname> OR <scheme>://<hostname>:<port> (IPv6 supported)
1319                // Use Origin only when host is missing or when it matches the Host-derived value.
1320                if (allowOriginFallback && (protocol == null || host == null || portAsString == null)) {
1321                        String originHeader = firstHeaderValue(headers.get("Origin"));
1322
1323                        if (originHeader != null) {
1324                                try {
1325                                        URI o = new URI(originHeader);
1326                                        String originProtocol = trimAggressivelyToNull(o.getScheme());
1327                                        String originHost = o.getHost(); // may be bracketed already on some JDKs
1328                                        int originPort = o.getPort(); // -1 if absent
1329
1330                                        if (originHost != null) {
1331                                                boolean alreadyBracketed = originHost.startsWith("[") && originHost.endsWith("]");
1332                                                boolean isIpv6Like = originHost.indexOf(':') >= 0; // contains colon(s)
1333                                                originHost = (isIpv6Like && !alreadyBracketed) ? "[" + originHost + "]" : originHost;
1334                                        }
1335
1336                                        boolean hostMatchesOrigin = host != null && originHost != null && host.equalsIgnoreCase(originHost);
1337
1338                                        if (host == null) {
1339                                                if (originHost != null)
1340                                                        host = originHost;
1341                                                if (originProtocol != null)
1342                                                        protocol = originProtocol;
1343                                                if (originPort >= 0) {
1344                                                        portAsString = String.valueOf(originPort);
1345                                                        portExplicit = true;
1346                                                }
1347                                        } else if (hostMatchesOrigin) {
1348                                                if (protocol == null && originProtocol != null)
1349                                                        protocol = originProtocol;
1350                                                if (portAsString == null && originPort >= 0) {
1351                                                        portAsString = String.valueOf(originPort);
1352                                                        portExplicit = true;
1353                                                }
1354                                        }
1355                                } catch (URISyntaxException ignored) {
1356                                        // no-op
1357                                }
1358                        }
1359                }
1360
1361                Integer port = null;
1362
1363                if (portAsString != null) {
1364                        try {
1365                                int parsedPort = Integer.parseInt(portAsString, 10);
1366                                if (parsedPort >= 1 && parsedPort <= 65535)
1367                                        port = parsedPort;
1368                        } catch (Exception ignored) {
1369                                // Not an integer; ignore it
1370                        }
1371                }
1372
1373                if (protocol != null && host != null && port == null) {
1374                        return Optional.of(format("%s://%s", protocol, host));
1375                }
1376
1377                if (protocol != null && host != null && port != null) {
1378                        boolean usingDefaultPort =
1379                                        ("http".equalsIgnoreCase(protocol) && port.equals(80)) ||
1380                                                        ("https".equalsIgnoreCase(protocol) && port.equals(443));
1381
1382                        // Keep default ports if the client/proxy explicitly sent them
1383                        String effectiveOrigin = (usingDefaultPort && !portExplicit)
1384                                        ? format("%s://%s", protocol, host)
1385                                        : format("%s://%s:%s", protocol, host, port);
1386
1387                        return Optional.of(effectiveOrigin);
1388                }
1389
1390                return Optional.empty();
1391        }
1392
1393        /**
1394         * Best-effort attempt to determine a client's effective IP address by examining request headers.
1395         * <p>
1396         * The socket peer is always used as the fallback. Forwarded headers are only used when permitted by
1397         * {@link EffectiveOriginResolver.TrustPolicy}. When using
1398         * {@link EffectiveOriginResolver.TrustPolicy#TRUST_PROXY_ALLOWLIST}, you must provide a trusted proxy predicate or allowlist.
1399         * If the remote address is missing or not trusted, forwarded headers are ignored.
1400         *
1401         * @param effectiveClientIpResolver request headers and trust settings
1402         * @return the effective client IP address, or {@link Optional#empty()} if it could not be determined
1403         */
1404        @NonNull
1405        static Optional<InetAddress> extractEffectiveClientIp(@NonNull EffectiveClientIpResolver effectiveClientIpResolver) {
1406                requireNonNull(effectiveClientIpResolver);
1407                requireNonNull(effectiveClientIpResolver.getHeaders());
1408                requireNonNull(effectiveClientIpResolver.getTrustPolicy());
1409
1410                if (effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST
1411                                && effectiveClientIpResolver.getTrustedProxyPredicate() == null) {
1412                        throw new IllegalStateException(format("%s policy requires a trusted proxy predicate or allowlist.",
1413                                        EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST));
1414                }
1415
1416                InetSocketAddress remoteAddress = effectiveClientIpResolver.getRemoteAddress();
1417                InetAddress remoteInetAddress = remoteAddress == null ? null : remoteAddress.getAddress();
1418
1419                if (!shouldTrustForwardedHeaders(effectiveClientIpResolver))
1420                        return Optional.ofNullable(remoteInetAddress);
1421
1422                List<InetAddress> forwardedForAddresses = forwardedForAddresses(effectiveClientIpResolver.getHeaders());
1423                Optional<InetAddress> effectiveClientIp = effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL
1424                                ? leftmostAddress(forwardedForAddresses)
1425                                : firstUntrustedAddressFromRight(forwardedForAddresses, effectiveClientIpResolver.getTrustedProxyPredicate());
1426
1427                if (effectiveClientIp.isPresent())
1428                        return effectiveClientIp;
1429
1430                List<InetAddress> xForwardedForAddresses = xForwardedForAddresses(effectiveClientIpResolver.getHeaders());
1431                effectiveClientIp = effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL
1432                                ? leftmostAddress(xForwardedForAddresses)
1433                                : firstUntrustedAddressFromRight(xForwardedForAddresses, effectiveClientIpResolver.getTrustedProxyPredicate());
1434
1435                if (effectiveClientIp.isPresent())
1436                        return effectiveClientIp;
1437
1438                return Optional.ofNullable(remoteInetAddress);
1439        }
1440
1441        private static boolean shouldTrustForwardedHeaders(@NonNull EffectiveOriginResolver effectiveOriginResolver) {
1442                if (effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL)
1443                        return true;
1444
1445                if (effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_NONE)
1446                        return false;
1447
1448                var remoteAddress = effectiveOriginResolver.getRemoteAddress();
1449                var trustedProxyPredicate = effectiveOriginResolver.getTrustedProxyPredicate();
1450
1451                if (remoteAddress == null || trustedProxyPredicate == null)
1452                        return false;
1453
1454                return trustedProxyPredicate.test(remoteAddress);
1455        }
1456
1457        private static boolean shouldTrustForwardedHeaders(@NonNull EffectiveClientIpResolver effectiveClientIpResolver) {
1458                if (effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL)
1459                        return true;
1460
1461                if (effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_NONE)
1462                        return false;
1463
1464                var remoteAddress = effectiveClientIpResolver.getRemoteAddress();
1465                var trustedProxyPredicate = effectiveClientIpResolver.getTrustedProxyPredicate();
1466
1467                if (remoteAddress == null || trustedProxyPredicate == null)
1468                        return false;
1469
1470                return trustedProxyPredicate.test(remoteAddress);
1471        }
1472
1473        @NonNull
1474        private static Optional<InetAddress> leftmostAddress(@NonNull List<@NonNull InetAddress> addresses) {
1475                requireNonNull(addresses);
1476                return addresses.isEmpty() ? Optional.empty() : Optional.of(addresses.get(0));
1477        }
1478
1479        @NonNull
1480        private static Optional<InetAddress> firstUntrustedAddressFromRight(@NonNull List<@NonNull InetAddress> addresses,
1481                                                                                                                                                                                                                                                                                 @Nullable Predicate<InetSocketAddress> trustedProxyPredicate) {
1482                requireNonNull(addresses);
1483
1484                if (trustedProxyPredicate == null)
1485                        return Optional.empty();
1486
1487                InetAddress leftmostAddress = null;
1488
1489                for (int i = addresses.size() - 1; i >= 0; i--) {
1490                        InetAddress address = addresses.get(i);
1491                        leftmostAddress = address;
1492
1493                        if (!trustedProxyPredicate.test(new InetSocketAddress(address, 0)))
1494                                return Optional.of(address);
1495                }
1496
1497                return Optional.ofNullable(leftmostAddress);
1498        }
1499
1500        @NonNull
1501        private static List<@NonNull InetAddress> forwardedForAddresses(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
1502                requireNonNull(headers);
1503                Set<String> forwardedHeaders = headers.get("Forwarded");
1504
1505                if (forwardedHeaders == null || forwardedHeaders.isEmpty())
1506                        return List.of();
1507
1508                List<InetAddress> addresses = new ArrayList<>();
1509
1510                for (String forwardedHeader : forwardedHeaders) {
1511                        String trimmed = trimAggressivelyToNull(forwardedHeader);
1512                        if (trimmed == null)
1513                                continue;
1514
1515                        for (String forwardedEntry : splitCommaAware(trimmed)) {
1516                                String entry = trimAggressivelyToNull(forwardedEntry);
1517                                if (entry == null)
1518                                        continue;
1519
1520                                for (String forwardedHeaderFieldComponent : splitSemicolonAware(entry)) {
1521                                        forwardedHeaderFieldComponent = trimAggressivelyToNull(forwardedHeaderFieldComponent);
1522                                        if (forwardedHeaderFieldComponent == null)
1523                                                continue;
1524
1525                                        String[] forwardedHeaderFieldNameAndValue = forwardedHeaderFieldComponent.split(Pattern.quote("="), 2);
1526                                        if (forwardedHeaderFieldNameAndValue.length != 2)
1527                                                continue;
1528
1529                                        String name = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[0]);
1530                                        String value = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[1]);
1531                                        if (name == null || value == null || !"for".equalsIgnoreCase(name))
1532                                                continue;
1533
1534                                        parseForwardedIpLiteral(value).ifPresent(addresses::add);
1535                                        break;
1536                                }
1537                        }
1538                }
1539
1540                return addresses.isEmpty() ? List.of() : Collections.unmodifiableList(addresses);
1541        }
1542
1543        @NonNull
1544        private static List<@NonNull InetAddress> xForwardedForAddresses(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
1545                requireNonNull(headers);
1546                Set<String> xForwardedForHeaders = headers.get("X-Forwarded-For");
1547
1548                if (xForwardedForHeaders == null || xForwardedForHeaders.isEmpty())
1549                        return List.of();
1550
1551                List<InetAddress> addresses = new ArrayList<>();
1552
1553                for (String xForwardedForHeader : xForwardedForHeaders) {
1554                        String trimmed = trimAggressivelyToNull(xForwardedForHeader);
1555                        if (trimmed == null)
1556                                continue;
1557
1558                        for (String part : splitCommaAware(trimmed))
1559                                parseForwardedIpLiteral(part).ifPresent(addresses::add);
1560                }
1561
1562                return addresses.isEmpty() ? List.of() : Collections.unmodifiableList(addresses);
1563        }
1564
1565        @NonNull
1566        private static Optional<InetAddress> parseForwardedIpLiteral(@Nullable String value) {
1567                String trimmed = trimAggressivelyToNull(value);
1568
1569                if (trimmed == null)
1570                        return Optional.empty();
1571
1572                trimmed = trimAggressivelyToNull(stripOptionalQuotes(trimmed));
1573
1574                if (trimmed == null || "unknown".equalsIgnoreCase(trimmed) || trimmed.startsWith("_"))
1575                        return Optional.empty();
1576
1577                if (trimmed.startsWith("[")) {
1578                        int closeIndex = trimmed.indexOf(']');
1579
1580                        if (closeIndex <= 1)
1581                                return Optional.empty();
1582
1583                        String suffix = trimmed.substring(closeIndex + 1);
1584
1585                        if (!suffix.isEmpty()) {
1586                                if (!suffix.startsWith(":") || !isValidPort(suffix.substring(1)))
1587                                        return Optional.empty();
1588                        }
1589
1590                        return parseIpLiteral(trimmed.substring(1, closeIndex));
1591                }
1592
1593                int colonCount = countOccurrences(trimmed, ':');
1594
1595                if (colonCount == 1 && trimmed.contains(".")) {
1596                        int colonIndex = trimmed.indexOf(':');
1597                        String addressPart = trimmed.substring(0, colonIndex);
1598                        String portPart = trimmed.substring(colonIndex + 1);
1599
1600                        if (isValidPort(portPart))
1601                                return parseIpv4Literal(addressPart);
1602
1603                        return Optional.empty();
1604                }
1605
1606                return parseIpLiteral(trimmed);
1607        }
1608
1609        @NonNull
1610        private static Optional<InetAddress> parseIpLiteral(@Nullable String value) {
1611                String trimmed = trimAggressivelyToNull(value);
1612
1613                if (trimmed == null)
1614                        return Optional.empty();
1615
1616                Optional<InetAddress> ipv4Address = parseIpv4Literal(trimmed);
1617
1618                if (ipv4Address.isPresent())
1619                        return ipv4Address;
1620
1621                return parseIpv6Literal(trimmed);
1622        }
1623
1624        @NonNull
1625        private static Optional<InetAddress> parseIpv4Literal(@Nullable String value) {
1626                String trimmed = trimAggressivelyToNull(value);
1627
1628                if (trimmed == null)
1629                        return Optional.empty();
1630
1631                String[] parts = trimmed.split(Pattern.quote("."), -1);
1632
1633                if (parts.length != 4)
1634                        return Optional.empty();
1635
1636                byte[] bytes = new byte[4];
1637
1638                for (int i = 0; i < parts.length; i++) {
1639                        String part = parts[i];
1640
1641                        if (part.isEmpty() || part.length() > 3)
1642                                return Optional.empty();
1643
1644                        if (part.length() > 1 && part.startsWith("0"))
1645                                return Optional.empty();
1646
1647                        for (int j = 0; j < part.length(); j++) {
1648                                if (!Character.isDigit(part.charAt(j)))
1649                                        return Optional.empty();
1650                        }
1651
1652                        int octet;
1653                        try {
1654                                octet = Integer.parseInt(part, 10);
1655                        } catch (NumberFormatException e) {
1656                                return Optional.empty();
1657                        }
1658
1659                        if (octet < 0 || octet > 255)
1660                                return Optional.empty();
1661
1662                        bytes[i] = (byte) octet;
1663                }
1664
1665                try {
1666                        return Optional.of(InetAddress.getByAddress(bytes));
1667                } catch (UnknownHostException e) {
1668                        return Optional.empty();
1669                }
1670        }
1671
1672        @NonNull
1673        private static Optional<InetAddress> parseIpv6Literal(@Nullable String value) {
1674                String trimmed = trimAggressivelyToNull(value);
1675
1676                if (trimmed == null || !trimmed.contains(":"))
1677                        return Optional.empty();
1678
1679                if (trimmed.indexOf('[') >= 0 || trimmed.indexOf(']') >= 0 || trimmed.indexOf('"') >= 0 || trimmed.indexOf('%') >= 0)
1680                        return Optional.empty();
1681
1682                try {
1683                        return Optional.of(InetAddress.getByName(trimmed));
1684                } catch (Exception e) {
1685                        return Optional.empty();
1686                }
1687        }
1688
1689        private static boolean isValidPort(@Nullable String value) {
1690                String trimmed = trimAggressivelyToNull(value);
1691
1692                if (trimmed == null)
1693                        return false;
1694
1695                try {
1696                        int port = Integer.parseInt(trimmed, 10);
1697                        return port >= 1 && port <= 65535;
1698                } catch (NumberFormatException e) {
1699                        return false;
1700                }
1701        }
1702
1703        private static int countOccurrences(@NonNull String value,
1704                                                                                                                                                        char character) {
1705                requireNonNull(value);
1706                int count = 0;
1707
1708                for (int i = 0; i < value.length(); i++) {
1709                        if (value.charAt(i) == character)
1710                                count++;
1711                }
1712
1713                return count;
1714        }
1715
1716        /**
1717         * Extracts the media type (without parameters) from the first {@code Content-Type} header.
1718         * <p>
1719         * For example, {@code "text/html; charset=UTF-8"} → {@code "text/html"}.
1720         *
1721         * @param headers request/response headers (must be non-{@code null})
1722         * @return the media type if present; otherwise {@link Optional#empty()}
1723         * @see #extractContentTypeFromHeaderValue(String)
1724         */
1725        @NonNull
1726        public static Optional<String> extractContentTypeFromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
1727                requireNonNull(headers);
1728
1729                Set<String> contentTypeHeaderValues = headers.get("Content-Type");
1730
1731                if (contentTypeHeaderValues == null || contentTypeHeaderValues.size() == 0)
1732                        return Optional.empty();
1733
1734                return extractContentTypeFromHeaderValue(contentTypeHeaderValues.stream().findFirst().get());
1735        }
1736
1737        /**
1738         * Extracts the media type (without parameters) from a {@code Content-Type} header value.
1739         * <p>
1740         * For example, {@code "application/json; charset=UTF-8"} → {@code "application/json"}.
1741         *
1742         * @param contentTypeHeaderValue the raw header value; may be {@code null} or blank
1743         * @return the media type if present; otherwise {@link Optional#empty()}
1744         */
1745        @NonNull
1746        public static Optional<String> extractContentTypeFromHeaderValue(@Nullable String contentTypeHeaderValue) {
1747                contentTypeHeaderValue = trimAggressivelyToNull(contentTypeHeaderValue);
1748
1749                if (contentTypeHeaderValue == null)
1750                        return Optional.empty();
1751
1752                // Examples
1753                // Content-Type: text/html; charset=UTF-8
1754                // Content-Type: multipart/form-data; boundary=something
1755
1756                int indexOfSemicolon = contentTypeHeaderValue.indexOf(";");
1757
1758                // Simple case, e.g. "text/html"
1759                if (indexOfSemicolon == -1)
1760                        return Optional.ofNullable(trimAggressivelyToNull(contentTypeHeaderValue));
1761
1762                // More complex case, e.g. "text/html; charset=UTF-8"
1763                return Optional.ofNullable(trimAggressivelyToNull(contentTypeHeaderValue.substring(0, indexOfSemicolon)));
1764        }
1765
1766        /**
1767         * Extracts the {@link Charset} from the first {@code Content-Type} header, if present and valid.
1768         * <p>
1769         * Tolerates additional parameters and arbitrary whitespace. Invalid or unknown charset tokens yield {@link Optional#empty()}.
1770         *
1771         * @param headers request/response headers (must be non-{@code null})
1772         * @return the charset declared by the header; otherwise {@link Optional#empty()}
1773         * @see #extractCharsetFromHeaderValue(String)
1774         */
1775        @NonNull
1776        public static Optional<Charset> extractCharsetFromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
1777                requireNonNull(headers);
1778
1779                Set<String> contentTypeHeaderValues = headers.get("Content-Type");
1780
1781                if (contentTypeHeaderValues == null || contentTypeHeaderValues.size() == 0)
1782                        return Optional.empty();
1783
1784                return extractCharsetFromHeaderValue(contentTypeHeaderValues.stream().findFirst().get());
1785        }
1786
1787        /**
1788         * Extracts the {@code charset=...} parameter from a {@code Content-Type} header value.
1789         * <p>
1790         * Parsing is forgiving: parameters may appear in any order and with arbitrary spacing. If a charset is found,
1791         * it is validated via {@link Charset#forName(String)}; invalid names result in {@link Optional#empty()}.
1792         *
1793         * @param contentTypeHeaderValue the raw header value; may be {@code null} or blank
1794         * @return the resolved charset if present and valid; otherwise {@link Optional#empty()}
1795         */
1796        @NonNull
1797        public static Optional<Charset> extractCharsetFromHeaderValue(@Nullable String contentTypeHeaderValue) {
1798                contentTypeHeaderValue = trimAggressivelyToNull(contentTypeHeaderValue);
1799
1800                if (contentTypeHeaderValue == null)
1801                        return Optional.empty();
1802
1803                // Examples
1804                // Content-Type: text/html; charset=UTF-8
1805                // Content-Type: multipart/form-data; boundary=something
1806
1807                int indexOfSemicolon = contentTypeHeaderValue.indexOf(";");
1808
1809                // Simple case, e.g. "text/html"
1810                if (indexOfSemicolon == -1)
1811                        return Optional.empty();
1812
1813                // More complex case, e.g. "text/html; charset=UTF-8" or "multipart/form-data; charset=UTF-8; boundary=something"
1814                boolean finishedContentType = false;
1815                boolean finishedCharsetName = false;
1816                StringBuilder buffer = new StringBuilder();
1817                String charsetName = null;
1818
1819                for (int i = 0; i < contentTypeHeaderValue.length(); i++) {
1820                        char c = contentTypeHeaderValue.charAt(i);
1821
1822                        if (Character.isWhitespace(c))
1823                                continue;
1824
1825                        if (c == ';') {
1826                                // No content type yet?  This just be it...
1827                                if (!finishedContentType) {
1828                                        finishedContentType = true;
1829                                        buffer = new StringBuilder();
1830                                } else if (!finishedCharsetName) {
1831                                        if (buffer.indexOf("charset=") == 0) {
1832                                                charsetName = buffer.toString();
1833                                                finishedCharsetName = true;
1834                                                break;
1835                                        }
1836                                }
1837                        } else {
1838                                buffer.append(Character.toLowerCase(c));
1839                        }
1840                }
1841
1842                // Handle case where charset is the end of the string, e.g. "whatever;charset=UTF-8"
1843                if (!finishedCharsetName) {
1844                        String potentialCharset = trimAggressivelyToNull(buffer.toString());
1845                        if (potentialCharset != null && potentialCharset.startsWith("charset=")) {
1846                                finishedCharsetName = true;
1847                                charsetName = potentialCharset;
1848                        }
1849                }
1850
1851                if (finishedCharsetName) {
1852                        String specifiedCharsetName = charsetName;
1853                        if (specifiedCharsetName == null)
1854                                return Optional.empty();
1855
1856                        // e.g. charset=UTF-8 or charset="UTF-8" or charset='UTF-8'
1857                        String possibleCharsetName = trimAggressivelyToNull(specifiedCharsetName.replace("charset=", ""));
1858
1859                        if (possibleCharsetName != null) {
1860                                // strip optional surrounding quotes
1861                                if ((possibleCharsetName.length() >= 2) &&
1862                                                ((possibleCharsetName.charAt(0) == '"' && possibleCharsetName.charAt(possibleCharsetName.length() - 1) == '"') ||
1863                                                                (possibleCharsetName.charAt(0) == '\'' && possibleCharsetName.charAt(possibleCharsetName.length() - 1) == '\''))) {
1864                                        possibleCharsetName = possibleCharsetName.substring(1, possibleCharsetName.length() - 1);
1865                                        possibleCharsetName = trimAggressivelyToNull(possibleCharsetName);
1866                                }
1867
1868                                if (possibleCharsetName != null) {
1869                                        try {
1870                                                return Optional.of(Charset.forName(possibleCharsetName));
1871                                        } catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) {
1872                                                return Optional.empty();
1873                                        }
1874                                }
1875                        }
1876                }
1877
1878                return Optional.empty();
1879        }
1880
1881        /**
1882         * A "stronger" version of {@link String#trim()} which discards leading and trailing Unicode space-separator characters ({@code \p{Z}}).
1883         * <p>
1884         * In a web environment with user-supplied inputs, this is the behavior we want the vast majority of the time.
1885         * For example, users copy-paste URLs from Microsoft Word or Outlook and it's easy to accidentally include a {@code U+202F
1886         * "Narrow No-Break Space (NNBSP)"} character at the end, which might break parsing.
1887         * <p>
1888         * Note that this does not remove other whitespace characters such as tabs, carriage returns, or line feeds.
1889         * <p>
1890         * See <a href="https://www.compart.com/en/unicode/U+202F">https://www.compart.com/en/unicode/U+202F</a> for details.
1891         *
1892         * @param string the string to trim
1893         * @return the trimmed string, or {@code null} if the input string is {@code null} or the trimmed representation is of length {@code 0}
1894         */
1895        @Nullable
1896        public static String trimAggressively(@Nullable String string) {
1897                if (string == null)
1898                        return null;
1899
1900                string = HEAD_WHITESPACE_PATTERN.matcher(string).replaceAll("");
1901
1902                if (string.length() == 0)
1903                        return string;
1904
1905                string = TAIL_WHITESPACE_PATTERN.matcher(string).replaceAll("");
1906
1907                return string;
1908        }
1909
1910        /**
1911         * Aggressively trims leading and trailing Unicode space-separator characters from the given string and returns {@code null} if the result is empty.
1912         * <p>
1913         * See {@link #trimAggressively(String)} for details on which code points are removed.
1914         *
1915         * @param string the input string; may be {@code null}
1916         * @return a trimmed, non-empty string; or {@code null} if input was {@code null} or trimmed to empty
1917         */
1918        @Nullable
1919        public static String trimAggressivelyToNull(@Nullable String string) {
1920                if (string == null)
1921                        return null;
1922
1923                string = trimAggressively(string);
1924                if (string == null)
1925                        return null;
1926                return string.length() == 0 ? null : string;
1927        }
1928
1929        /**
1930         * Aggressively trims leading and trailing Unicode space-separator characters from the given string and returns {@code ""} if the input is {@code null}.
1931         * <p>
1932         * See {@link #trimAggressively(String)} for details on which code points are removed.
1933         *
1934         * @param string the input string; may be {@code null}
1935         * @return a trimmed string (never {@code null}); {@code ""} if input was {@code null}
1936         */
1937        @NonNull
1938        public static String trimAggressivelyToEmpty(@Nullable String string) {
1939                if (string == null)
1940                        return "";
1941
1942                String trimmed = trimAggressively(string);
1943                return trimmed == null ? "" : trimmed;
1944        }
1945
1946        static void validateHeaderNameAndValue(@Nullable String name,
1947                                                                                                                                                                 @Nullable String value) {
1948                // First, validate name:
1949                name = trimAggressivelyToNull(name);
1950
1951                if (name == null)
1952                        throw new IllegalArgumentException("Header name is blank");
1953
1954                for (int i = 0; i < name.length(); i++) {
1955                        char c = name.charAt(i);
1956                        // RFC 9110 tchar: "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
1957                        if (c > 0x7F || !(c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' || c == '+' ||
1958                                        c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~' ||
1959                                        Character.isLetterOrDigit(c))) {
1960                                throw new IllegalArgumentException(format("Illegal header name '%s'. Offending character: '%s'", name, printableChar(c)));
1961                        }
1962                }
1963
1964                // Then, validate value:
1965                if (value == null)
1966                        return;
1967
1968                for (int i = 0; i < value.length(); i++) {
1969                        char c = value.charAt(i);
1970                        if (c == '\r' || c == '\n' || c == 0x00 || c > 0xFF || (c < 0x20 && c != '\t')) {
1971                                throw new IllegalArgumentException(format("Illegal header value '%s' for header name '%s'. Offending character: '%s'", value, name, printableChar(c)));
1972                        }
1973                }
1974
1975                // Percent-encoded control sequence checks
1976                Matcher m = HEADER_PERCENT_ENCODING_PATTERN.matcher(value);
1977
1978                while (m.find()) {
1979                        int b = Integer.parseInt(m.group(1), 16);
1980                        if (b == 0x0D || b == 0x0A || b == 0x00 || (b >= 0x00 && b < 0x20 && b != 0x09)) {
1981                                throw new IllegalArgumentException(format(
1982                                                "Illegal (percent-encoded) header value '%s' for header name '%s'. Offending octet: 0x%02X",
1983                                                value, name, b));
1984                        }
1985                }
1986        }
1987
1988        @NonNull
1989        static String printableString(@NonNull String input) {
1990                requireNonNull(input);
1991
1992                StringBuilder out = new StringBuilder(input.length() + 16);
1993
1994                for (int i = 0; i < input.length(); i++)
1995                        out.append(printableChar(input.charAt(i)));
1996
1997                return out.toString();
1998        }
1999
2000        @NonNull
2001        static String printableChar(char c) {
2002                if (c == '\r') return "\\r";
2003                if (c == '\n') return "\\n";
2004                if (c == '\t') return "\\t";
2005                if (c == '\f') return "\\f";
2006                if (c == '\b') return "\\b";
2007                if (c == '\\') return "\\\\";
2008                if (c == '\'') return "\\'";
2009                if (c == '\"') return "\\\"";
2010                if (c == 0) return "\\0";
2011
2012                if (c < 0x20 || c == 0x7F)  // control chars
2013                        return String.format("\\u%04X", (int) c);
2014
2015                if (Character.isISOControl(c) || Character.getType(c) == Character.FORMAT)
2016                        return String.format("\\u%04X", (int) c);
2017
2018                return String.valueOf(c);
2019        }
2020
2021        @NonNull
2022        private static final Set<String> COMMA_JOINABLE_HEADER_NAMES = Set.of(
2023                        // Common list-type headers (RFC 7230/9110)
2024                        "accept",
2025                        "accept-encoding",
2026                        "accept-language",
2027                        "cache-control",
2028                        "pragma",
2029                        "vary",
2030                        "connection",
2031                        "transfer-encoding",
2032                        "upgrade",
2033                        "allow",
2034                        "via",
2035                        "warning"
2036                        // intentionally NOT: set-cookie, authorization, cookie, content-disposition, location
2037        );
2038
2039        /**
2040         * Given a list of raw HTTP header lines, convert them into a normalized case-insensitive, order-preserving map which "inflates" comma-separated headers into distinct values where permitted according to RFC 7230/9110.
2041         * <p>
2042         * For example, given these raw header lines:
2043         * <pre>{@code List<String> lines = List.of(
2044         *   "Cache-Control: no-cache, no-store",
2045         *   "Set-Cookie: a=b; Path=/; HttpOnly",
2046         *   "Set-Cookie: c=d; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/"
2047         * );}</pre>
2048         * The result of parsing would look like this:
2049         * <pre>{@code result.get("cache-control") -> [
2050         *   "no-cache",
2051         *   "no-store"
2052         * ]
2053         * result.get("set-cookie") -> [
2054         *   "a=b; Path=/; HttpOnly",
2055         *   "c=d; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/"
2056         * ]}</pre>
2057         * <p>
2058         * Keys in the returned map are case-insensitive and are guaranteed to be in the same order as encountered in {@code rawHeaderLines}.
2059         * <p>
2060         * Values in the returned map are guaranteed to be in the same order as encountered in {@code rawHeaderLines}.
2061         *
2062         * @param rawHeaderLines the raw HTTP header lines to parse
2063         * @return a normalized mapping of header name keys to values
2064         */
2065        @NonNull
2066        public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractHeadersFromRawHeaderLines(@NonNull List<@NonNull String> rawHeaderLines) {
2067                requireNonNull(rawHeaderLines);
2068
2069                // 1) Unfold obsolete folded lines (obs-fold): lines beginning with SP/HT are continuations
2070                List<String> lines = unfold(rawHeaderLines);
2071
2072                // 2) Parse into map
2073                Map<String, Set<String>> headers = new LinkedCaseInsensitiveMap<>();
2074
2075                for (String raw : lines) {
2076                        String line = trimAggressivelyToNull(raw);
2077
2078                        if (line == null)
2079                                continue;
2080
2081                        int idx = line.indexOf(':');
2082
2083                        if (idx <= 0)
2084                                continue; // skip malformed
2085
2086                        addParsedHeader(headers, line.substring(0, idx), line.substring(idx + 1));
2087                }
2088
2089                freezeStringValueSets(headers);
2090                return headers;
2091        }
2092
2093        static void addParsedHeader(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers,
2094                                                                                                                        @Nullable String name,
2095                                                                                                                        @Nullable String value) {
2096                requireNonNull(headers);
2097
2098                String key = trimAggressivelyToEmpty(name); // keep original case for display
2099                String trimmedValue = trimAggressivelyToNull(value);
2100                if (trimmedValue == null)
2101                        return;
2102
2103                if (COMMA_JOINABLE_HEADER_NAMES.contains(key.toLowerCase(Locale.ROOT))) {
2104                        for (String part : splitCommaAware(trimmedValue)) {
2105                                String v = trimAggressivelyToNull(part);
2106                                if (v != null)
2107                                        addStringValue(headers, key, v);
2108                        }
2109                } else {
2110                        addStringValue(headers, key, trimmedValue.trim());
2111                }
2112        }
2113
2114        static void addParsedHeaderValues(@NonNull Set<@NonNull String> values,
2115                                                                                                                                                @Nullable String name,
2116                                                                                                                                                @Nullable String value) {
2117                requireNonNull(values);
2118
2119                String key = trimAggressivelyToEmpty(name);
2120                String keyLowercase = key.toLowerCase(Locale.ROOT);
2121                value = trimAggressivelyToNull(value);
2122
2123                if (value == null)
2124                        return;
2125
2126                if (COMMA_JOINABLE_HEADER_NAMES.contains(keyLowercase)) {
2127                        for (String part : splitCommaAware(value)) {
2128                                String v = trimAggressivelyToNull(part);
2129                                if (v != null)
2130                                        values.add(v);
2131                        }
2132                } else {
2133                        values.add(value.trim());
2134                }
2135        }
2136
2137        static void freezeStringValueSets(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> valuesByName) {
2138                requireNonNull(valuesByName);
2139
2140                for (Entry<String, Set<String>> entry : valuesByName.entrySet()) {
2141                        Set<String> values = entry.getValue();
2142
2143                        if (values == null || values.isEmpty()) {
2144                                entry.setValue(Set.of());
2145                        } else if (values instanceof LinkedHashSet) {
2146                                entry.setValue(Collections.unmodifiableSet(values));
2147                        }
2148                }
2149        }
2150
2151        private static void addStringValue(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> valuesByName,
2152                                                                                                                                                 @NonNull String name,
2153                                                                                                                                                 @NonNull String value) {
2154                requireNonNull(valuesByName);
2155                requireNonNull(name);
2156                requireNonNull(value);
2157
2158                Set<String> values = valuesByName.get(name);
2159
2160                if (values == null || values.isEmpty()) {
2161                        valuesByName.put(name, Set.of(value));
2162                        return;
2163                }
2164
2165                if (values.contains(value))
2166                        return;
2167
2168                if (values instanceof LinkedHashSet) {
2169                        values.add(value);
2170                        return;
2171                }
2172
2173                Set<String> promotedValues = new LinkedHashSet<>(values);
2174                promotedValues.add(value);
2175                valuesByName.put(name, promotedValues);
2176        }
2177
2178        /**
2179         * Header parsing helper
2180         */
2181        @NonNull
2182        private static List<String> unfold(@NonNull List<String> raw) {
2183                requireNonNull(raw);
2184                if (raw.isEmpty()) return List.of();
2185
2186                List<String> out = new ArrayList<>(raw.size());
2187                StringBuilder cur = null;
2188                boolean curIsHeader = false;
2189
2190                for (String line : raw) {
2191                        if (line == null) continue;
2192
2193                        boolean isContinuation = !line.isEmpty() && (line.charAt(0) == ' ' || line.charAt(0) == '\t');
2194                        if (isContinuation) {
2195                                if (cur != null && curIsHeader) {
2196                                        cur.append(' ').append(line.trim());
2197                                } else {
2198                                        // Do not fold into a non-header; flush previous and start anew
2199                                        if (cur != null) out.add(cur.toString());
2200                                        cur = new StringBuilder(line);
2201                                        curIsHeader = line.indexOf(':') > 0; // almost certainly false for leading-space lines
2202                                }
2203                        } else {
2204                                if (cur != null) out.add(cur.toString());
2205                                cur = new StringBuilder(line);
2206                                curIsHeader = line.indexOf(':') > 0;
2207                        }
2208                }
2209                if (cur != null) out.add(cur.toString());
2210                return out;
2211        }
2212
2213        /**
2214         * Header parsing helper: split on commas that are not inside a quoted-string; supports \" escapes inside quotes.
2215         */
2216        @NonNull
2217        private static List<String> splitCommaAware(@NonNull String string) {
2218                requireNonNull(string);
2219
2220                List<String> out = new ArrayList<>(4);
2221                StringBuilder cur = new StringBuilder();
2222                boolean inQuotes = false;
2223                boolean escaped = false;
2224
2225                for (int i = 0; i < string.length(); i++) {
2226                        char c = string.charAt(i);
2227
2228                        if (escaped) {
2229                                // Preserve the escaped char as-is
2230                                cur.append(c);
2231                                escaped = false;
2232                        } else if (c == '\\') {
2233                                if (inQuotes) {
2234                                        // Preserve the backslash itself, then mark next char as escaped
2235                                        cur.append('\\');       // ← keep the backslash
2236                                        escaped = true;
2237                                } else {
2238                                        cur.append('\\');       // literal backslash outside quotes
2239                                }
2240                        } else if (c == '"') {
2241                                inQuotes = !inQuotes;
2242                                cur.append('"');
2243                        } else if (c == ',' && !inQuotes) {
2244                                out.add(cur.toString());
2245                                cur.setLength(0);
2246                        } else {
2247                                cur.append(c);
2248                        }
2249                }
2250                out.add(cur.toString());
2251                return out;
2252        }
2253
2254        /**
2255         * Header parsing helper: split on semicolons that are not inside a quoted-string; supports \" escapes inside quotes.
2256         */
2257        @NonNull
2258        static List<String> splitSemicolonAware(@NonNull String string) {
2259                requireNonNull(string);
2260
2261                List<String> out = new ArrayList<>(4);
2262                StringBuilder cur = new StringBuilder();
2263                boolean inQuotes = false;
2264                boolean escaped = false;
2265
2266                for (int i = 0; i < string.length(); i++) {
2267                        char c = string.charAt(i);
2268
2269                        if (escaped) {
2270                                cur.append(c);
2271                                escaped = false;
2272                        } else if (c == '\\') {
2273                                if (inQuotes) {
2274                                        cur.append('\\');
2275                                        escaped = true;
2276                                } else {
2277                                        cur.append('\\');
2278                                }
2279                        } else if (c == '"') {
2280                                inQuotes = !inQuotes;
2281                                cur.append('"');
2282                        } else if (c == ';' && !inQuotes) {
2283                                out.add(cur.toString());
2284                                cur.setLength(0);
2285                        } else {
2286                                cur.append(c);
2287                        }
2288                }
2289
2290                out.add(cur.toString());
2291                return out;
2292        }
2293
2294        /**
2295         * Remove a single pair of surrounding quotes if present.
2296         */
2297        @NonNull
2298        private static String stripOptionalQuotes(@NonNull String string) {
2299                requireNonNull(string);
2300
2301                if (string.length() >= 2) {
2302                        char first = string.charAt(0), last = string.charAt(string.length() - 1);
2303
2304                        if ((first == '"' && last == '"') || (first == '\'' && last == '\''))
2305                                return string.substring(1, string.length() - 1);
2306                }
2307
2308                return string;
2309        }
2310
2311        /**
2312         * Parse host[:port] with IPv6 support: "[v6](:port)?" or "host(:port)?".
2313         * Returns host (with brackets for v6) and port (nullable).
2314         */
2315        @ThreadSafe
2316        private static final class HostPort {
2317                @NonNull
2318                private final String host;
2319                @Nullable
2320                private final Integer port;
2321
2322                HostPort(@NonNull String host,
2323                                                 @Nullable Integer port) {
2324                        this.host = host;
2325                        this.port = port;
2326                }
2327
2328                @NonNull
2329                public String getHost() {
2330                        return this.host;
2331                }
2332
2333                @NonNull
2334                public Optional<Integer> getPort() {
2335                        return Optional.ofNullable(this.port);
2336                }
2337        }
2338
2339        @NonNull
2340        private static Optional<HostPort> parseForwardedHostPort(@Nullable String input) {
2341                input = trimAggressivelyToNull(input);
2342
2343                if (input == null)
2344                        return Optional.empty();
2345
2346                input = stripOptionalQuotes(input);
2347
2348                if (!HostHeaderValidator.isValidHostHeaderValue(input))
2349                        return Optional.empty();
2350
2351                return parseHostPort(input);
2352        }
2353
2354        @NonNull
2355        private static Optional<HostPort> parseHostPort(@Nullable String input) {
2356                input = trimAggressivelyToNull(input);
2357
2358                if (input == null)
2359                        return Optional.empty();
2360
2361                input = stripOptionalQuotes(input);
2362
2363                if (input.startsWith("[")) {
2364                        int close = input.indexOf(']');
2365
2366                        if (close > 0) {
2367                                String core = input.substring(1, close); // IPv6 literal without brackets
2368                                String rest = input.substring(close + 1); // maybe ":port"
2369                                String host = "[" + core + "]";
2370                                Integer port = null;
2371
2372                                if (rest.startsWith(":")) {
2373                                        String ps = trimAggressivelyToNull(rest.substring(1));
2374                                        if (ps != null) {
2375                                                try {
2376                                                        port = Integer.parseInt(ps, 10);
2377                                                } catch (Exception ignored) {
2378                                                        // Nothing to do
2379                                                }
2380                                        }
2381                                }
2382
2383                                return Optional.of(new HostPort(host, port));
2384                        }
2385                }
2386
2387                int colon = input.indexOf(':');
2388
2389                if (colon > 0 && input.indexOf(':', colon + 1) == -1) {
2390                        // exactly one ':' -> host:port (IPv4/hostname)
2391                        String h = trimAggressivelyToNull(input.substring(0, colon));
2392                        String ps = trimAggressivelyToNull(input.substring(colon + 1));
2393                        Integer p = null;
2394
2395                        if (ps != null) {
2396                                try {
2397                                        p = Integer.parseInt(ps, 10);
2398                                } catch (Exception ignored) {
2399                                        // Nothing to do
2400                                }
2401                        }
2402                        if (h != null)
2403                                return Optional.of(new HostPort(h, p));
2404                }
2405
2406                // no port
2407                return Optional.of(new HostPort(input, null));
2408        }
2409
2410        @NonNull
2411        private static String removeDotSegments(@NonNull String path) {
2412                requireNonNull(path);
2413
2414                Deque<String> stack = new ArrayDeque<>();
2415
2416                for (String seg : path.split("/", -1)) {
2417                        if (seg.isEmpty() || ".".equals(seg))
2418                                continue;
2419
2420                        if ("..".equals(seg)) {
2421                                if (!stack.isEmpty())
2422                                        stack.removeLast();
2423                        } else {
2424                                stack.addLast(seg);
2425                        }
2426                }
2427
2428                return "/" + String.join("/", stack);
2429        }
2430}