Support wildcard path elements at the start of path patterns

Prior to this commit, the `PathPattern` and `PathPatternParser` would
allow multiple-segments matching and capturing with the following:

* "/files/**" (matching 0-N segments until the end)
* "/files/{*path}" (matching 0-N segments until the end and capturing
  the value as the "path" variable)

This would be only allowed as the last path element in the pattern and
the parser would reject other combinations.

This commit expands the support and allows multiple segments matching at
the beginning of the path:

* "/**/index.html" (matching 0-N segments from the start)
* "/{*path}/index.html" (matching 0-N segments until the end and capturing
  the value as the "path" variable)

This does come with additional restrictions:

1. "/files/**/file.txt" and "/files/{*path}/file.txt" are invalid,
   as multiple segment matching is not allowed in the middle of the
   pattern.
2. "/{*path}/files/**" is not allowed, as a single "{*path}" or "/**"
   element is allowed in a pattern
3. "/{*path}/{folder}/file.txt"  "/**/{folder:[a-z]+}/file.txt" are
   invalid because only a literal pattern is allowed right after
   multiple segments path elements.

Closes gh-35679
This commit is contained in:
Brian Clozel
2025-07-25 12:34:40 +02:00
parent 82c34f7b51
commit d3c1e678c2
10 changed files with 864 additions and 846 deletions
@@ -25,24 +25,31 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.util.pattern.PathPattern.MatchingContext;
/**
* A path element representing capturing the rest of a path. In the pattern
* '/foo/{*foobar}' the /{*foobar} is represented as a {@link CaptureTheRestPathElement}.
* A path element that captures multiple path segments.
* This element is only allowed in two situations:
* <ol>
* <li>At the start of a path, immediately followed by a {@link LiteralPathElement} like '/{*foobar}/foo/{bar}'
* <li>At the end of a path, like '/foo/{*foobar}'
* </ol>
* <p>Only a single {@link WildcardSegmentsPathElement} or {@link CaptureSegmentsPathElement} element is allowed
* * in a pattern. In the pattern '/foo/{*foobar}' the /{*foobar} is represented as a {@link CaptureSegmentsPathElement}.
*
* @author Andy Clement
* @author Brian Clozel
* @since 5.0
*/
class CaptureTheRestPathElement extends PathElement {
class CaptureSegmentsPathElement extends PathElement {
private final String variableName;
/**
* Create a new {@link CaptureTheRestPathElement} instance.
* Create a new {@link CaptureSegmentsPathElement} instance.
* @param pos position of the path element within the path pattern text
* @param captureDescriptor a character array containing contents like '{' '*' 'a' 'b' '}'
* @param separator the separator used in the path pattern
*/
CaptureTheRestPathElement(int pos, char[] captureDescriptor, char separator) {
CaptureSegmentsPathElement(int pos, char[] captureDescriptor, char separator) {
super(pos, separator);
this.variableName = new String(captureDescriptor, 2, captureDescriptor.length - 3);
}
@@ -50,41 +57,53 @@ class CaptureTheRestPathElement extends PathElement {
@Override
public boolean matches(int pathIndex, MatchingContext matchingContext) {
// No need to handle 'match start' checking as this captures everything
// anyway and cannot be followed by anything else
// assert next == null
// If there is more data, it must start with the separator
if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) {
// wildcard segments at the start of the pattern
if (pathIndex == 0 && this.next != null) {
int endPathIndex = pathIndex;
while (endPathIndex < matchingContext.pathLength) {
if (this.next.matches(endPathIndex, matchingContext)) {
collectParameters(matchingContext, pathIndex, endPathIndex);
return true;
}
endPathIndex++;
}
return false;
}
// match until the end of the path
else if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) {
return false;
}
if (matchingContext.determineRemainingPath) {
matchingContext.remainingPathIndex = matchingContext.pathLength;
}
collectParameters(matchingContext, pathIndex, matchingContext.pathLength);
return true;
}
private void collectParameters(MatchingContext matchingContext, int pathIndex, int endPathIndex) {
if (matchingContext.extractingVariables) {
// Collect the parameters from all the remaining segments
MultiValueMap<String,String> parametersCollector = null;
for (int i = pathIndex; i < matchingContext.pathLength; i++) {
MultiValueMap<String, String> parametersCollector = NO_PARAMETERS;
for (int i = pathIndex; i < endPathIndex; i++) {
Element element = matchingContext.pathElements.get(i);
if (element instanceof PathSegment pathSegment) {
MultiValueMap<String, String> parameters = pathSegment.parameters();
if (!parameters.isEmpty()) {
if (parametersCollector == null) {
if (parametersCollector == NO_PARAMETERS) {
parametersCollector = new LinkedMultiValueMap<>();
}
parametersCollector.addAll(parameters);
}
}
}
matchingContext.set(this.variableName, pathToString(pathIndex, matchingContext.pathElements),
parametersCollector == null?NO_PARAMETERS:parametersCollector);
matchingContext.set(this.variableName, pathToString(pathIndex, endPathIndex, matchingContext.pathElements),
parametersCollector);
}
return true;
}
private String pathToString(int fromSegment, List<Element> pathElements) {
private String pathToString(int fromSegment, int toSegment, List<Element> pathElements) {
StringBuilder sb = new StringBuilder();
for (int i = fromSegment, max = pathElements.size(); i < max; i++) {
for (int i = fromSegment, max = toSegment; i < max; i++) {
Element element = pathElements.get(i);
if (element instanceof PathSegment pathSegment) {
sb.append(pathSegment.valueToMatch());
@@ -119,7 +138,7 @@ class CaptureTheRestPathElement extends PathElement {
@Override
public String toString() {
return "CaptureTheRest(/{*" + this.variableName + "})";
return "CaptureSegments(/{*" + this.variableName + "})";
}
}
@@ -29,6 +29,7 @@ import org.springframework.web.util.pattern.PatternParseException.PatternMessage
* {@link PathElement PathElements} in a linked list. Instances are reusable but are not thread-safe.
*
* @author Andy Clement
* @author Brian Clozel
* @since 5.0
*/
class InternalPathPatternParser {
@@ -51,7 +52,7 @@ class InternalPathPatternParser {
private boolean wildcard = false;
// Is the construct {*...} being used in a particular path element
private boolean isCaptureTheRestVariable = false;
private boolean isCaptureSegmentsVariable = false;
// Has the parser entered a {...} variable capture block in a particular
// path element
@@ -66,6 +67,9 @@ class InternalPathPatternParser {
// Start of the most recent variable capture in a particular path element
private int variableCaptureStart;
// Did we parse a WildcardSegments(**) or CaptureSegments({*foo}) PathElement already?
private boolean hasMultipleSegmentsElement = false;
// Variables captures in this path pattern
@Nullable
private List<String> capturedVariableNames;
@@ -110,13 +114,7 @@ class InternalPathPatternParser {
if (this.pathElementStart != -1) {
pushPathElement(createPathElement());
}
if (peekDoubleWildcard()) {
pushPathElement(new WildcardTheRestPathElement(this.pos, separator));
this.pos += 2;
}
else {
pushPathElement(new SeparatorPathElement(this.pos, separator));
}
pushPathElement(new SeparatorPathElement(this.pos, separator));
}
else {
if (this.pathElementStart == -1) {
@@ -144,35 +142,37 @@ class InternalPathPatternParser {
PatternMessage.MISSING_OPEN_CAPTURE);
}
this.insideVariableCapture = false;
if (this.isCaptureTheRestVariable && (this.pos + 1) < this.pathPatternLength) {
throw new PatternParseException(this.pos + 1, this.pathPatternData,
PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST);
}
this.variableCaptureCount++;
}
else if (ch == ':') {
if (this.insideVariableCapture && !this.isCaptureTheRestVariable) {
if (this.insideVariableCapture && !this.isCaptureSegmentsVariable) {
skipCaptureRegex();
this.insideVariableCapture = false;
this.variableCaptureCount++;
}
}
else if (isDoubleWildcard(separator)) {
checkValidMultipleSegmentsElements(this.pos, this.pos + 1);
pushPathElement(new WildcardSegmentsPathElement(this.pos, separator));
this.hasMultipleSegmentsElement = true;
this.pos++;
}
else if (ch == '*') {
if (this.insideVariableCapture && this.variableCaptureStart == this.pos - 1) {
this.isCaptureTheRestVariable = true;
this.isCaptureSegmentsVariable = true;
}
this.wildcard = true;
}
// Check that the characters used for captured variable names are like java identifiers
if (this.insideVariableCapture) {
if ((this.variableCaptureStart + 1 + (this.isCaptureTheRestVariable ? 1 : 0)) == this.pos &&
if ((this.variableCaptureStart + 1 + (this.isCaptureSegmentsVariable ? 1 : 0)) == this.pos &&
!Character.isJavaIdentifierStart(ch)) {
throw new PatternParseException(this.pos, this.pathPatternData,
PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR,
Character.toString(ch));
}
else if ((this.pos > (this.variableCaptureStart + 1 + (this.isCaptureTheRestVariable ? 1 : 0)) &&
else if ((this.pos > (this.variableCaptureStart + 1 + (this.isCaptureSegmentsVariable ? 1 : 0)) &&
!Character.isJavaIdentifierPart(ch) && ch != '-')) {
throw new PatternParseException(this.pos, this.pathPatternData,
PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR,
@@ -185,6 +185,7 @@ class InternalPathPatternParser {
if (this.pathElementStart != -1) {
pushPathElement(createPathElement());
}
verifyPatternElements(this.headPE);
return new PathPattern(pathPattern, this.parser, this.headPE);
}
@@ -234,23 +235,28 @@ class InternalPathPatternParser {
PatternMessage.MISSING_CLOSE_CAPTURE);
}
/**
* After processing a separator, a quick peek whether it is followed by
* a double wildcard (and only as the last path element).
*/
private boolean peekDoubleWildcard() {
if ((this.pos + 2) >= this.pathPatternLength) {
private boolean isDoubleWildcard(char separator) {
if ((this.pos + 1) >= this.pathPatternLength) {
return false;
}
if (this.pathPatternData[this.pos + 1] != '*' || this.pathPatternData[this.pos + 2] != '*') {
if (this.pathPatternData[this.pos] != '*' || this.pathPatternData[this.pos + 1] != '*') {
return false;
}
char separator = this.parser.getPathOptions().separator();
if ((this.pos + 3) < this.pathPatternLength && this.pathPatternData[this.pos + 3] == separator) {
if ((this.pos + 2) < this.pathPatternLength) {
return this.pathPatternData[this.pos + 2] == separator;
}
return true;
}
private void checkValidMultipleSegmentsElements(int startPosition, int endPosition) {
if (this.hasMultipleSegmentsElement) {
throw new PatternParseException(this.pos, this.pathPatternData,
PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST);
PatternMessage.CANNOT_HAVE_MANY_MULTISEGMENT_PATHELEMENTS);
}
if (startPosition > 1 && endPosition != this.pathPatternLength - 1) {
throw new PatternParseException(this.pos, this.pathPatternData,
PatternMessage.INVALID_LOCATION_FOR_MULTISEGMENT_PATHELEMENT);
}
return (this.pos + 3 == this.pathPatternLength);
}
/**
@@ -258,7 +264,8 @@ class InternalPathPatternParser {
* @param newPathElement the new path element to add
*/
private void pushPathElement(PathElement newPathElement) {
if (newPathElement instanceof CaptureTheRestPathElement) {
if (newPathElement instanceof CaptureSegmentsPathElement ||
newPathElement instanceof WildcardSegmentsPathElement) {
// There must be a separator ahead of this thing
// currentPE SHOULD be a SeparatorPathElement
if (this.currentPE == null) {
@@ -279,7 +286,8 @@ class InternalPathPatternParser {
this.currentPE = newPathElement;
}
else {
throw new IllegalStateException("Expected SeparatorPathElement but was " + this.currentPE);
throw new IllegalStateException("Expected SeparatorPathElement before " +
newPathElement.getClass().getName() +" but was " + this.currentPE);
}
}
else {
@@ -320,9 +328,11 @@ class InternalPathPatternParser {
if (this.variableCaptureCount > 0) {
if (this.variableCaptureCount == 1 && this.pathElementStart == this.variableCaptureStart &&
this.pathPatternData[this.pos - 1] == '}') {
if (this.isCaptureTheRestVariable) {
if (this.isCaptureSegmentsVariable) {
// It is {*....}
newPE = new CaptureTheRestPathElement(
checkValidMultipleSegmentsElements(this.pathElementStart, this.pos -1);
this.hasMultipleSegmentsElement = true;
newPE = new CaptureSegmentsPathElement(
this.pathElementStart, getPathElementText(), separator);
}
else {
@@ -341,7 +351,7 @@ class InternalPathPatternParser {
}
}
else {
if (this.isCaptureTheRestVariable) {
if (this.isCaptureSegmentsVariable) {
throw new PatternParseException(this.pathElementStart, this.pathPatternData,
PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT);
}
@@ -405,7 +415,7 @@ class InternalPathPatternParser {
this.insideVariableCapture = false;
this.variableCaptureCount = 0;
this.wildcard = false;
this.isCaptureTheRestVariable = false;
this.isCaptureSegmentsVariable = false;
this.variableCaptureStart = -1;
}
@@ -423,4 +433,22 @@ class InternalPathPatternParser {
this.capturedVariableNames.add(variableName);
}
private void verifyPatternElements(@Nullable PathElement headPE) {
PathElement currentElement = headPE;
while (currentElement != null) {
if (currentElement instanceof CaptureSegmentsPathElement ||
currentElement instanceof WildcardSegmentsPathElement) {
PathElement nextElement = currentElement.next;
while (nextElement instanceof SeparatorPathElement) {
nextElement = nextElement.next;
}
if (nextElement != null && !(nextElement instanceof LiteralPathElement)) {
throw new PatternParseException(nextElement.pos, this.pathPatternData,
PatternMessage.MULTISEGMENT_PATHELEMENT_NOT_FOLLOWED_BY_LITERAL);
}
}
currentElement = currentElement.next;
}
}
}
@@ -109,7 +109,7 @@ abstract class PathElement {
}
/**
* Return if the there are no more PathElements in the pattern.
* Return if there are no more PathElements in the pattern.
* @return {@code true} if the there are no more elements
*/
protected final boolean isNoMorePattern() {
@@ -167,7 +167,7 @@ public class PathPattern implements Comparable<PathPattern> {
this.capturedVariableCount += elem.getCaptureCount();
this.normalizedLength += elem.getNormalizedLength();
this.score += elem.getScore();
if (elem instanceof CaptureTheRestPathElement || elem instanceof WildcardTheRestPathElement) {
if (elem instanceof CaptureSegmentsPathElement || elem instanceof WildcardSegmentsPathElement) {
this.catchAll = true;
}
if (elem instanceof SeparatorPathElement && elem.next instanceof WildcardPathElement && elem.next.next == null) {
@@ -206,7 +206,7 @@ public class PathPattern implements Comparable<PathPattern> {
(this.matchOptionalTrailingSeparator && pathContainerIsJustSeparator(pathContainer));
}
else if (!hasLength(pathContainer)) {
if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) {
if (this.head instanceof WildcardSegmentsPathElement || this.head instanceof CaptureSegmentsPathElement) {
pathContainer = EMPTY_PATH; // Will allow CaptureTheRest to bind the variable to empty
}
else {
@@ -231,7 +231,7 @@ public class PathPattern implements Comparable<PathPattern> {
null : PathMatchInfo.EMPTY);
}
else if (!hasLength(pathContainer)) {
if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) {
if (this.head instanceof WildcardSegmentsPathElement || this.head instanceof CaptureSegmentsPathElement) {
pathContainer = EMPTY_PATH; // Will allow CaptureTheRest to bind the variable to empty
}
else {
@@ -22,6 +22,7 @@ import java.text.MessageFormat;
* Exception that is thrown when there is a problem with the pattern being parsed.
*
* @author Andy Clement
* @author Brian Clozel
* @since 5.0
*/
@SuppressWarnings("serial")
@@ -98,12 +99,14 @@ public class PatternParseException extends IllegalArgumentException {
CANNOT_HAVE_ADJACENT_CAPTURES("Adjacent captures are not allowed"),
ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR("Char ''{0}'' not allowed at start of captured variable name"),
ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR("Char ''{0}'' is not allowed in a captured variable name"),
NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST("No more pattern data allowed after '{*...}' or '**' pattern element"),
CANNOT_HAVE_MANY_MULTISEGMENT_PATHELEMENTS("Multiple '{*...}' or '**' pattern elements are not allowed"),
INVALID_LOCATION_FOR_MULTISEGMENT_PATHELEMENT("'{*...}' or '**' pattern elements should be placed at the start or end of the pattern"),
MULTISEGMENT_PATHELEMENT_NOT_FOLLOWED_BY_LITERAL("'{*...}' or '**' pattern elements should be followed by a literal path element"),
BADLY_FORMED_CAPTURE_THE_REST("Expected form when capturing the rest of the path is simply '{*...}'"),
MISSING_REGEX_CONSTRAINT("Missing regex constraint on capture"),
ILLEGAL_DOUBLE_CAPTURE("Not allowed to capture ''{0}'' twice in the same pattern"),
REGEX_PATTERN_SYNTAX_EXCEPTION("Exception occurred in regex pattern compilation"),
CAPTURE_ALL_IS_STANDALONE_CONSTRUCT("'{*...}' can only be preceded by a path separator");
CAPTURE_ALL_IS_STANDALONE_CONSTRUCT("'{*...}' cannot be mixed with other path elements in the same path segment");
private final String message;
@@ -17,23 +17,41 @@
package org.springframework.web.util.pattern;
/**
* A path element representing wildcarding the rest of a path. In the pattern
* '/foo/**' the /** is represented as a {@link WildcardTheRestPathElement}.
* A path element representing wildcarding multiple segments in a path.
* This element is only allowed in two situations:
* <ol>
* <li>At the start of a path, immediately followed by a {@link LiteralPathElement} like '&#47;**&#47;foo&#47;{bar}'
* <li>At the end of a path, like '&#47;foo&#47;**'
* </ol>
* <p>Only a single {@link WildcardSegmentsPathElement} or {@link CaptureSegmentsPathElement} element is allowed
* in a pattern. In the pattern '&#47;foo&#47;**' the '&#47;**' is represented as a {@link WildcardSegmentsPathElement}.
*
* @author Andy Clement
* @author Brian Clozel
* @since 5.0
*/
class WildcardTheRestPathElement extends PathElement {
class WildcardSegmentsPathElement extends PathElement {
WildcardTheRestPathElement(int pos, char separator) {
WildcardSegmentsPathElement(int pos, char separator) {
super(pos, separator);
}
@Override
public boolean matches(int pathIndex, PathPattern.MatchingContext matchingContext) {
// If there is more data, it must start with the separator
if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) {
// wildcard segments at the start of the pattern
if (pathIndex == 0 && this.next != null) {
int endPathIndex = pathIndex;
while (endPathIndex < matchingContext.pathLength) {
if (this.next.matches(endPathIndex, matchingContext)) {
return true;
}
endPathIndex++;
}
return false;
}
// match until the end of the path
else if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) {
return false;
}
if (matchingContext.determineRemainingPath) {
@@ -60,7 +78,7 @@ class WildcardTheRestPathElement extends PathElement {
@Override
public String toString() {
return "WildcardTheRest(" + this.separator + "**)";
return "WildcardSegments(" + this.separator + "**)";
}
}