All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher Maven / Gradle / Ivy

package io.quarkus.vertx.http.runtime.security;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.BiConsumer;

import io.quarkus.runtime.configuration.ConfigurationException;
import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch;

/**
 * Handler that dispatches to a given handler based on a match of the path.
 */
public class ImmutablePathMatcher {

    private final ImmutableSubstringMap paths;
    private final Map exactPathMatches;

    /**
     * lengths of all registered paths
     */
    private final int[] lengths;
    private final T defaultHandler;
    private final boolean hasPathWithInnerWildcard;
    private final boolean hasExactPathMatches;

    private ImmutablePathMatcher(T defaultHandler, ImmutableSubstringMap paths, Map exactPathMatches,
            int[] lengths, boolean hasPathWithInnerWildcard) {
        this.defaultHandler = defaultHandler;
        this.paths = paths;
        this.lengths = Arrays.copyOf(lengths, lengths.length);
        this.hasPathWithInnerWildcard = hasPathWithInnerWildcard;
        if (exactPathMatches.isEmpty()) {
            this.exactPathMatches = null;
            this.hasExactPathMatches = false;
        } else {
            this.exactPathMatches = Map.copyOf(exactPathMatches);
            this.hasExactPathMatches = true;
        }
    }

    /**
     * Matches a path against the registered handlers.
     *
     * @param path The relative path to match
     * @return The match. This will never be null, however if none matched its value field will be
     */
    public PathMatch match(String path) {
        if (hasExactPathMatches) {
            T match = exactPathMatches.get(path);
            if (match != null) {
                return new PathMatch<>(path, "", match);
            }
        }

        int length = path.length();
        for (int pathLength : lengths) {
            if (pathLength == length) {
                SubstringMatch next = paths.get(path, length);
                if (next != null) {
                    return new PathMatch<>(path, "", next.getValue());
                }
            } else if (pathLength < length) {
                char c = path.charAt(pathLength);
                // pathLength == 1 means prefix path is / because prefix path always starts with /
                // which means it's default handler match, but if there is at least
                // one path with inner wildcard, we need to check for paths like /*/one
                if (c == '/' || (hasPathWithInnerWildcard && pathLength == 1)) {

                    //String part = path.substring(0, pathLength);
                    SubstringMatch next = paths.get(path, pathLength);
                    if (next != null) {
                        return new PathMatch<>(next.getKey(), path.substring(pathLength), next.getValue());
                    }
                }
            }
        }
        return new PathMatch<>("", path, defaultHandler);
    }

    public static  ImmutablePathMatcherBuilder builder() {
        return new ImmutablePathMatcherBuilder<>();
    }

    public static final class PathMatch {
        private final String matched;
        private final String remaining;
        private final T value;

        public PathMatch(String matched, String remaining, T value) {
            this.matched = matched;
            this.remaining = remaining;
            this.value = value;
        }

        /**
         * @deprecated because it can't be supported with inner wildcard without cost. It's unlikely this method is
         *             used by anyone as users don't get in touch with this class. If there is legit use case, please
         *             open Quarkus issue.
         */
        @Deprecated
        public String getRemaining() {
            return remaining;
        }

        public String getMatched() {
            return matched;
        }

        public T getValue() {
            return value;
        }
    }

    public static class ImmutablePathMatcherBuilder {

        private static final String STRING_PATH_SEPARATOR = "/";
        private final Map exactPathMatches = new HashMap<>();
        /**
         * Exact paths we proactively secure when more specify permissions are not specified.
         * For example path for exact path '/api/hello' we add extra pattern for the '/api/hello/'.
         * This helps to secure Jakarta REST endpoints by default as both paths may point to the same endpoint there.
         * However, we only do that when user didn't declare any permission for the '/api/hello/'.
         * This way, user can still forbid access to the `/api/hello' path and permit access to the '/api/hello/' path.
         */
        private final Map additionalExactPathMatches = new HashMap<>();
        private final Map> pathsWithWildcard = new HashMap<>();
        private BiConsumer handlerAccumulator;
        private String rootPath;
        private boolean empty = true;

        private ImmutablePathMatcherBuilder() {
        }

        /**
         * @param handlerAccumulator policies defined with same path are accumulated, this way, you can define
         *        more than one policy of one path (e.g. one for POST method, one for GET method)
         * @return ImmutablePathMatcherBuilder
         */
        public ImmutablePathMatcherBuilder handlerAccumulator(BiConsumer handlerAccumulator) {
            this.handlerAccumulator = handlerAccumulator;
            return this;
        }

        public boolean hasPaths() {
            return !empty;
        }

