CVE-2021-43795 (Java, published 2021-12-02T18:15Z)
Description: value: Armeria is an open source microservice framework. In affected versions an attacker can access an Armeria server's local file system beyond its restricted directory by sending an HTTP request whose path contains `%2F` (encoded `/`), such as `/files/..%2Fsecrets.txt`, bypassing Armeria's path validation logic. Armeria 1.13.4 or above contains the hardened path validation logic that handles `%2F` properly. This vulnerability can be worked around by inserting a decorator that performs an additional validation on the request path.
Severity: MEDIUM (CVSS v3 base score: 7.5)
Weakness: CWE-22 \u2014 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
Repository: https://github.com/line/armeria
Files changed:
core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java
core/src/main/java/com/linecorp/armeria/internal/common/PathAndQuery.java
core/src/main/java/com/linecorp/armeria/server/RoutingResultBuilder.java
core/src/test/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtilTest.java
core/src/test/java/com/linecorp/armeria/internal/common/PathAndQueryTest.java
core/src/test/java/com/linecorp/armeria/server/RoutingResultTest.java
Commit message: Merge pull request from GHSA-8fp4-rp6c-5gcv

Motivation:

- We changed how `PathAndQuery` handles `%2F` (/) in 1.12.0 via #3855.
  This change introduces an unexpected hole in its double-dot detection
  logic.
- Since we decided not to decode `%2F`, we should not decode it
  whereever possible.

Modifications:

- Hardened the double-dot detection logic in `PathAndQuery`.
- `Bytes.data` now always store the bytes in their decoded form. We keep
  whether the byte has to be encoded in a separate `BitSet`.
- Split `ArmeriaHttpUtil.decodePath()` into `decodePath()` and
  `decodePathParam()`.
  - We don't decode `%2F` in `decodePath()` but we do in
    `decodePathParam()`.
  - `RoutingResultBuilder.rawParam()` now uses `decodePathParam()`
    because `decodePath()` doesn't decode `%2F` anymore.

Result:

- A path that contains double dots with `%2F`, such as
  `/files/..%2Fsecrets.txt`, are now rejected correctly.

