From bae0f038fd9ced46fba56d01e255036f56e01290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Wed, 22 Nov 2023 00:49:58 +0100 Subject: [PATCH] Support repeated wildcards in the HTTP permissions paths --- ...ity-authorize-web-endpoints-reference.adoc | 59 +- .../PathMatchingHttpSecurityPolicyTest.java | 31 ++ ...bstractPathMatchingHttpSecurityPolicy.java | 40 +- .../security/ImmutablePathMatcher.java | 346 ++++++++++++ .../security/ImmutableSubstringMap.java | 135 +++++ .../http/runtime/security/PathMatcher.java | 11 +- .../http/runtime/security/SubstringMap.java | 85 ++- .../vertx/http/runtime/PathMatcherTest.java | 511 ++++++++++++++++++ 8 files changed, 1142 insertions(+), 76 deletions(-) create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java create mode 100644 extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java diff --git a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc index 41a9d39bf3fed..698225dd0f3a3 100644 --- a/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc +++ b/docs/src/main/asciidoc/security-authorize-web-endpoints-reference.adoc @@ -68,6 +68,8 @@ It is an exact path match because it does not end with `*`. <3> This permission set references the previously defined policy. `roles1` is an example name; you can call the permission sets whatever you want. +WARNING: The `/forbidden` exact path in the example above will not secure the `/forbidden/` path. Don't forget to add new exact path for the `/forbidden/` path. + === Custom HttpSecurityPolicy Sometimes it might be useful to register your own named policy. You can get it done by creating application scoped CDI @@ -123,10 +125,12 @@ Otherwise, it queries for an exact match and only matches that specific path: [source,properties] ---- -quarkus.http.auth.permission.permit1.paths=/public/*,/css/*,/js/*,/robots.txt +quarkus.http.auth.permission.permit1.paths=/public*,/css/*,/js/*,/robots.txt <1> quarkus.http.auth.permission.permit1.policy=permit quarkus.http.auth.permission.permit1.methods=GET,HEAD ---- +<1> The `$$*$$` wildcard at the end of the path matches zero or more path segments, but never any word starting from the `/public` path. +For that reason, a path like `/public-info` is not matched by this pattern. === Matching a path but not a method @@ -170,6 +174,59 @@ quarkus.http.auth.permission.public.policy=permit ---- ==== +=== Matching multiple sub-paths: longest path to the `*` wildcard wins + +Previous examples shown how you can match all sub-paths when a path ends with the `$$*$$` wildcard. +The `$$*$$` wildcard can also be used in the middle of the path, in which case it represents exactly one path segment. +You can't combine this wildcard with any other path segment character, therefore the `$$*$$` wildcard will always be +enclosed with path separators as in the `/public/$$*$$/about-us` path. + +What happens if multiple path patterns matches same request path? +Matching is always done on the "longest sub-path to the `$$*$$` wildcard wins" basis. +Every path segment character is considered more specific than the `$$*$$` wildcard. + +Here is a simple example: + +[source,properties] +---- +quarkus.http.auth.permission.secured.paths=/api/*/detail <1> +quarkus.http.auth.permission.secured.policy=authenticated +quarkus.http.auth.permission.public.paths=/api/public-product/detail <2> +quarkus.http.auth.permission.public.policy=permit +---- +<1> Request paths like `/api/product/detail` can only be accessed by authenticated users. +<2> The path `/api/public-product/detail` is more specific, therefore accessible by anyone. + +[IMPORTANT] +==== +All paths secured with the authorization using configuration should be tested. +Writing path patterns with multiple wildcards can be cumbersome. +Please make sure paths are authorized as you intended. +==== + +In the following example, paths are ordered from the most specific to the least specific one: + +.Request path `/one/two/three/four/five` matches ordered from the most specific to the least specific path + +[source, text] +---- +/one/two/three/four/five +/one/two/three/four/* +/one/two/three/*/five +/one/two/three/*/* +/one/two/*/four/five +/one/*/three/four/five +/*/two/three/four/five +/*/two/three/*/five +/* +---- + +[IMPORTANT] +==== +The `$$*$$` wildcard at the end of the path matches zero or more path segments. +The `$$*$$` wildcard placed anywhere else matches exactly one path segment. +==== + === Matching multiple paths: most specific method wins When a path is registered with multiple permission sets, the permission sets explicitly specifying an HTTP method that matches the request take precedence. diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java index 08679d345bcaa..3169e6bb6067d 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathMatchingHttpSecurityPolicyTest.java @@ -41,6 +41,18 @@ public class PathMatchingHttpSecurityPolicyTest { "quarkus.http.auth.permission.public.policy=permit\n" + "quarkus.http.auth.permission.foo.paths=/api/foo/bar\n" + "quarkus.http.auth.permission.foo.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard.paths=/api/*/bar\n" + + "quarkus.http.auth.permission.inner-wildcard.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard2.paths=/api/next/*/prev\n" + + "quarkus.http.auth.permission.inner-wildcard2.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard3.paths=/api/one/*/three/*\n" + + "quarkus.http.auth.permission.inner-wildcard3.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard4.paths=/api/one/*/*/five\n" + + "quarkus.http.auth.permission.inner-wildcard4.policy=authenticated\n" + + "quarkus.http.auth.permission.inner-wildcard5.paths=/api/one/*/jamaica/*\n" + + "quarkus.http.auth.permission.inner-wildcard5.policy=permit\n" + + "quarkus.http.auth.permission.inner-wildcard6.paths=/api/*/sadly/*/dont-know\n" + + "quarkus.http.auth.permission.inner-wildcard6.policy=deny\n" + "quarkus.http.auth.permission.baz.paths=/api/baz\n" + "quarkus.http.auth.permission.baz.policy=authenticated\n" + "quarkus.http.auth.permission.static-resource.paths=/static-file.html\n" + @@ -85,6 +97,25 @@ private WebClient getClient() { return client; } + @Test + public void testInnerWildcardPath() { + assurePath("/api/any-value/bar", 401); + assurePath("/api/any-value/bar", 401); + assurePath("/api/next/any-value/prev", 401); + assurePath("/api/one/two/three/four", 401); + assurePath("/api////any-value//////bar", 401); + assurePath("/api/next///////any-value////prev", 401); + assurePath("////api//one/two//three////four?door=wood", 401); + assurePath("/api/one/three/four/five", 401); + assurePath("/api/one/3/4/five", 401); + assurePath("////api/one///3/4/five", 401); + assurePath("/api/now/sadly/i/dont-know", 401); + assurePath("/api/now/sadly///i/dont-know", 401); + assurePath("/api/one/three/jamaica/five", 200); + assurePath("/api/one/three/jamaica/football", 200); + assurePath("/api/now/sally/i/dont-know", 200); + } + @ParameterizedTest @ValueSource(strings = { // path policy without wildcard diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java index 304688d56f51d..3371e6c365162 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java @@ -23,6 +23,7 @@ import io.quarkus.vertx.http.runtime.PolicyMappingConfig; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.AuthorizationRequestContext; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy.CheckResult; +import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher.PathMatch; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -33,15 +34,15 @@ */ public class AbstractPathMatchingHttpSecurityPolicy { - private final PathMatcher> pathMatcher = new PathMatcher<>(); + private final ImmutablePathMatcher> pathMatcher; AbstractPathMatchingHttpSecurityPolicy(Map permissions, Map rolePolicy, String rootPath, Instance installedPolicies) { - init(permissions, toNamedHttpSecPolicies(rolePolicy, installedPolicies), rootPath); + pathMatcher = init(permissions, toNamedHttpSecPolicies(rolePolicy, installedPolicies), rootPath); } public String getAuthMechanismName(RoutingContext routingContext) { - PathMatcher.PathMatch> toCheck = pathMatcher.match(routingContext.normalizedPath()); + PathMatch> toCheck = pathMatcher.match(routingContext.normalizedPath()); if (toCheck.getValue() == null || toCheck.getValue().isEmpty()) { return null; } @@ -93,9 +94,9 @@ public Uni apply(CheckResult checkResult) { }); } - private void init(Map permissions, + private static ImmutablePathMatcher> init(Map permissions, Map permissionCheckers, String rootPath) { - Map> tempMap = new HashMap<>(); + final var builder = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll); for (Map.Entry entry : permissions.entrySet()) { HttpSecurityPolicy checker = permissionCheckers.get(entry.getValue().policy); if (checker == null) { @@ -108,34 +109,19 @@ private void init(Map permissions, if (!path.startsWith("/")) { path = rootPath + path; } - if (tempMap.containsKey(path)) { - HttpMatcher m = new HttpMatcher(entry.getValue().authMechanism.orElse(null), - new HashSet<>(entry.getValue().methods.orElse(Collections.emptyList())), - checker); - tempMap.get(path).add(m); - } else { - HttpMatcher m = new HttpMatcher(entry.getValue().authMechanism.orElse(null), - new HashSet<>(entry.getValue().methods.orElse(Collections.emptyList())), - checker); - List perms = new ArrayList<>(); - tempMap.put(path, perms); - perms.add(m); - if (path.endsWith("/*")) { - String stripped = path.substring(0, path.length() - 2); - pathMatcher.addPrefixPath(stripped.isEmpty() ? "/" : stripped, perms); - } else if (path.endsWith("*")) { - pathMatcher.addPrefixPath(path.substring(0, path.length() - 1), perms); - } else { - pathMatcher.addExactPath(path, perms); - } - } + HttpMatcher m = new HttpMatcher(entry.getValue().authMechanism.orElse(null), + new HashSet<>(entry.getValue().methods.orElse(Collections.emptyList())), checker); + List perms = new ArrayList<>(); + perms.add(m); + builder.addPath(path, perms); } } } + return builder.build(); } public List findPermissionCheckers(RoutingContext context) { - PathMatcher.PathMatch> toCheck = pathMatcher.match(context.normalizedPath()); + PathMatch> toCheck = pathMatcher.match(context.normalizedPath()); if (toCheck.getValue() == null || toCheck.getValue().isEmpty()) { return Collections.emptyList(); } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java new file mode 100644 index 0000000000000..1778c24d81a94 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java @@ -0,0 +1,346 @@ +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<>(); + private final Map> pathsWithWildcard = new HashMap<>(); + private BiConsumer handlerAccumulator; + + 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 ImmutablePathMatcher build() { + T defaultHandler = null; + SubstringMap paths = new SubstringMap<>(); + 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)) { + 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); + } + int[] lengths = buildLengths(paths.keys()); + return new ImmutablePathMatcher<>(defaultHandler, paths.asImmutableMap(), 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) { + 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); + } + } + + 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 static class PathWithInnerWildcard { + private final String remaining; + private final T handler; + + private PathWithInnerWildcard(String remaining, T handler) { + this.remaining = remaining; + this.handler = handler; + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java new file mode 100644 index 0000000000000..fd0e572b83cfd --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java @@ -0,0 +1,135 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.util.Arrays; + +import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher.PathMatch; + +/** + * A string keyed map that can be accessed as a substring, eliminating the need to allocate a new string + * to do a key comparison against. + */ +public class ImmutableSubstringMap { + + private static final int ALL_BUT_LAST_BIT = ~1; + private final Object[] table; + + ImmutableSubstringMap(Object[] table) { + this.table = Arrays.copyOf(table, table.length); + } + + @SuppressWarnings("unchecked") + public SubstringMatch get(String key, int length) { + if (key.length() < length) { + throw new IllegalArgumentException(); + } + int hash = hash(key, length); + int pos = tablePos(table, hash); + int start = pos; + while (table[pos] != null) { + if (doEquals((String) table[pos], key, length)) { + SubstringMatch match = (SubstringMatch) table[pos + 1]; + if (match == null) { + return null; + } + if (match.hasSubPathMatcher) { + // consider request path '/one/two/three/four/five' + // 'match.key' (which is prefix path) never ends with a slash, e.g. 'match.key=/one/two' + // which means index 'match.key.length()' is index of the last char of the '/one/two/' sub-path + // considering we are looking for a path segment after '/one/two/*', that is the first char + // of the '/four/five' sub-path, the separator index must be greater than 'match.key.length() + 1' + if (key.length() > (match.key.length() + 1)) { + // let say match key is '/one/two' + // then next path segment is '/four' and '/three' is skipped + // for path pattern was like: '/one/two/*/four/five' + int nextPathSegmentIdx = key.indexOf('/', match.key.length() + 1); + if (nextPathSegmentIdx != -1) { + // following the example above, 'nextPath' would be '/four/five' + // and * matched 'three' path segment characters + String nextPath = key.substring(nextPathSegmentIdx); + PathMatch> subMatch = match.subPathMatcher.match(nextPath); + if (subMatch.getValue() != null) { + return subMatch.getValue(); + } + } + } + + if (match.value == null) { + // paths with inner wildcard didn't match + // and there is no prefix path with ending wildcard either + return null; + } + } + // prefix path with ending wildcard: /one/two* + return match; + } + pos += 2; + if (pos >= table.length) { + pos = 0; + } + if (pos == start) { + return null; + } + } + return null; + } + + static int tablePos(Object[] table, int hash) { + return (hash & (table.length - 1)) & ALL_BUT_LAST_BIT; + } + + static boolean doEquals(String s1, String s2, int length) { + if (s1.length() != length || s2.length() < length) { + return false; + } + for (int i = 0; i < length; ++i) { + if (s1.charAt(i) != s2.charAt(i)) { + return false; + } + } + return true; + } + + static int hash(String value, int length) { + if (length == 0) { + return 0; + } + int h = 0; + for (int i = 0; i < length; i++) { + h = 31 * h + value.charAt(i); + } + return h; + } + + public static final class SubstringMatch { + private final String key; + private final V value; + private final boolean hasSubPathMatcher; + private final ImmutablePathMatcher> subPathMatcher; + + SubstringMatch(String key, V value) { + this.key = key; + this.value = value; + this.subPathMatcher = null; + this.hasSubPathMatcher = false; + } + + SubstringMatch(String key, V value, ImmutablePathMatcher> subPathMatcher) { + this.key = key; + this.value = value; + this.subPathMatcher = subPathMatcher; + this.hasSubPathMatcher = subPathMatcher != null; + } + + public String getKey() { + return key; + } + + public V getValue() { + return value; + } + + boolean hasSubPathMatcher() { + return hasSubPathMatcher; + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java index 032a9f91fe118..c069fe2645a0c 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java @@ -7,6 +7,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch; + /** * Handler that dispatches to a given handler based of a prefix match of the path. *

@@ -16,7 +18,10 @@ *

* * @author Stuart Douglas + * + * @deprecated use {@link ImmutablePathMatcher} instead */ +@Deprecated public class PathMatcher { private static final String STRING_PATH_SEPARATOR = "/"; @@ -55,7 +60,7 @@ public PathMatch match(String path) { final int[] lengths = this.lengths; for (int pathLength : lengths) { if (pathLength == length) { - SubstringMap.SubstringMatch next = paths.get(path, length); + SubstringMatch next = paths.get(path, length); if (next != null) { return new PathMatch<>(path, "", next.getValue()); } @@ -64,7 +69,7 @@ public PathMatch match(String path) { if (c == '/') { //String part = path.substring(0, pathLength); - SubstringMap.SubstringMatch next = paths.get(path, pathLength); + SubstringMatch next = paths.get(path, pathLength); if (next != null) { return new PathMatch<>(next.getKey(), path.substring(pathLength), next.getValue()); } @@ -117,7 +122,7 @@ public T getExactPath(final String path) { public T getPrefixPath(final String path) { // enable the prefix path mechanism to return the default handler - SubstringMap.SubstringMatch match = paths.get(path); + SubstringMatch match = paths.get(path); if (PathMatcher.STRING_PATH_SEPARATOR.equals(path) && match == null) { return this.defaultHandler; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java index 8ee402e48e220..75867de490fe0 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java @@ -1,10 +1,16 @@ package io.quarkus.vertx.http.runtime.security; +import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.doEquals; +import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.hash; +import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.tablePos; + import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.NoSuchElementException; +import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch; + /** * A string keyed map that can be accessed as a substring, eliminating the need to allocate a new string * to do a key comparison against. @@ -17,19 +23,27 @@ * @author Stuart Douglas */ public class SubstringMap { - private static final int ALL_BUT_LAST_BIT = ~1; private volatile Object[] table = new Object[16]; private int size; + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public SubstringMatch get(String key, int length) { return get(key, length, false); } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public SubstringMatch get(String key) { return get(key, key.length(), false); } + @SuppressWarnings("unchecked") private SubstringMatch get(String key, int length, boolean exact) { if (key.length() < length) { throw new IllegalArgumentException(); @@ -59,26 +73,19 @@ private SubstringMatch get(String key, int length, boolean exact) { return null; } - private int tablePos(Object[] table, int hash) { - return (hash & (table.length - 1)) & ALL_BUT_LAST_BIT; - } - - private boolean doEquals(String s1, String s2, int length) { - if (s1.length() != length || s2.length() < length) { - return false; - } - for (int i = 0; i < length; ++i) { - if (s1.charAt(i) != s2.charAt(i)) { - return false; - } - } - return true; + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated + public synchronized void put(String key, V value) { + put(key, value, null); } - public synchronized void put(String key, V value) { + void put(String key, V value, ImmutablePathMatcher> subPathMatcher) { if (key == null) { throw new NullPointerException(); } + Object[] newTable; if (table.length / (double) size < 4 && table.length != Integer.MAX_VALUE) { newTable = new Object[table.length << 1]; @@ -91,11 +98,15 @@ public synchronized void put(String key, V value) { newTable = new Object[table.length]; System.arraycopy(table, 0, newTable, 0, table.length); } - doPut(newTable, key, new SubstringMap.SubstringMatch<>(key, value)); + doPut(newTable, key, new SubstringMatch<>(key, value, subPathMatcher)); this.table = newTable; size++; } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public synchronized V remove(String key) { if (key == null) { throw new NullPointerException(); @@ -133,33 +144,30 @@ private void doPut(Object[] newTable, String key, Object value) { newTable[pos + 1] = value; } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public Map toMap() { Map map = new HashMap<>(); Object[] t = this.table; for (int i = 0; i < t.length; i += 2) { if (t[i] != null) { - map.put((String) t[i], ((SubstringMatch) t[i + 1]).value); + map.put((String) t[i], ((SubstringMatch) t[i + 1]).getValue()); } } return map; } + /** + * @deprecated use {@link ImmutablePathMatcher} + */ + @Deprecated public synchronized void clear() { size = 0; table = new Object[16]; } - private static int hash(String value, int length) { - if (length == 0) { - return 0; - } - int h = 0; - for (int i = 0; i < length; i++) { - h = 31 * h + value.charAt(i); - } - return h; - } - public Iterable keys() { return new Iterable() { @Override @@ -206,21 +214,8 @@ public void remove() { } - public static final class SubstringMatch { - private final String key; - private final V value; - - public SubstringMatch(String key, V value) { - this.key = key; - this.value = value; - } - - public String getKey() { - return key; - } - - public V getValue() { - return value; - } + ImmutableSubstringMap asImmutableMap() { + return new ImmutableSubstringMap<>(table); } + } diff --git a/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java new file mode 100644 index 0000000000000..9cc89a3e3bd32 --- /dev/null +++ b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/PathMatcherTest.java @@ -0,0 +1,511 @@ +package io.quarkus.vertx.http.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher; + +public class PathMatcherTest { + + private static final Object HANDLER = new Object(); + + @Test + public void testPrefixPathWithEndingWildcard() { + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two/*", HANDLER).build(); + assertMatched(matcher, "/one/two"); + assertMatched(matcher, "/one/two/"); + assertMatched(matcher, "/one/two/three"); + assertNotMatched(matcher, "/one/twothree"); + assertNotMatched(matcher, "/one/tw"); + assertNotMatched(matcher, "/one"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, ""); + final Object exactPathMatcher1 = new Object(); + final Object exactPathMatcher2 = new Object(); + final Object exactPathMatcher3 = new Object(); + final Object prefixPathMatcher1 = new Object(); + final Object prefixPathMatcher2 = new Object(); + matcher = ImmutablePathMatcher.builder().addPath("/one/two/*", prefixPathMatcher1) + .addPath("/one/two/three", exactPathMatcher1).addPath("/one/two", exactPathMatcher2) + .addPath("/one/two/three*", prefixPathMatcher2).addPath("/one/two/three/four", exactPathMatcher3).build(); + assertMatched(matcher, "/one/two/three", exactPathMatcher1); + assertMatched(matcher, "/one/two", exactPathMatcher2); + assertMatched(matcher, "/one/two/three/four", exactPathMatcher3); + assertMatched(matcher, "/one/two/three/fou", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/four/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/five", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/thre", prefixPathMatcher1); + assertMatched(matcher, "/one/two/", prefixPathMatcher1); + assertNotMatched(matcher, "/one/tw"); + assertNotMatched(matcher, "/one/"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, ""); + } + + @Test + public void testPrefixPathDefaultHandler() { + final Object defaultHandler = new Object(); + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two*", HANDLER) + .addPath("/*", defaultHandler).addPath("/q*", HANDLER).build(); + assertMatched(matcher, "/", defaultHandler); + assertMatched(matcher, "", defaultHandler); + assertMatched(matcher, "0", defaultHandler); + assertMatched(matcher, "/q"); + assertMatched(matcher, "/q/dev-ui"); + assertMatched(matcher, "/qE", defaultHandler); + assertMatched(matcher, "/one/two"); + assertMatched(matcher, "/one/two/three"); + assertMatched(matcher, "/one/twothree", defaultHandler); + final Object exactPathMatcher1 = new Object(); + final Object exactPathMatcher2 = new Object(); + final Object exactPathMatcher3 = new Object(); + final Object prefixPathMatcher1 = new Object(); + final Object prefixPathMatcher2 = new Object(); + matcher = ImmutablePathMatcher.builder().addPath("/one/two/*", prefixPathMatcher1).addPath("/*", defaultHandler) + .addPath("/one/two/three", exactPathMatcher1).addPath("/one/two", exactPathMatcher2) + .addPath("/one/two/three*", prefixPathMatcher2).addPath("/one/two/three/four", exactPathMatcher3).build(); + assertMatched(matcher, "/one/two/three", exactPathMatcher1); + assertMatched(matcher, "/one/two", exactPathMatcher2); + assertMatched(matcher, "/one/two/three/four", exactPathMatcher3); + assertMatched(matcher, "/one/two/three/fou", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/four/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/five", prefixPathMatcher2); + assertMatched(matcher, "/one/two/three/", prefixPathMatcher2); + assertMatched(matcher, "/one/two/thre", prefixPathMatcher1); + assertMatched(matcher, "/one/two/", prefixPathMatcher1); + assertMatched(matcher, "/one/tw", defaultHandler); + assertMatched(matcher, "/one/", defaultHandler); + assertMatched(matcher, "/", defaultHandler); + assertMatched(matcher, "", defaultHandler); + } + + @Test + public void testPrefixPathsNoDefaultHandlerNoExactPath() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two*", handler1) + .addPath("/q*", handler2).build(); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, ""); + assertNotMatched(matcher, "0"); + assertMatched(matcher, "/q", handler2); + assertMatched(matcher, "/q/dev-ui", handler2); + assertNotMatched(matcher, "/qE"); + assertMatched(matcher, "/one/two", handler1); + assertMatched(matcher, "/one/two/three", handler1); + assertMatched(matcher, "/one/two/", handler1); + assertNotMatched(matcher, "/one/twothree"); + } + + @Test + public void testSpecialChars() { + // strictly speaking query params are not part of request path passed to the matcher + // but here they are treated like any other character different from path separator + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + // with default handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two#three", handler2) + .addPath("/one/two?three=four", handler1).addPath("/one/*/three?one\\\\\\=two", handler3) + .addPath("/one/two#three*", handler4).addPath("/*/two#three*", handler5).addPath("/*", HANDLER) + .build(); + assertMatched(matcher, "/one/two#three", handler2); + assertMatched(matcher, "/one/two?three=four", handler1); + assertMatched(matcher, "/one/any-value/three?one\\\\\\=two", handler3); + assertMatched(matcher, "/one/two/three?one\\\\\\=two", handler3); + assertMatched(matcher, "/one/two/three?one\\=two"); + assertMatched(matcher, "/one/two/three?one\\\\\\=two-three"); + assertMatched(matcher, "/one/two/three?one"); + assertMatched(matcher, "/one/two/three?"); + assertMatched(matcher, "/one/two#three?"); + assertMatched(matcher, "/one/two#thre"); + assertMatched(matcher, "/one/two"); + assertMatched(matcher, "/one/two?three=four#"); + assertMatched(matcher, "/one/two?three=fou"); + assertMatched(matcher, "/one/two#three/", handler4); + assertMatched(matcher, "/one/two#three/christmas!", handler4); + assertMatched(matcher, "/one/two#thre"); + assertMatched(matcher, "/one1/two#three", handler5); + assertMatched(matcher, "/one1/two#three/", handler5); + assertMatched(matcher, "/one1/two#three/christmas!", handler5); + assertMatched(matcher, "/one1/two#thre"); + // no default handler + matcher = ImmutablePathMatcher.builder().addPath("/one/two#three", handler2) + .addPath("/one/two?three=four", handler1).addPath("/one/*/three?one\\\\\\=two", handler3) + .addPath("/one/two#three*", handler4).addPath("/*/two#three*", handler5).build(); + assertMatched(matcher, "/one/two#three", handler2); + assertMatched(matcher, "/one/two?three=four", handler1); + assertMatched(matcher, "/one/any-value/three?one\\\\\\=two", handler3); + assertMatched(matcher, "/one/two/three?one\\\\\\=two", handler3); + assertNotMatched(matcher, "/one/two/three?one\\=two"); + assertNotMatched(matcher, "/one/two/three?one\\\\\\=two-three"); + assertNotMatched(matcher, "/one/two/three?one"); + assertNotMatched(matcher, "/one/two/three?"); + assertNotMatched(matcher, "/one/two#three?"); + assertNotMatched(matcher, "/one/two#thre"); + assertNotMatched(matcher, "/one/two"); + assertNotMatched(matcher, "/one/two?three=four#"); + assertNotMatched(matcher, "/one/two?three=fou"); + assertMatched(matcher, "/one/two#three/", handler4); + assertMatched(matcher, "/one/two#three/christmas!", handler4); + assertNotMatched(matcher, "/one/two#thre"); + assertMatched(matcher, "/one1/two#three", handler5); + assertMatched(matcher, "/one1/two#three/", handler5); + assertMatched(matcher, "/one1/two#three/christmas!", handler5); + assertNotMatched(matcher, "/one1/two#thre"); + } + + @Test + public void testInnerWildcardsWithExactMatches() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + final Object handler6 = new Object(); + final Object handler7 = new Object(); + final Object handler8 = new Object(); + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/one/two", handler1) + .addPath("/one/two/three", handler2).addPath("/one/two/three/four", handler3) + .addPath("/", handler4).addPath("/*", HANDLER).addPath("/one/two/*/four", handler5) + .addPath("/one/*/three/four", handler6).addPath("/*/two/three/four", handler7) + .addPath("/*/two", handler8).build(); + assertMatched(matcher, "/one/two", handler1); + assertMatched(matcher, "/one/two/three", handler2); + assertMatched(matcher, "/one/two/three/four", handler3); + assertMatched(matcher, "/", handler4); + assertMatched(matcher, ""); + assertMatched(matcher, "no-one-likes-us"); + assertMatched(matcher, "/one/two/we-do-not-care/four", handler5); + assertMatched(matcher, "/one/two/we-do-not-care/four/4"); + assertMatched(matcher, "/one/we-are-millwall/three/four", handler6); + assertMatched(matcher, "/1-one/we-are-millwall/three/four"); + assertMatched(matcher, "/super-millwall/two/three/four", handler7); + assertMatched(matcher, "/super-millwall/two/three/four/"); + assertMatched(matcher, "/super-millwall/two/three/four/1"); + assertMatched(matcher, "/from-the-den/two", handler8); + assertMatched(matcher, "/from-the-den/two2"); + } + + @Test + public void testInnerWildcardsOnly() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + // with default path handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/two", handler2) + .addPath("/*/*/three", handler1).addPath("/one/*/three", handler3) + .addPath("/one/two/*/four", handler4).addPath("/one/two/three/*/five", handler5) + .addPath("/*", HANDLER).build(); + assertMatched(matcher, "/any-value"); + assertMatched(matcher, "/one/two/three/four/five", handler5); + assertMatched(matcher, "/one/two/three/4/five", handler5); + assertMatched(matcher, "/one/two/three/sergey/five", handler5); + assertMatched(matcher, "/one/two/three/sergey/five-ish"); + assertMatched(matcher, "/one/two/three/sergey/five/"); + assertMatched(matcher, "/one/two/three/four", handler4); + assertMatched(matcher, "/one/two/3/four", handler4); + assertMatched(matcher, "/one/two/three", handler3); + assertMatched(matcher, "/one/2/three", handler3); + assertMatched(matcher, "/one/some-very-long-text/three", handler3); + assertMatched(matcher, "/two"); + assertMatched(matcher, "/two/two", handler2); + assertMatched(matcher, "/2/two", handler2); + assertMatched(matcher, "/ho-hey/two", handler2); + assertMatched(matcher, "/ho-hey/two2"); + assertMatched(matcher, "/ho-hey/two2/"); + assertMatched(matcher, "/ho-hey/two/"); + assertMatched(matcher, "/ho-hey/hey-ho/three", handler1); + assertMatched(matcher, "/1/2/three", handler1); + assertMatched(matcher, "/1/two/three", handler1); + assertMatched(matcher, "/1/two/three/"); + assertMatched(matcher, "/1/two/three/f"); + // no default path handler + matcher = ImmutablePathMatcher.builder().addPath("/*/two", handler2) + .addPath("/*/*/three", handler1).addPath("/one/*/three", handler3) + .addPath("/one/two/*/four", handler4).addPath("/one/two/three/*/five", handler5).build(); + assertNotMatched(matcher, "/any-value"); + assertMatched(matcher, "/one/two/three/four/five", handler5); + assertMatched(matcher, "/one/two/three/4/five", handler5); + assertMatched(matcher, "/one/two/three/sergey/five", handler5); + assertNotMatched(matcher, "/one/two/three/sergey/five-ish"); + assertNotMatched(matcher, "/one/two/three/sergey/five/"); + assertMatched(matcher, "/one/two/three/four", handler4); + assertMatched(matcher, "/one/two/3/four", handler4); + assertMatched(matcher, "/one/two/three", handler3); + assertMatched(matcher, "/one/2/three", handler3); + assertMatched(matcher, "/one/some-very-long-text/three", handler3); + assertNotMatched(matcher, "/two"); + assertMatched(matcher, "/two/two", handler2); + assertMatched(matcher, "/2/two", handler2); + assertMatched(matcher, "/ho-hey/two", handler2); + assertNotMatched(matcher, "/ho-hey/two2"); + assertNotMatched(matcher, "/ho-hey/two2/"); + assertNotMatched(matcher, "/ho-hey/two/"); + assertMatched(matcher, "/ho-hey/hey-ho/three", handler1); + assertMatched(matcher, "/1/2/three", handler1); + assertMatched(matcher, "/1/two/three", handler1); + assertNotMatched(matcher, "/1/two/three/"); + assertNotMatched(matcher, "/1/two/three/f"); + } + + @Test + public void testInnerWildcardWithEndingWildcard() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + final Object handler4 = new Object(); + final Object handler5 = new Object(); + // with default handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/two/*", handler1) + .addPath("/one/*/*", handler2).addPath("/one/two/*/four*", handler3) + .addPath("/one/*/three/*", handler4).addPath("/one/two/*/*", handler5) + .addPath("/*", HANDLER).build(); + assertMatched(matcher, "/one/two/three/four/five/six", handler3); + assertMatched(matcher, "/one/two/three/four/five", handler3); + assertMatched(matcher, "/one/two/three/four/", handler3); + assertMatched(matcher, "/one/two/three/four", handler3); + assertMatched(matcher, "/one/two/3/four", handler3); + assertMatched(matcher, "/one/two/three/4", handler5); + assertMatched(matcher, "/one/two/three/4/", handler5); + assertMatched(matcher, "/one/two/three/4/five", handler5); + assertMatched(matcher, "/one/2/three/four/five", handler4); + assertMatched(matcher, "/one/2/3/four/five", handler2); + assertMatched(matcher, "/1/two/three/four/five", handler1); + assertMatched(matcher, "/1/2/three/four/five"); + } + + @Test + public void testInnerWildcardsDefaultHandler() { + final Object handler1 = new Object(); + final Object handler2 = new Object(); + final Object handler3 = new Object(); + // both default root path handler and sub-path handler + ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/*", handler1) + .addPath("/*/*/three", handler3).addPath("/*", handler2).build(); + assertMatched(matcher, "/one/two/three", handler3); + assertMatched(matcher, "/one/two/four", handler1); + assertMatched(matcher, "/one/two", handler1); + assertMatched(matcher, "/one", handler2); + assertMatched(matcher, "/", handler2); + } + + @Test + public void testInvalidPathPattern() { + // path must start with a path separator + assertThrows(IllegalArgumentException.class, () -> ImmutablePathMatcher.builder().addPath("one", HANDLER).build()); + // inner wildcard must always be only path segment character + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one*/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/*one/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/o*ne/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one/*two/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one/*two/", HANDLER).build()); + assertThrows(ConfigurationException.class, () -> ImmutablePathMatcher.builder().addPath("/one/two*/", HANDLER).build()); + assertThrows(ConfigurationException.class, + () -> ImmutablePathMatcher.builder().addPath("/one/*two*/", HANDLER).build()); + } + + @Test + public void testExactPathHandlerMerging() { + List handler1 = new ArrayList<>(); + handler1.add("Neo"); + List handler2 = new ArrayList<>(); + handler2.add("Trinity"); + List handler3 = new ArrayList<>(); + handler3.add("Morpheus"); + var matcher = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll) + .addPath("/exact-path", handler1).addPath("/exact-path", handler2) + .addPath("/exact-not-matched", handler3).build(); + var handler = matcher.match("/exact-path").getValue(); + assertNotNull(handler); + assertTrue(handler.contains("Neo")); + assertTrue(handler.contains("Trinity")); + assertEquals(2, handler.size()); + handler = matcher.match("/exact-not-matched").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + } + + @Test + public void testPrefixPathHandlerMerging() { + List handler1 = new ArrayList<>(); + handler1.add("Neo"); + List handler2 = new ArrayList<>(); + handler2.add("Trinity"); + List handler3 = new ArrayList<>(); + handler3.add("Morpheus"); + List handler4 = new ArrayList<>(); + handler4.add("AgentSmith"); + List handler5 = new ArrayList<>(); + handler5.add("TheOracle"); + List handler6 = new ArrayList<>(); + handler6.add("AgentBrown"); + var matcher = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll).addPath("/path*", handler1) + .addPath("/path*", handler2).addPath("/path/*", handler3).addPath("/path/", handler4) + .addPath("/path/*/", handler5).addPath("/*", handler6).build(); + var handler = matcher.match("/path").getValue(); + assertNotNull(handler); + assertTrue(handler.contains("Neo")); + assertTrue(handler.contains("Trinity")); + assertTrue(handler.contains("Morpheus")); + assertEquals(3, handler.size()); + handler = matcher.match("/path/").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("AgentSmith")); + handler = matcher.match("/stuart").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("AgentBrown")); + handler = matcher.match("/path/ozzy/").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("TheOracle")); + } + + @Test + public void testInnerWildcardPathHandlerMerging() { + List handler1 = new ArrayList<>(); + handler1.add("Neo"); + List handler2 = new ArrayList<>(); + handler2.add("Trinity"); + List handler3 = new ArrayList<>(); + handler3.add("Morpheus"); + List handler4 = new ArrayList<>(); + handler4.add("AgentSmith"); + List handler5 = new ArrayList<>(); + handler5.add("TheOracle"); + List handler6 = new ArrayList<>(); + handler6.add("AgentBrown"); + List handler7 = new ArrayList<>(); + handler7.add("TheOperator"); + List handler8 = new ArrayList<>(); + handler8.add("TheSpoonBoy"); + List handler9 = new ArrayList<>(); + handler9.add("TheArchitect"); + List handler10 = new ArrayList<>(); + handler10.add("KeyMan"); + List handler11 = new ArrayList<>(); + handler11.add("Revolutions"); + List handler12 = new ArrayList<>(); + handler12.add("Reloaded-1"); + List handler13 = new ArrayList<>(); + handler13.add("Reloaded-2"); + List handler14 = new ArrayList<>(); + handler14.add("Reloaded-3"); + var matcher = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll) + .addPath("/*/one", handler1).addPath("/*/*", handler2).addPath("/*/*", handler3) + .addPath("/*/one", handler4).addPath("/*/two", handler5).addPath("/*", handler6) + .addPath("/one/*/three", handler7).addPath("/one/*", handler8).addPath("/one/*/*", handler9) + .addPath("/one/*/three", handler10).addPath("/one/*/*", handler11) + .addPath("/one/*/*/*", handler12).addPath("/one/*/*/*", handler13) + .addPath("/one/*/*/*", handler14).build(); + var handler = matcher.match("/one/two/three").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("TheOperator")); + assertTrue(handler.contains("KeyMan")); + handler = matcher.match("/one/two/three/four").getValue(); + assertNotNull(handler); + assertEquals(3, handler.size()); + assertTrue(handler.contains("Reloaded-1")); + assertTrue(handler.contains("Reloaded-2")); + assertTrue(handler.contains("Reloaded-3")); + handler = matcher.match("/one/2/3").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("TheArchitect")); + assertTrue(handler.contains("Revolutions")); + handler = matcher.match("/one/two").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("TheSpoonBoy")); + handler = matcher.match("/1/one").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("Neo")); + assertTrue(handler.contains("AgentSmith")); + handler = matcher.match("/1/two").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("TheOracle")); + handler = matcher.match("/father-brown").getValue(); + assertNotNull(handler); + assertEquals(1, handler.size()); + assertTrue(handler.contains("AgentBrown")); + handler = matcher.match("/welcome/to/the/jungle").getValue(); + assertNotNull(handler); + assertEquals(2, handler.size()); + assertTrue(handler.contains("Trinity")); + assertTrue(handler.contains("Morpheus")); + } + + @Test + public void testDefaultHandlerInnerWildcardAndEndingWildcard() { + // calling it default handler inner wildcard because first '/' path is matched and then '/one*' + // '/one*' is matched as prefix path + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/one*", HANDLER).build(); + assertMatched(matcher, "/1/one"); + assertMatched(matcher, "/2/one"); + assertMatched(matcher, "/3/one"); + assertMatched(matcher, "/4/one"); + assertMatched(matcher, "/4/one"); + assertMatched(matcher, "/1/one/"); + assertMatched(matcher, "/1/one/two"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, "/1"); + assertNotMatched(matcher, "/1/"); + assertNotMatched(matcher, "/1/one1"); + assertNotMatched(matcher, "/1/two"); + assertNotMatched(matcher, "/1/on"); + } + + @Test + public void testDefaultHandlerOneInnerWildcard() { + // calling it default handler inner wildcard because first '/' path is matched and then '/one' + // '/one' is matched as exact path + final ImmutablePathMatcher matcher = ImmutablePathMatcher.builder().addPath("/*/one", HANDLER).build(); + assertMatched(matcher, "/1/one"); + assertMatched(matcher, "/2/one"); + assertMatched(matcher, "/3/one"); + assertMatched(matcher, "/4/one"); + assertMatched(matcher, "/4/one"); + assertNotMatched(matcher, "/"); + assertNotMatched(matcher, "/1"); + assertNotMatched(matcher, "/1/"); + assertNotMatched(matcher, "/1/two"); + assertNotMatched(matcher, "/1/one/"); + assertNotMatched(matcher, "/1/one1"); + assertNotMatched(matcher, "/1/on"); + assertNotMatched(matcher, "/1/one/two"); + } + + private static void assertMatched(ImmutablePathMatcher matcher, String path, Object handler) { + var match = matcher.match(path); + assertEquals(handler, match.getValue()); + } + + private static void assertMatched(ImmutablePathMatcher matcher, String path) { + assertMatched(matcher, path, HANDLER); + } + + private static void assertNotMatched(ImmutablePathMatcher matcher, String path) { + var match = matcher.match(path); + assertNull(match.getValue()); + } + +}