        /**
         * @param rootPath Path to which relative patterns (paths not starting with a separator) are linked.
         * @return ImmutablePathMatcherBuilder
         */
        public ImmutablePathMatcherBuilder rootPath(String rootPath) {
            this.rootPath = rootPath;
            return this;
        }

        public ImmutablePathMatcher build() {
            T defaultHandler = null;
            var paths = ImmutableSubstringMap. builder();
            boolean hasPathWithInnerWildcard = false;
            // process paths with a wildcard first, that way we only create inner path matcher when really needed
            for (Path p : pathsWithWildcard.values()) {
                T handler = null;
                ImmutablePathMatcher> subPathMatcher = null;

                if (p.prefixPathHandler != null) {
                    handler = p.prefixPathHandler;
                    if (STRING_PATH_SEPARATOR.equals(p.path)) {
                        if (defaultHandler == null) {
                            defaultHandler = p.prefixPathHandler;
                        } else {
                            handlerAccumulator.accept(defaultHandler, p.prefixPathHandler);
                        }
                    }
                }

                if (p.pathsWithInnerWildcard != null) {
                    if (!hasPathWithInnerWildcard) {
                        hasPathWithInnerWildcard = true;
                    }
                    // create path matcher for sub-path after inner wildcard: /one/*/three/four => /three/four
                    var builder = new ImmutablePathMatcherBuilder>();
                    if (handlerAccumulator != null) {
                        builder.handlerAccumulator(
                                new BiConsumer, SubstringMatch>() {
                                    @Override
                                    public void accept(SubstringMatch match1, SubstringMatch match2) {
                                        if (match2.hasSubPathMatcher()) {
                                            // this should be impossible to happen since these matches are created
                                            // right in this 'build()' method, but let's make sure of that
                                            throw new IllegalStateException(
                                                    String.format("Failed to merge sub-matches with key '%s' for path '%s'",
                                                            match1.getKey(), p.originalPath));
                                        }
                                        handlerAccumulator.accept(match1.getValue(), match2.getValue());
                                    }
                                });
                    }
                    for (PathWithInnerWildcard p1 : p.pathsWithInnerWildcard) {
                        builder.addPath(p.originalPath, p1.remaining, new SubstringMatch<>(p1.remaining, p1.handler));
                    }
                    subPathMatcher = builder.build();
                }

                paths.put(p.path, handler, subPathMatcher);
            }
            for (var e : additionalExactPathMatches.entrySet()) {
                exactPathMatches.putIfAbsent(e.getKey(), e.getValue());
            }
            int[] lengths = buildLengths(paths.keys());
            return new ImmutablePathMatcher<>(defaultHandler, paths.build(), exactPathMatches, lengths,
                    hasPathWithInnerWildcard);
        }

        /**
         * Two sorts of paths are accepted:
         * - exact path matches (without wildcard); these are matched first and Quarkus does no magic,
         * request path must exactly match
         * - paths with one or more wildcard:
         * - ending wildcard matches zero or more path segment
         * - inner wildcard matches exactly one path segment
         * few notes:
         * - it's key to understand only segments are matched, for example '/one*' will not match request path '/ones'
         * - path patterns '/one*' and '/one/*' are one and the same thing as we only match path segments and '/one*'
         * in fact means 'either /one or /one/any-number-of-path-segments'
         * - paths are matched on longer-prefix-wins basis
         * - what we call 'prefix' is in fact path to the first wildcard
         * - if there is a path after first wildcard like in the '/one/*\/three' pattern ('/three' is remainder)
         * path pattern is considered longer than the '/one/*' pattern and wins for request path '/one/two/three'
         * - more specific pattern wins and wildcard is always less specific than any other path segment character,
         * therefore path '/one/two/three*' will win over '/one/*\/three*' for request path '/one/two/three/four'
         *
         * @param path normalized path
         * @param handler prefix path handler
         * @return self
         */
        public ImmutablePathMatcherBuilder addPath(String path, T handler) {
            if (empty) {
                empty = false;
            }
            path = path.trim();
            if (rootPath != null && !path.startsWith("/")) {
                path = rootPath + path;
            }
            return addPath(path, path, handler);
        }

        private ImmutablePathMatcherBuilder addPath(String originalPath, String path, T handler) {
            if (!path.startsWith("/")) {
                String errMsg = "Path must always start with a path separator, but was '" + path + "'";
                if (!originalPath.equals(path)) {
                    errMsg += " created from original path pattern '" + originalPath + "'";
                }
                throw new IllegalArgumentException(errMsg);
            }
            final int wildcardIdx = path.indexOf('*');
            if (wildcardIdx == -1) {
                addExactPath(path, handler);
            } else {
                addWildcardPath(path, handler, wildcardIdx, originalPath);
            }
            return this;
        }