Vulnerable code:
            // No need to decoded; not percent-encoded
                    buf[dstLen++] = (byte) ((digit1 << 4) | digit2);
    private static final int PERCENT_ENCODING_MARKER = 0xFF;
    private static final byte[] RAW_CHAR_TO_MARKER = new byte[256];
    private static final String[] MARKER_TO_PERCENT_ENCODED_CHAR = new String[256];
        for (final ReservedChar reservedChar : ReservedChar.values()) {
            RAW_CHAR_TO_MARKER[reservedChar.rawChar] = reservedChar.marker;
            MARKER_TO_PERCENT_ENCODED_CHAR[reservedChar.marker] = reservedChar.percentEncodedChar;
        return path + "?" + query;
        if (path.data[0] != '/') {
        if (pathContainsDoubleDots(path)) {
        return new PathAndQuery(encodeToPercents(path, true),
                                query != null ? encodeToPercents(query, false) : null);

                        // Insert a special mark so we can distinguish a raw character ('/') and
                        // percent-encoded character ('%2F') in a path string.
                        // We will encode this mark back into a percent-encoded character later.
                        final byte marker = RAW_CHAR_TO_MARKER['/'];
                        buf.ensure(2);
                        buf.add((byte) PERCENT_ENCODING_MARKER);
                        buf.add(marker);
                    final byte marker = RAW_CHAR_TO_MARKER[decoded];
                    if (marker != 0) {
                        // Insert a special mark so we can distinguish a raw character and percent-encoded
                        // character in a query string, such as '&' and '%26'.
                        // We will encode this mark back into a percent-encoded character later.
                        buf.ensure(2);
                        buf.add((byte) PERCENT_ENCODING_MARKER);
                        buf.add(marker);
                buf.add((byte) ' ');
                buf.add((byte) ((cp >>> 6) | 0b110_00000));
                buf.add((byte) (cp & 0b111111 | 0b10_000000));
                buf.add((byte) ((cp >>> 12) | 0b1110_0000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp >>> 18) | 0b11110_000));
                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp >>> 24) | 0b111110_00));
                buf.add((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp >>> 30) | 0b1111110_0));
                buf.add((byte) (((cp >>> 24) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.add((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.add((byte) ((cp & 0b111111) | 0b10_000000));
            buf.add((byte) cp);
            if (b3 == '/' && b2 == '.' && b1 == '.' && b0 == '/') {
        return b0 == '/' && b1 == '.' && b2 == '.';
    private static String encodeToPercents(Bytes value, boolean isPath) {
        final BitSet allowedChars = isPath ? ALLOWED_PATH_CHARS : ALLOWED_QUERY_CHARS;
        final int length = value.length;
        boolean needsEncoding = false;
            if (!allowedChars.get(value.data[i] & 0xFF)) {
                needsEncoding = true;
                break;
        if (!needsEncoding) {
            return new String(value.data, 0, 0, length);
        final StringBuilder buf = new StringBuilder(length);
            if (b == PERCENT_ENCODING_MARKER && (i + 1) < length) {
                final int marker = value.data[i + 1] & 0xFF;
                final String percentEncodedChar = MARKER_TO_PERCENT_ENCODED_CHAR[marker];
                if (percentEncodedChar != null) {
                    buf.append(percentEncodedChar);
                    i++;
                    continue;
                }
            if (allowedChars.get(b)) {
                buf.append((char) b);
            } else if (b == ' ') {
                if (isPath) {
                    buf.append("%20");
                } else {
                    buf.append('+');
                }
            } else {
                buf.append('%');
                appendHexNibble(buf, b >>> 4);
                appendHexNibble(buf, b & 0xF);
            }
    private static void appendHexNibble(StringBuilder buf, int nibble) {
        if (nibble < 10) {
            buf.append((char) ('0' + nibble));
        } else {
            buf.append((char) ('A' + nibble - 10));

    /**
     * Reserved characters which require percent-encoding. These values are only used for constructing
     * {@link #RAW_CHAR_TO_MARKER} and {@link #MARKER_TO_PERCENT_ENCODED_CHAR} mapping tables.
     *
     * @see <a href="https://datatracker.ietf.org/doc/html/rfc3986#section-2.2">RFC 3986, section 2.2</a>
     */
    private enum ReservedChar {
        GEN_DELIM_01(':', "%3A", (byte) 0x01),
        GEN_DELIM_02('/', "%2F", (byte) 0x02),
        GEN_DELIM_03('?', "%3F", (byte) 0x03),
        GEN_DELIM_04('#', "%23", (byte) 0x04),
        GEN_DELIM_05('[', "%5B", (byte) 0x05),
        GEN_DELIM_06(']', "%5D", (byte) 0x06),
        GEN_DELIM_07('@', "%40", (byte) 0x07),

        SUB_DELIM_01('!', "%21", (byte) 0x11),
        SUB_DELIM_02('$', "%24", (byte) 0x12),
        SUB_DELIM_03('&', "%26", (byte) 0x13),
        SUB_DELIM_04('\'', "%27", (byte) 0x14),
        SUB_DELIM_05('(', "%28", (byte) 0x15),
        SUB_DELIM_06(')', "%29", (byte) 0x16),
        SUB_DELIM_07('*', "%2A", (byte) 0x17),
        SUB_DELIM_08('+', "%2B", (byte) 0x18),
        SUB_DELIM_09(',', "%2C", (byte) 0x19),
        SUB_DELIM_10(';', "%3B", (byte) 0x1A),
        SUB_DELIM_11('=', "%3D", (byte) 0x1B);

        private final int rawChar;
        private final String percentEncodedChar;
        private final byte marker;

        ReservedChar(int rawChar, String percentEncodedChar, byte marker) {
            this.rawChar = rawChar;
            this.percentEncodedChar = percentEncodedChar;
            this.marker = marker;
        }
    }
                         ArmeriaHttpUtil.decodePath(requireNonNull(value, "value")));
    @Test
    void testDecodePath() throws Exception {
        assertThat(decodePath(pathThatDoesNotNeedDecode)).isSameAs(pathThatDoesNotNeedDecode);
        assertThat(decodePath("/foo%20bar\u007fbaz")).isEqualTo("/foo bar\u007fbaz");
        assertThat(decodePath("/%C2%A2")).isEqualTo("/¢"); // Valid UTF-8 sequence
        assertThat(decodePath("/%20\u0080")).isEqualTo("/ �"); // Unallowed character
        assertThat(decodePath("/%")).isEqualTo("/�"); // No digit
        assertThat(decodePath("/%1")).isEqualTo("/�"); // Only a single digit
        assertThat(decodePath("/%G0")).isEqualTo("/�"); // First digit is not hex.
        assertThat(decodePath("/%0G")).isEqualTo("/�"); // Second digit is not hex.
        assertThat(decodePath("/%C3%28")).isEqualTo("/�("); // Invalid UTF-8 sequence
        final PathAndQuery res = PathAndQuery.parse(null);
        final PathAndQuery res2 = PathAndQuery.parse("");
        final PathAndQuery res3 = PathAndQuery.parse("?");
        assertThat(PathAndQuery.parse("foo")).isNull();
    void doubleDots() {
        assertThat(PathAndQuery.parse("/..")).isNull();
        assertThat(PathAndQuery.parse("/../")).isNull();
        assertThat(PathAndQuery.parse("/../foo")).isNull();
        assertThat(PathAndQuery.parse("/foo/..")).isNull();
        assertThat(PathAndQuery.parse("/foo/../")).isNull();
        assertThat(PathAndQuery.parse("/foo/../bar")).isNull();
        // Escaped
        assertThat(PathAndQuery.parse("/.%2e")).isNull();
        assertThat(PathAndQuery.parse("/%2E./")).isNull();
        assertThat(PathAndQuery.parse("/foo/.%2e")).isNull();
        assertThat(PathAndQuery.parse("/foo/%2E./")).isNull();
        // Not the double dots we are looking for.
        final PathAndQuery res = PathAndQuery.parse("/..a");
        assertThat(res).isNotNull();
        assertThat(res.path()).isEqualTo("/..a");
        final PathAndQuery res2 = PathAndQuery.parse("/a..");
        assertThat(res2).isNotNull();
        assertThat(res2.path()).isEqualTo("/a..");
        assertThat(PathAndQuery.parse("/%")).isNull();
        assertThat(PathAndQuery.parse("/%0")).isNull();
        assertThat(PathAndQuery.parse("/%0X")).isNull();
        assertThat(PathAndQuery.parse("/%X0")).isNull();
        assertThat(PathAndQuery.parse("/\0")).isNull();
        assertThat(PathAndQuery.parse("/a\nb")).isNull();
        assertThat(PathAndQuery.parse("/a\u007fb")).isNull();
        assertThat(PathAndQuery.parse("/%00")).isNull();
        assertThat(PathAndQuery.parse("/a%09b")).isNull();
        assertThat(PathAndQuery.parse("/a%0ab")).isNull();
        assertThat(PathAndQuery.parse("/a%0db")).isNull();
        assertThat(PathAndQuery.parse("/a%7fb")).isNull();
        assertThat(PathAndQuery.parse("/\0?c")).isNull();
        assertThat(PathAndQuery.parse("/a\tb?c")).isNull();
        assertThat(PathAndQuery.parse("/a\nb?c")).isNull();
        assertThat(PathAndQuery.parse("/a\rb?c")).isNull();
        assertThat(PathAndQuery.parse("/a\u007fb?c")).isNull();
        assertThat(PathAndQuery.parse("/?\0")).isNull();
        assertThat(PathAndQuery.parse("/?%00")).isNull();
        assertThat(PathAndQuery.parse("/?a\u007fb")).isNull();
        assertThat(PathAndQuery.parse("/?a%7Fb")).isNull();
        assertThat(PathAndQuery.parse("/?a\tb").query()).isEqualTo("a%09b");
        assertThat(PathAndQuery.parse("/?a\nb").query()).isEqualTo("a%0Ab");
        assertThat(PathAndQuery.parse("/?a\rb").query()).isEqualTo("a%0Db");
        assertThat(PathAndQuery.parse("/?a%09b").query()).isEqualTo("a%09b");
        assertThat(PathAndQuery.parse("/?a%0Ab").query()).isEqualTo("a%0Ab");
        assertThat(PathAndQuery.parse("/?a%0Db").query()).isEqualTo("a%0Db");
        final PathAndQuery res = PathAndQuery.parse("/%25");
        final PathAndQuery res = PathAndQuery.parse("%2F?%2F");
        final PathAndQuery res1 = PathAndQuery.parse("/%2F?%2F");
        final PathAndQuery pathOnly = PathAndQuery.parse("/foo%2F");
        final PathAndQuery queryOnly = PathAndQuery.parse("/?%2f=%2F");
        final PathAndQuery res = PathAndQuery.parse(
        final PathAndQuery res2 = PathAndQuery.parse(
        assertThat(PathAndQuery.parse("/:")).isNotNull();
        assertThat(PathAndQuery.parse("/:/")).isNotNull();
        assertThat(PathAndQuery.parse("/a/:")).isNotNull();
        assertThat(PathAndQuery.parse("/a/:/")).isNotNull();
        final PathAndQuery res1 = PathAndQuery.parse("/\u00A2?\u20AC"); // ¢ and €
        final PathAndQuery res2 = PathAndQuery.parse("/\uD800\uDF48"); // 𐍈
        final PathAndQuery res = PathAndQuery.parse(encodedPath + '?' + encodedQuery);
        final PathAndQuery res = PathAndQuery.parse("/a?b=c");
        final PathAndQuery res = PathAndQuery.parse("/ ? ");
        final PathAndQuery res2 = PathAndQuery.parse("/%20?%20");
        final PathAndQuery res = PathAndQuery.parse("/+?a+b=c+d");
        final PathAndQuery res2 = PathAndQuery.parse("/%2b?a%2bb=c%2bd");
        final PathAndQuery res = PathAndQuery.parse("/&?a=1&a=2&b=3");
        final PathAndQuery res2 = PathAndQuery.parse("/%26?a=1%26a=2&b=3");
        final PathAndQuery res = PathAndQuery.parse("/;?a=b;c=d");
        final PathAndQuery res2 = PathAndQuery.parse("/%3b?a=b%3Bc=d");
        final PathAndQuery res = PathAndQuery.parse("/=?a=b=1");
        final PathAndQuery res2 = PathAndQuery.parse("/%3D?a%3db=1");
        final PathAndQuery res = PathAndQuery.parse("/#?a=b#1");
        final PathAndQuery res2 = PathAndQuery.parse("/%23?a=b%231");
        final PathAndQuery res = PathAndQuery.parse("/#/:[]@!$&'()*+,;=?a=/#/:[]@!$&'()*+,;=");
                PathAndQuery.parse("/%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F" +
                                   "?a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
        final PathAndQuery res = PathAndQuery.parse("/\"?\"");
import java.util.AbstractMap.SimpleEntry;
        assertThat(routingResult.pathParams()).containsOnly(new SimpleEntry<>("qux", "quux"));

Fixed code:
            // No need to decode because it's not percent-encoded
        // Decode percent-encoded characters, but don't decode %2F into /, so that a user can choose
        // to use it as a non-separator.
        //
        // For example, for the path pattern `/orgs/{org_name}/agents/{agent_name}`:
        // - orgs/mi6/agents/ethan-hunt
        //   - org_name: mi6
        //   - agent_name: ethan-hunt
        // - orgs/mi%2F6/agents/ethan-hunt
        //   - org_name: mi/6
        //   - agent_name: ethan-hunt
        return slowDecodePath(path, false);
    }

    /**
     * Decodes a single percent-encoded path parameter.
     */
    public static String decodePathParam(String pathParam) {
        if (pathParam.indexOf('%') < 0) {
            // No need to decode because it's not percent-encoded
            return pathParam;
        }

        return slowDecodePath(pathParam, true);
    }

    private static String slowDecodePath(String path, boolean decodeSlash) {
                    final byte decoded = (byte) ((digit1 << 4) | digit2);
                    if (decodeSlash || decoded != 0x2F) {
                        buf[dstLen++] = decoded;
                    } else {
                        buf[dstLen++] = '%';
                        buf[dstLen++] = '2';
                        buf[dstLen++] = (byte) path.charAt(i); // f or F - preserve the case.
                    }
    /**
     * The lookup table for the characters allowed in a path.
     */

    /**
     * The lookup table for the characters allowed in a query string.
     */
    /**
     * The lookup table for the reserved characters that require percent-encoding.
     */
    private static final BitSet RESERVED_CHARS = new BitSet();
    /**
     * The table that converts a byte into a percent-encoded chars, e.g. 'A' -> "%41".
     */
    private static final char[][] TO_PERCENT_ENCODED_CHARS = new char[256][];
        final String reservedChars = ":/?#[]@!$&'()*+,;=";
        for (int i = 0; i < reservedChars.length(); i++) {
            RESERVED_CHARS.set(reservedChars.charAt(i));
        }

        for (int i = 0; i < TO_PERCENT_ENCODED_CHARS.length; i++) {
            TO_PERCENT_ENCODED_CHARS[i] = String.format("%%%02X", i).toCharArray();
        return path + '?' + query;
        if (path.data[0] != '/' || path.isEncoded(0)) {
        if (pathContainsDoubleDots(path) || queryContainsDoubleDots(query)) {
        return new PathAndQuery(encodePathToPercents(path), encodeQueryToPercents(query));
                        buf.ensure(1);
                        buf.addEncoded((byte) '/');
                    if (RESERVED_CHARS.get(decoded)) {
                        buf.ensure(1);
                        buf.addEncoded((byte) decoded);
                buf.addEncoded((byte) ' ');
                buf.addEncoded((byte) ((cp >>> 6) | 0b110_00000));
                buf.addEncoded((byte) (cp & 0b111111 | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 12) | 0b1110_0000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 18) | 0b11110_000));
                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 24) | 0b111110_00));
                buf.addEncoded((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp >>> 30) | 0b1111110_0));
                buf.addEncoded((byte) (((cp >>> 24) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 18) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 12) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) (((cp >>> 6) & 0b111111) | 0b10_000000));
                buf.addEncoded((byte) ((cp & 0b111111) | 0b10_000000));
            final BitSet allowedChars = isPath ? ALLOWED_PATH_CHARS : ALLOWED_QUERY_CHARS;
            if (allowedChars.get(cp)) {
                buf.add((byte) cp);
            } else {
                buf.addEncoded((byte) cp);
            }
            // Flag if the last four bytes are `/../`.
            if (b1 == '.' && b2 == '.' && isSlash(b0) && isSlash(b3)) {
        // Flag if the last three bytes are `/..`.
        return b1 == '.' && b2 == '.' && isSlash(b0);
    private static boolean queryContainsDoubleDots(@Nullable Bytes query) {
        if (query == null) {
            return false;
        }

        final int length = query.length;
        boolean lookingForEquals = true;
        byte b0 = 0;
        byte b1 = 0;
        byte b2 = '/';
            byte b3 = query.data[i];

            // Treat the delimiters as `/` so that we can use isSlash() for matching them.
            switch (b3) {
                case '=':
                    // Treat only the first `=` as `/`, e.g.
                    // - `foo=..` and `foo=../` should be flagged.
                    // - `foo=..=` shouldn't be flagged because `..=` is not a relative path.
                    if (lookingForEquals) {
                        lookingForEquals = false;
                        b3 = '/';
                    }
                    break;
                case '&':
                case ';':
                    b3 = '/';
                    lookingForEquals = true;
                    break;
            }

            // Flag if the last four bytes are `/../` or `/..&`
            if (b1 == '.' && b2 == '.' && isSlash(b0) && isSlash(b3)) {
                return true;

            b0 = b1;
            b1 = b2;
            b2 = b3;
        }

        return b1 == '.' && b2 == '.' && isSlash(b0);
    }

    private static boolean isSlash(byte b) {
        switch (b) {
            case '/':
            case '\\':
                return true;
            default:
                return false;
    }
    private static String encodePathToPercents(Bytes value) {
        if (!value.hasEncodedBytes()) {
            return new String(value.data, 0, 0, value.length);
        // Slow path: some percent-encoded chars.
        return slowEncodePathToPercents(value);
    }

    @Nullable
    private static String encodeQueryToPercents(@Nullable Bytes value) {
        if (value == null) {
            return null;
        }

        if (!value.hasEncodedBytes()) {
            // Deprecated, but it fits perfect for our use case.
            // noinspection deprecation
            return new String(value.data, 0, 0, value.length);
        }

        // Slow path: some percent-encoded chars.
        return slowEncodeQueryToPercents(value);
    }

    private static String slowEncodePathToPercents(Bytes value) {
        final int length = value.length;
        final StringBuilder buf = new StringBuilder(length + value.numEncodedBytes() * 2);
            if (value.isEncoded(i)) {
                buf.append(TO_PERCENT_ENCODED_CHARS[b]);
                continue;
            buf.append((char) b);
    private static String slowEncodeQueryToPercents(Bytes value) {
        final int length = value.length;
        final StringBuilder buf = new StringBuilder(length + value.numEncodedBytes() * 2);
        for (int i = 0; i < length; i++) {
            final int b = value.data[i] & 0xFF;

            if (value.isEncoded(i)) {
                if (b == ' ') {
                    buf.append('+');
                } else {
                    buf.append(TO_PERCENT_ENCODED_CHARS[b]);
                }
                continue;
            }

            buf.append((char) b);

        return buf.toString();
        @Nullable
        private BitSet encoded;
        private int numEncodedBytes;
        void addEncoded(byte b) {
            if (encoded == null) {
                encoded = new BitSet();
            }
            encoded.set(length);
            data[length++] = b;
            numEncodedBytes++;
        }

        boolean isEncoded(int index) {
            return encoded != null && encoded.get(index);
        }

        boolean hasEncodedBytes() {
            return encoded != null;
        }

        int numEncodedBytes() {
            return numEncodedBytes;
        }

                         ArmeriaHttpUtil.decodePathParam(requireNonNull(value, "value")));
import java.util.function.Function;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
    @ParameterizedTest
    @CsvSource({ "true", "false" })
    void testDecodePath(boolean isPathParam) throws Exception {
        final Function<String, String> decodeFunc;
        if (isPathParam) {
            decodeFunc = ArmeriaHttpUtil::decodePathParam;
        } else {
            decodeFunc = ArmeriaHttpUtil::decodePath;
        }

        assertThat(decodeFunc.apply(pathThatDoesNotNeedDecode)).isSameAs(pathThatDoesNotNeedDecode);
        assertThat(decodeFunc.apply("/foo%20bar\u007fbaz")).isEqualTo("/foo bar\u007fbaz");
        assertThat(decodeFunc.apply("/%C2%A2")).isEqualTo("/¢"); // Valid UTF-8 sequence
        assertThat(decodeFunc.apply("/%20\u0080")).isEqualTo("/ �"); // Unallowed character
        assertThat(decodeFunc.apply("/%")).isEqualTo("/�"); // No digit
        assertThat(decodeFunc.apply("/%1")).isEqualTo("/�"); // Only a single digit
        assertThat(decodeFunc.apply("/%G0")).isEqualTo("/�"); // First digit is not hex.
        assertThat(decodeFunc.apply("/%0G")).isEqualTo("/�"); // Second digit is not hex.
        assertThat(decodeFunc.apply("/%C3%28")).isEqualTo("/�("); // Invalid UTF-8 sequence

        // %2F (/) must be decoded only for path parameters.
        if (isPathParam) {
            assertThat(decodeFunc.apply("/%2F")).isEqualTo("//");
        } else {
            assertThat(decodeFunc.apply("/%2F")).isEqualTo("/%2F");
        }
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;

import com.linecorp.armeria.common.QueryParams;
import com.linecorp.armeria.common.annotation.Nullable;

    private static final Logger logger = LoggerFactory.getLogger(PathAndQueryTest.class);

    private static final Set<String> QUERY_SEPARATORS = ImmutableSet.of("&", ";");

    private static final Set<String> BAD_DOUBLE_DOT_PATTERNS = ImmutableSet.of(
            "..", "/..", "../", "/../",
            "../foo", "/../foo",
            "foo/..", "/foo/..",
            "foo/../", "/foo/../",
            "foo/../bar", "/foo/../bar",

            // Dots escaped
            ".%2e", "/.%2e", "%2E./", "/%2E./", ".%2E/", "/.%2E/",
            "foo/.%2e", "/foo/.%2e",
            "foo/%2E./", "/foo/%2E./",
            "foo/%2E./bar", "/foo/%2E./bar",

            // Slashes escaped
            "%2f..", "..%2F", "/..%2F", "%2F../", "%2f..%2f",
            "/foo%2f..", "/foo%2f../", "/foo/..%2f","/foo%2F..%2F",

            // Dots and slashes escaped
            ".%2E%2F"
    );

    private static final Set<String> GOOD_DOUBLE_DOT_PATTERNS = ImmutableSet.of(
            "..a", "a..", "a..b",
            "/..a", "/a..", "/a..b",
            "..a/", "a../", "a..b/",
            "/..a/", "/a../", "/a..b/"
    );

        final PathAndQuery res = parse(null);
        final PathAndQuery res2 = parse("");
        final PathAndQuery res3 = parse("?");
        assertThat(parse("foo")).isNull();
    void doubleDotsInPath() {
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> assertProhibited(pattern));
        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            final String path = pattern.startsWith("/") ? pattern : '/' + pattern;
            final PathAndQuery res = parse(path);
            assertThat(res).as("Ensure %s is allowed.", path).isNotNull();
            assertThat(res.path()).as("Ensure %s is parsed as-is.", path).isEqualTo(path);
        });
    }
    @Test
    void doubleDotsInFreeFormQuery() {
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertProhibited("/?" + pattern);
        });

        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertQueryStringAllowed("/?" + pattern, pattern);
        });
    }
    @Test
    void doubleDotsInNameValueQuery() {
        // Dots in a query param name.
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertProhibited("/?" + pattern + "=foo");
        });
        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertQueryStringAllowed("/?" + pattern + "=foo");
        });

        // Dots in a query param value.
        BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertProhibited("/?foo=" + pattern);
        });
        GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
            assertQueryStringAllowed("/?foo=" + pattern);
        });

        QUERY_SEPARATORS.forEach(qs -> {
            // Dots in the second query param name.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + pattern + "=c");
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + pattern + "=c");
            });

            // Dots in the second query param value.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + "c=" + pattern);
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + "c=" + pattern);
            });

            // Dots in the name of the query param in the middle.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + pattern + "=c" + qs + "d=e");
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + pattern + "=c" + qs + "d=e");
            });

            // Dots in the value of the query param in the middle.
            BAD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertProhibited("/?a=b" + qs + "c=" + pattern + qs + "d=e");
            });
            GOOD_DOUBLE_DOT_PATTERNS.forEach(pattern -> {
                assertQueryStringAllowed("/?a=b" + qs + "c=" + pattern + qs + "d=e");
            });
        });
    }

    /**
     * {@link PathAndQuery} treats the first `=` in a query parameter as `/` internally to simplify
     * the detection the logic. This test makes sure the `=` appeared later is not treated as `/`.
     */
    @Test
    void dotsAndEqualsInNameValueQuery() {
        QUERY_SEPARATORS.forEach(qs -> {
            final PathAndQuery res = parse("/?a=..=" + qs + "b=..=");
            assertThat(res).isNotNull();
            assertThat(res.query()).isEqualTo("a=..=" + qs + "b=..=");
            assertThat(QueryParams.fromQueryString(res.query(), true)).containsExactly(
                    Maps.immutableEntry("a", "..="),
                    Maps.immutableEntry("b", "..=")
            );

            final PathAndQuery res2 = parse("/?a==.." + qs + "b==..");
            assertThat(res2).isNotNull();
            assertThat(res2.query()).isEqualTo("a==.." + qs + "b==..");
            assertThat(QueryParams.fromQueryString(res2.query(), true)).containsExactly(
                    Maps.immutableEntry("a", "=.."),
                    Maps.immutableEntry("b", "=..")
            );

            final PathAndQuery res3 = parse("/?a==..=" + qs + "b==..=");
            assertThat(res3).isNotNull();
            assertThat(res3.query()).isEqualTo("a==..=" + qs + "b==..=");
            assertThat(QueryParams.fromQueryString(res3.query(), true)).containsExactly(
                    Maps.immutableEntry("a", "=..="),
                    Maps.immutableEntry("b", "=..=")
            );
        });
        assertThat(parse("/%")).isNull();
        assertThat(parse("/%0")).isNull();
        assertThat(parse("/%0X")).isNull();
        assertThat(parse("/%X0")).isNull();
        assertThat(parse("/\0")).isNull();
        assertThat(parse("/a\nb")).isNull();
        assertThat(parse("/a\u007fb")).isNull();
        assertThat(parse("/%00")).isNull();
        assertThat(parse("/a%09b")).isNull();
        assertThat(parse("/a%0ab")).isNull();
        assertThat(parse("/a%0db")).isNull();
        assertThat(parse("/a%7fb")).isNull();
        assertThat(parse("/\0?c")).isNull();
        assertThat(parse("/a\tb?c")).isNull();
        assertThat(parse("/a\nb?c")).isNull();
        assertThat(parse("/a\rb?c")).isNull();
        assertThat(parse("/a\u007fb?c")).isNull();
        assertThat(parse("/?\0")).isNull();
        assertThat(parse("/?%00")).isNull();
        assertThat(parse("/?a\u007fb")).isNull();
        assertThat(parse("/?a%7Fb")).isNull();
        assertThat(parse("/?a\tb").query()).isEqualTo("a%09b");
        assertThat(parse("/?a\nb").query()).isEqualTo("a%0Ab");
        assertThat(parse("/?a\rb").query()).isEqualTo("a%0Db");
        assertThat(parse("/?a%09b").query()).isEqualTo("a%09b");
        assertThat(parse("/?a%0Ab").query()).isEqualTo("a%0Ab");
        assertThat(parse("/?a%0Db").query()).isEqualTo("a%0Db");
        final PathAndQuery res = parse("/%25");
        final PathAndQuery res = parse("%2F?%2F");
        final PathAndQuery res1 = parse("/%2F?%2F");
        final PathAndQuery pathOnly = parse("/foo%2F");
        final PathAndQuery queryOnly = parse("/?%2f=%2F");
        final PathAndQuery res = parse(
        final PathAndQuery res2 = parse(
        assertThat(parse("/:")).isNotNull();
        assertThat(parse("/:/")).isNotNull();
        assertThat(parse("/a/:")).isNotNull();
        assertThat(parse("/a/:/")).isNotNull();
        final PathAndQuery res1 = parse("/\u00A2?\u20AC"); // ¢ and €
        final PathAndQuery res2 = parse("/\uD800\uDF48"); // 𐍈
        final PathAndQuery res = parse(encodedPath + '?' + encodedQuery);
        final PathAndQuery res = parse("/a?b=c");
        final PathAndQuery res = parse("/ ? ");
        final PathAndQuery res2 = parse("/%20?%20");
        final PathAndQuery res = parse("/+?a+b=c+d");
        final PathAndQuery res2 = parse("/%2b?a%2bb=c%2bd");
        final PathAndQuery res = parse("/&?a=1&a=2&b=3");
        final PathAndQuery res2 = parse("/%26?a=1%26a=2&b=3");
        final PathAndQuery res = parse("/;?a=b;c=d");
        final PathAndQuery res2 = parse("/%3b?a=b%3Bc=d");
        final PathAndQuery res = parse("/=?a=b=1");
        final PathAndQuery res2 = parse("/%3D?a%3db=1");
        final PathAndQuery res = parse("/#?a=b#1");
        final PathAndQuery res2 = parse("/%23?a=b%231");
        final PathAndQuery res = parse("/#/:[]@!$&'()*+,;=?a=/#/:[]@!$&'()*+,;=");
                parse("/%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F" +
                      "?a=%23%2F%3A%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D%3F");
        final PathAndQuery res = parse("/\"?\"");

    private static void assertProhibited(String rawPath) {
        assertThat(parse(rawPath))
                .as("Ensure parse(\"%s\") returns null.", rawPath)
                .isNull();
    }

    private static void assertQueryStringAllowed(String rawPath) {
        assertThat(rawPath).startsWith("/?");
        final String expectedQuery = rawPath.substring(2);
        assertQueryStringAllowed(rawPath, expectedQuery);
    }

    private static void assertQueryStringAllowed(String rawPath, String expectedQuery) {
        final PathAndQuery res = parse(rawPath);
        assertThat(res)
                .as("parse(\"%s\") must return non-null.", rawPath)
                .isNotNull();
        assertThat(res.query())
                .as("parse(\"%s\").query() must return \"%s\".", rawPath, expectedQuery)
                .isEqualTo(expectedQuery);
    }

    @Nullable
    private static PathAndQuery parse(@Nullable String rawPath) {
        final PathAndQuery res = PathAndQuery.parse(rawPath);
        if (res != null) {
            logger.info("parse({}) => path: {}, query: {}", rawPath, res.path(), res.query());
        } else {
            logger.info("parse({}) => null", rawPath);
        }
        return res;
    }
import com.google.common.collect.Maps;

        assertThat(routingResult.pathParams()).containsOnly(Maps.immutableEntry("qux", "quux"));

    @Test
    void percentEncodedPathParam() {
        final RoutingResultBuilder builder = RoutingResult.builder();
        final RoutingResult routingResult = builder.path("/foo")
                                                   .rawParam("bar", "%62az%2Fqu%78")
                                                   .build();
        assertThat(routingResult.pathParams()).containsOnly(Maps.immutableEntry("bar", "baz/qux"));
    }