Skip to content

Commit 4a46d95

Browse files
committed
Fix out of bounds exception for PathPattern#combine
Prior to this commit, combining the "/*" and "/x/y" path patterns would result in a `StringIndexOutOfBoundsException`. This commit fixes this problem and revisits the implementation for better consistency: * "/*" + "/x/y" is now "/x/y" * "/x/*.html" + "/y/file.*" is now rejected because they don't share the same prefix. This change also adds the relevant Javadoc to the `PathPattern#combine` method. Fixes gh-34986
1 parent 4df93a8 commit 4a46d95

File tree

2 files changed

+133
-49
lines changed

2 files changed

+133
-49
lines changed

spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -374,19 +374,49 @@ public int compareTo(@Nullable PathPattern otherPattern) {
374374
}
375375

376376
/**
377-
* Combine this pattern with another.
377+
* Combine this pattern with the one given as parameter, returning a new
378+
* {@code PathPattern} instance that concatenates or merges both.
379+
* This operation is not commutative, meaning {@code pattern1.combine(pattern2)}
380+
* is not equal to {@code pattern2.combine(pattern1)}.
381+
*
382+
* <p>Combining two "fixed" patterns effectively concatenates them:
383+
* <ul>
384+
* <li><code> "/projects" + "/spring-framework" => "/projects/spring-framework"</code>
385+
* </ul>
386+
* Combining a "fixed" pattern with a "matching" pattern concatenates them:
387+
* <ul>
388+
* <li><code> "/projects" + "/{project}" => "/projects/{project}"</code>
389+
* </ul>
390+
* Combining a "matching" pattern with a "fixed" pattern merges them:
391+
* <ul>
392+
* <li><code> "/projects/&#42;" + "/spring-framework" => "/projects/spring-framework"</code>
393+
* <li><code> "/projects/&#42;.html" + "/spring-framework.html" => "/projects/spring-framework.html"</code>
394+
* </ul>
395+
* Combining two "matching" patterns merges them:
396+
* <ul>
397+
* <li><code> "/projects/&#42;&#42;" + "/&#42;.html" => "/projects/&#42;.html"</code>
398+
* <li><code> "/projects/&#42;" + "/{project}" => "/projects/{project}"</code>
399+
* <li><code> "/projects/&#42;.html" + "/spring-framework.&#42;" => "/projects/spring-framework.html"</code>
400+
* </ul>
401+
* Note, if a pattern does not end with a "matching" segment, it is considered as a "fixed" one:
402+
* <ul>
403+
* <li><code> "/projects/&#42;/releases" + "/{id}" => "/projects/&#42;/releases/{id}"</code>
404+
* </ul>
405+
* @param otherPattern the pattern to be combined with the current one
406+
* @return the new {@code PathPattern} that combines both patterns
407+
* @throws IllegalArgumentException if the combination is not allowed
378408
*/
379-
public PathPattern combine(PathPattern pattern2string) {
380-
// If one of them is empty the result is the other. If both empty the result is ""
409+
public PathPattern combine(PathPattern otherPattern) {
410+
// If one of them is empty, the result is the other. If both are empty, the result is ""
381411
if (!StringUtils.hasLength(this.patternString)) {
382-
if (!StringUtils.hasLength(pattern2string.patternString)) {
412+
if (!StringUtils.hasLength(otherPattern.patternString)) {
383413
return this.parser.parse("");
384414
}
385415
else {
386-
return pattern2string;
416+
return otherPattern;
387417
}
388418
}
389-
else if (!StringUtils.hasLength(pattern2string.patternString)) {
419+
else if (!StringUtils.hasLength(otherPattern.patternString)) {
390420
return this;
391421
}
392422

@@ -395,40 +425,55 @@ else if (!StringUtils.hasLength(pattern2string.patternString)) {
395425
// However:
396426
// /usr + /user => /usr/user
397427
// /{foo} + /bar => /{foo}/bar
398-
if (!this.patternString.equals(pattern2string.patternString) && this.capturedVariableCount == 0 &&
399-
matches(PathContainer.parsePath(pattern2string.patternString))) {
400-
return pattern2string;
428+
if (!this.patternString.equals(otherPattern.patternString) && this.capturedVariableCount == 0 &&
429+
matches(PathContainer.parsePath(otherPattern.patternString))) {
430+
return otherPattern;
401431
}
402432

403433
// /hotels/* + /booking => /hotels/booking
404434
// /hotels/* + booking => /hotels/booking
405435
if (this.endsWithSeparatorWildcard) {
406-
return this.parser.parse(concat(
407-
this.patternString.substring(0, this.patternString.length() - 2),
408-
pattern2string.patternString));
436+
String prefix = this.patternString.length() > 2 ?
437+
this.patternString.substring(0, this.patternString.length() - 2) :
438+
String.valueOf(this.getSeparator());
439+
return this.parser.parse(concat(prefix, otherPattern.patternString));
440+
}
441+
442+
// /hotels/** + "/booking/rooms => /hotels/booking/rooms
443+
if (this.catchAll) {
444+
return this.parser.parse(concat(this.patternString.substring(0, this.patternString.length() - 3),
445+
otherPattern.patternString));
409446
}
410447

411448
// /hotels + /booking => /hotels/booking
412449
// /hotels + booking => /hotels/booking
413-
int starDotPos1 = this.patternString.indexOf("*."); // Are there any file prefix/suffix things to consider?
414-
if (this.capturedVariableCount != 0 || starDotPos1 == -1 || getSeparator() == '.') {
415-
return this.parser.parse(concat(this.patternString, pattern2string.patternString));
450+
int firstStarDotPos = this.patternString.indexOf("*."); // Are there any file prefix/suffix things to consider?
451+
if (this.capturedVariableCount != 0 || firstStarDotPos == -1 || getSeparator() == '.') {
452+
return this.parser.parse(concat(this.patternString, otherPattern.patternString));
416453
}
417454

418455
// /*.html + /hotel => /hotel.html
419456
// /*.html + /hotel.* => /hotel.html
420-
String firstExtension = this.patternString.substring(starDotPos1 + 1); // looking for the first extension
421-
String p2string = pattern2string.patternString;
422-
int dotPos2 = p2string.indexOf('.');
423-
String file2 = (dotPos2 == -1 ? p2string : p2string.substring(0, dotPos2));
424-
String secondExtension = (dotPos2 == -1 ? "" : p2string.substring(dotPos2));
425-
boolean firstExtensionWild = (firstExtension.equals(".*") || firstExtension.isEmpty());
426-
boolean secondExtensionWild = (secondExtension.equals(".*") || secondExtension.isEmpty());
427-
if (!firstExtensionWild && !secondExtensionWild) {
457+
int secondDotPos = otherPattern.patternString.indexOf('.');
458+
459+
String firstExtension = this.patternString.substring(firstStarDotPos + 1); // looking for the first extension
460+
String secondExtension = (secondDotPos == -1 ? "" : otherPattern.patternString.substring(secondDotPos));
461+
boolean isFirstExtensionWildcard = (firstExtension.equals(".*") || firstExtension.isEmpty());
462+
boolean isSecondExtensionWildcard = (secondExtension.equals(".*") || secondExtension.isEmpty());
463+
if (!isFirstExtensionWildcard && !isSecondExtensionWildcard) {
428464
throw new IllegalArgumentException(
429-
"Cannot combine patterns: " + this.patternString + " and " + pattern2string);
465+
"Cannot combine patterns: " + this.patternString + " and " + otherPattern);
430466
}
431-
return this.parser.parse(file2 + (firstExtensionWild ? secondExtension : firstExtension));
467+
468+
String firstPath = this.patternString.substring(0, this.patternString.lastIndexOf(this.getSeparator()));
469+
String secondPath = otherPattern.patternString.substring(0, otherPattern.patternString.lastIndexOf(this.getSeparator()));
470+
if (!this.parser.parse(firstPath).matches(PathContainer.parsePath(secondPath))) {
471+
throw new IllegalArgumentException(
472+
"Cannot combine patterns: " + this.patternString + " and " + otherPattern);
473+
}
474+
475+
String secondFile = (secondDotPos == -1 ? otherPattern.patternString : otherPattern.patternString.substring(0, secondDotPos));
476+
return this.parser.parse(secondFile + (isFirstExtensionWildcard ? secondExtension : firstExtension));
432477
}
433478

434479
@Override

spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternTests.java

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -905,34 +905,25 @@ void extractUriTemplateVarsRegexCapturingGroups() {
905905
}
906906

907907
@Test
908-
void combine() {
908+
void combineEmptyPatternsShouldReturnEmpty() {
909909
TestPathCombiner pathMatcher = new TestPathCombiner();
910910
assertThat(pathMatcher.combine("", "")).isEmpty();
911+
}
912+
913+
@Test
914+
void combineWithEmptyPatternShouldReturnPattern() {
915+
TestPathCombiner pathMatcher = new TestPathCombiner();
911916
assertThat(pathMatcher.combine("/hotels", "")).isEqualTo("/hotels");
912917
assertThat(pathMatcher.combine("", "/hotels")).isEqualTo("/hotels");
913-
assertThat(pathMatcher.combine("/hotels/*", "booking")).isEqualTo("/hotels/booking");
914-
assertThat(pathMatcher.combine("/hotels/*", "/booking")).isEqualTo("/hotels/booking");
918+
}
919+
920+
@Test
921+
void combineStaticPatternsShouldConcatenate() {
922+
TestPathCombiner pathMatcher = new TestPathCombiner();
915923
assertThat(pathMatcher.combine("/hotels", "/booking")).isEqualTo("/hotels/booking");
916924
assertThat(pathMatcher.combine("/hotels", "booking")).isEqualTo("/hotels/booking");
917925
assertThat(pathMatcher.combine("/hotels/", "booking")).isEqualTo("/hotels/booking");
918-
assertThat(pathMatcher.combine("/hotels/*", "{hotel}")).isEqualTo("/hotels/{hotel}");
919-
assertThat(pathMatcher.combine("/hotels", "{hotel}")).isEqualTo("/hotels/{hotel}");
920-
assertThat(pathMatcher.combine("/hotels", "{hotel}.*")).isEqualTo("/hotels/{hotel}.*");
921-
assertThat(pathMatcher.combine("/hotels/*/booking", "{booking}")).isEqualTo("/hotels/*/booking/{booking}");
922-
assertThat(pathMatcher.combine("/*.html", "/hotel.html")).isEqualTo("/hotel.html");
923-
assertThat(pathMatcher.combine("/*.html", "/hotel")).isEqualTo("/hotel.html");
924-
assertThat(pathMatcher.combine("/*.html", "/hotel.*")).isEqualTo("/hotel.html");
925-
// TODO this seems rather bogus, should we eagerly show an error?
926-
assertThat(pathMatcher.combine("/a/b/c/*.html", "/d/e/f/hotel.*")).isEqualTo("/d/e/f/hotel.html");
927-
assertThat(pathMatcher.combine("/**", "/*.html")).isEqualTo("/*.html");
928-
assertThat(pathMatcher.combine("/*", "/*.html")).isEqualTo("/*.html");
929-
assertThat(pathMatcher.combine("/*.*", "/*.html")).isEqualTo("/*.html");
930-
// SPR-8858
931-
assertThat(pathMatcher.combine("/{foo}", "/bar")).isEqualTo("/{foo}/bar");
932-
// SPR-7970
933-
assertThat(pathMatcher.combine("/user", "/user")).isEqualTo("/user/user");
934926
// SPR-10062
935-
assertThat(pathMatcher.combine("/{foo:.*[^0-9].*}", "/edit/")).isEqualTo("/{foo:.*[^0-9].*}/edit/");
936927
assertThat(pathMatcher.combine("/1.0", "/foo/test")).isEqualTo("/1.0/foo/test");
937928
// SPR-10554
938929
// SPR-12975
@@ -941,14 +932,56 @@ void combine() {
941932
assertThat(pathMatcher.combine("/hotel/", "/booking")).isEqualTo("/hotel/booking");
942933
assertThat(pathMatcher.combine("", "/hotel")).isEqualTo("/hotel");
943934
assertThat(pathMatcher.combine("/hotel", "")).isEqualTo("/hotel");
944-
// TODO Do we need special handling when patterns contain multiple dots?
935+
// SPR-7970
936+
assertThat(pathMatcher.combine("/user", "/user")).isEqualTo("/user/user");
945937
}
946938

947939
@Test
948-
void combineWithTwoFileExtensionPatterns() {
940+
void combineStaticWithMatchingShouldConcatenate() {
949941
TestPathCombiner pathMatcher = new TestPathCombiner();
950-
assertThatIllegalArgumentException().isThrownBy(() ->
951-
pathMatcher.combine("/*.html", "/*.txt"));
942+
assertThat(pathMatcher.combine("/hotels", "*")).isEqualTo("/hotels/*");
943+
assertThat(pathMatcher.combine("/hotels", "{hotel}")).isEqualTo("/hotels/{hotel}");
944+
assertThat(pathMatcher.combine("/hotels", "{hotel}.*")).isEqualTo("/hotels/{hotel}.*");
945+
}
946+
947+
@Test
948+
void combineMatchingWithStaticShouldMergeWhenWildcardMatch() {
949+
TestPathCombiner pathMatcher = new TestPathCombiner();
950+
assertThat(pathMatcher.combine("/hotels/*", "booking")).isEqualTo("/hotels/booking");
951+
assertThat(pathMatcher.combine("/hotels/*", "/booking")).isEqualTo("/hotels/booking");
952+
assertThat(pathMatcher.combine("/hotels/**", "/booking/rooms")).isEqualTo("/hotels/booking/rooms");
953+
assertThat(pathMatcher.combine("/*.html", "/hotel.html")).isEqualTo("/hotel.html");
954+
assertThat(pathMatcher.combine("/*.html", "/hotel")).isEqualTo("/hotel.html");
955+
// gh-34986
956+
assertThat(pathMatcher.combine("/*", "/foo/bar")).isEqualTo("/foo/bar");
957+
assertThat(pathMatcher.combine("/*", "foo/bar")).isEqualTo("/foo/bar");
958+
}
959+
960+
@Test
961+
void combineMatchingWithStaticShouldConcatenateWhenNoWildcardMatch() {
962+
TestPathCombiner pathMatcher = new TestPathCombiner();
963+
// SPR-10062
964+
assertThat(pathMatcher.combine("/{foo:.*[^0-9].*}", "/edit/")).isEqualTo("/{foo:.*[^0-9].*}/edit/");
965+
// SPR-8858
966+
assertThat(pathMatcher.combine("/{foo}", "/bar")).isEqualTo("/{foo}/bar");
967+
}
968+
969+
@Test
970+
void combineMatchingPatternsShouldMergeWhenMatch() {
971+
TestPathCombiner pathMatcher = new TestPathCombiner();
972+
assertThat(pathMatcher.combine("/hotels/*/booking", "{booking}")).isEqualTo("/hotels/*/booking/{booking}");
973+
assertThat(pathMatcher.combine("/hotels/*", "{hotel}")).isEqualTo("/hotels/{hotel}");
974+
assertThat(pathMatcher.combine("/*.html", "/hotel.*")).isEqualTo("/hotel.html");
975+
assertThat(pathMatcher.combine("/**", "/*.html")).isEqualTo("/*.html");
976+
assertThat(pathMatcher.combine("/*", "/*.html")).isEqualTo("/*.html");
977+
assertThat(pathMatcher.combine("/*.*", "/*.html")).isEqualTo("/*.html");
978+
}
979+
980+
@Test
981+
void combineMatchingPatternsShouldFailWhenNoMatch() {
982+
TestPathCombiner pathMatcher = new TestPathCombiner();
983+
pathMatcher.combineFails("/*.html", "/*.txt");
984+
pathMatcher.combineFails("/a/b/c/*.html", "/d/e/f/hotel.*");
952985
}
953986

954987
@Test
@@ -1268,6 +1301,12 @@ public String combine(String string1, String string2) {
12681301
return pattern1.combine(pattern2).getPatternString();
12691302
}
12701303

1304+
public void combineFails(String string1, String string2) {
1305+
PathPattern pattern1 = pp.parse(string1);
1306+
PathPattern pattern2 = pp.parse(string2);
1307+
assertThatIllegalArgumentException().isThrownBy(() -> pattern1.combine(pattern2));
1308+
}
1309+
12711310
}
12721311

12731312
}

0 commit comments

Comments
 (0)