        private void addWildcardPath(String path, T handler, int wildcardIdx, String originalPath) {
            final int lastIdx = path.length() - 1;
            final String pathWithWildcard;
            final String pathAfter1stWildcard;

            if (lastIdx == wildcardIdx) {
                // ends with a wildcard => it's a prefix path
                pathWithWildcard = path;
                pathAfter1stWildcard = null;
            } else {
                // contains at least one inner wildcard: /one/*/three, /one/two/*/four/*, ...
                // the inner wildcard represents exactly one path segment
                pathWithWildcard = path.substring(0, wildcardIdx + 1);
                pathAfter1stWildcard = path.substring(wildcardIdx + 1);

                // validate that inner wildcard is enclosed with path separators like: /one/*/two
                // anything like: /one*/two, /one/*two/, /one/tw*o/ is not allowed
                if (!pathWithWildcard.endsWith("/*") || !pathAfter1stWildcard.startsWith("/")) {
                    throw new ConfigurationException("HTTP permission path '" + originalPath + "' contains inner "
                            + "wildcard enclosed with a path character other than a separator. The inner wildcard "
                            + "must represent exactly one path segment. Please see this Quarkus guide for more "
                            + "information: https://quarkus.io/guides/security-authorize-web-endpoints-reference");
                }
            }

            final String pathWithoutWildcard;
            if (pathWithWildcard.endsWith("/*")) {
                // remove /*
                String stripped = pathWithWildcard.substring(0, pathWithWildcard.length() - 2);
                pathWithoutWildcard = stripped.isEmpty() ? "/" : stripped;
            } else {
                // remove *
                pathWithoutWildcard = pathWithWildcard.substring(0, pathWithWildcard.length() - 1);
            }

            Path p = pathsWithWildcard.computeIfAbsent(pathWithoutWildcard, Path::new);
            p.originalPath = originalPath;
            if (pathAfter1stWildcard == null) {
                p.addPrefixPath(handler, handlerAccumulator);
            } else {
                p.addPathWithInnerWildcard(pathAfter1stWildcard, handler);
            }
        }

        private void addExactPath(final String path, final T handler) {
            if (path.isEmpty()) {
                throw new IllegalArgumentException("Path not specified");
            }
            if (exactPathMatches.containsKey(path) && handlerAccumulator != null) {
                handlerAccumulator.accept(exactPathMatches.get(path), handler);
            } else {
                exactPathMatches.put(path, handler);
            }
            // when 'path.equals("/api/hello")' then the other path is '/api/hello/'
            final String otherPath;
            if (path.endsWith(STRING_PATH_SEPARATOR)) {
                if (path.length() == 1) {
                    // path '/' is only valid option, '' is not allowed
                    return;
                }
                // drop path separator
                otherPath = path.substring(0, path.length() - 1);
            } else {
                otherPath = path + STRING_PATH_SEPARATOR;
            }
            // if key is already present, then we have the right handler into which new ones have already been merged
            additionalExactPathMatches.putIfAbsent(otherPath, handler);
        }

        private static int[] buildLengths(Iterable keys) {
            final Set lengths = new TreeSet<>(new Comparator() {
                @Override
                public int compare(Integer o1, Integer o2) {
                    return -o1.compareTo(o2);
                }
            });
            for (String p : keys) {
                lengths.add(p.length());
            }

            int[] lengthArray = new int[lengths.size()];
            int pos = 0;
            for (int i : lengths) {
                lengthArray[pos++] = i;
            }
            return lengthArray;
        }
    }

    private static class Path {
        private final String path;
        private String originalPath = null;
        private T prefixPathHandler = null;
        private List> pathsWithInnerWildcard = null;

        private Path(String path) {
            this.path = path;
        }

        private void addPathWithInnerWildcard(String remaining, T handler) {
            if (pathsWithInnerWildcard == null) {
                pathsWithInnerWildcard = new ArrayList<>();
            }
            pathsWithInnerWildcard.add(new PathWithInnerWildcard<>(remaining, handler));
        }

        public void addPrefixPath(T prefixPathHandler, BiConsumer handlerAccumulator) {
            Objects.requireNonNull(prefixPathHandler);
            if (this.prefixPathHandler != null && handlerAccumulator != null) {
                handlerAccumulator.accept(this.prefixPathHandler, prefixPathHandler);
            } else {
                this.prefixPathHandler = prefixPathHandler;
            }
        }
    }

    private record PathWithInnerWildcard(String remaining, T handler) {
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy