diff --git a/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java b/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java index d0d6d33f27aa..5adca325d44b 100644 --- a/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java +++ b/sdk-platform-java/api-common-java/src/main/java/com/google/api/pathtemplate/PathTemplate.java @@ -36,9 +36,9 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import com.google.common.net.PercentEscaper; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; -import java.net.URLEncoder; import java.util.ArrayList; import java.util.List; import java.util.ListIterator; @@ -888,12 +888,14 @@ private String instantiate(Map values, boolean allowPartial) { || nextNext.kind() != SegmentKind.END_BINDING; restore(iterator, iterator.nextIndex() - 2); if (!pathEscape) { + validatePathSegment(var, value); result.append(encodeUrl(value)); } else { // For a path wildcard or path of length greater 1, split the value and escape // every sub-segment. boolean first = true; for (String subSeg : SLASH_SPLITTER.split(value)) { + validatePathSegment(var, subSeg); if (!first) { result.append('/'); } @@ -1208,13 +1210,18 @@ private static List parseComplexResourceId(String seg) { // Helpers // ======= + private static final PercentEscaper URL_ESCAPER = new PercentEscaper("-_.~", false); + + private static void validatePathSegment(String var, String segment) { + if (".".equals(segment) || "..".equals(segment)) { + throw new ValidationException( + "Value for %s must not contain segments that are exactly . or ..", var); + } + } + private String encodeUrl(String text) { if (urlEncoding) { - try { - return URLEncoder.encode(text, "UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new ValidationException("UTF-8 encoding is not supported on this platform"); - } + return URL_ESCAPER.escape(text); } else { // When encoding is disabled, we accept any character except '/' final String INVALID_CHAR = "/"; diff --git a/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java b/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java index db23c9bc8ae5..98c6382387f8 100644 --- a/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java +++ b/sdk-platform-java/api-common-java/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java @@ -642,6 +642,120 @@ void instantiateEscapeUnsafeChar() { .isEqualTo("buckets/f%2Fo%2Fo/objects/b%2Fa%2Fr"); } + @Test + void instantiateRejectsDotInSingleWildcard() { + PathTemplate template = + PathTemplate.create("/compute/v1/projects/{project}/regions/{region}/addresses"); + ValidationException dotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("project", "my-project", "region", ".")); + Truth.assertThat(dotException.getMessage()) + .isEqualTo("Value for region must not contain segments that are exactly . or .."); + } + + @Test + void instantiateRejectsDoubleDotInSingleWildcard() { + PathTemplate template = + PathTemplate.create("/compute/v1/projects/{project}/regions/{region}/addresses"); + ValidationException dotDotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("project", "my-project", "region", "..")); + Truth.assertThat(dotDotException.getMessage()) + .isEqualTo("Value for region must not contain segments that are exactly . or .."); + } + + @Test + void instantiateRejectsDotInMultiSegmentWildcard() { + PathTemplate template = + PathTemplate.create("/v2/{parent=projects/*/locations/*}/content:inspect"); + ValidationException dotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("parent", "projects/my-project/locations/.")); + Truth.assertThat(dotException.getMessage()) + .isEqualTo("Value for parent must not contain segments that are exactly . or .."); + } + + @Test + void instantiateRejectsDoubleDotInMultiSegmentWildcard() { + PathTemplate template = + PathTemplate.create("/v2/{parent=projects/*/locations/*}/content:inspect"); + ValidationException dotDotException = + Assertions.assertThrows( + ValidationException.class, + () -> template.instantiate("parent", "projects/my-project/locations/..")); + Truth.assertThat(dotDotException.getMessage()) + .isEqualTo("Value for parent must not contain segments that are exactly . or .."); + } + + @Test + void instantiateRejectsDotInPathWildcard() { + PathTemplate template = + PathTemplate.create("/v3/{name=projects/*/monitoredResourceDescriptors/**}"); + for (String invalidName : + new String[] { + "projects/my-project/monitoredResourceDescriptors/instance/my-instance/.", + "projects/my-project/monitoredResourceDescriptors/.", + "projects/my-project/monitoredResourceDescriptors/instance/./my-instance", + "projects/my-project/monitoredResourceDescriptors/ . " + }) { + ValidationException exception = + Assertions.assertThrows( + ValidationException.class, () -> template.instantiate("name", invalidName)); + Truth.assertThat(exception.getMessage()) + .isEqualTo("Value for name must not contain segments that are exactly . or .."); + } + } + + @Test + void instantiateRejectsDoubleDotInPathWildcard() { + PathTemplate template = + PathTemplate.create("/v3/{name=projects/*/monitoredResourceDescriptors/**}"); + for (String invalidName : + new String[] { + "projects/my-project/monitoredResourceDescriptors/instance/my-instance/..", + "projects/my-project/monitoredResourceDescriptors/..", + "projects/my-project/monitoredResourceDescriptors/instance/./my-instance/..", + "projects/my-project/monitoredResourceDescriptors/instance/../..", + "projects/my-project/monitoredResourceDescriptors/ .. " + }) { + ValidationException exception = + Assertions.assertThrows( + ValidationException.class, () -> template.instantiate("name", invalidName)); + Truth.assertThat(exception.getMessage()) + .isEqualTo("Value for name must not contain segments that are exactly . or .."); + } + } + + @Test + void instantiateAllowsSafeDotsAndPercentEncodesReservedChars() { + PathTemplate template = PathTemplate.create("/v1/{x=projects/*/databases/*/documents/**}"); + Truth.assertThat( + template.instantiate("x", "projects/sys-prod-123/databases/default/documents/my.txt")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/my.txt"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/my..file.txt")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/my..file.txt"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/doc?key=val")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/doc%3Fkey%3Dval"); + Truth.assertThat( + template.instantiate("x", "projects/sys-prod-123/databases/default/documents/doc#frag")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/doc%23frag"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/doc with space")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/doc%20with%20space"); + Truth.assertThat( + template.instantiate( + "x", "projects/sys-prod-123/databases/default/documents/%2e%2e/escape-db")) + .isEqualTo("v1/projects/sys-prod-123/databases/default/documents/%252e%252e/escape-db"); + } + @Test void instantiateNotEscapeForUnboundedWildcard() { PathTemplate template = PathTemplate.create("buckets/*/objects/**");