This is an automated email from the ASF dual-hosted git repository. kwin pushed a commit to branch feature/escape-resource-names in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-api.git
commit 2f40662dc0dacf32012836cd8450a231beac172e Author: Konrad Windszus <[email protected]> AuthorDate: Mon Jun 2 18:46:30 2025 +0200 SLING-12815 Expose methods to escape/unescape resource names This takes care of escaping characters not allowed by Sling API in resource names, namely "/" and names only consisting of ".". Highlight in javadocs where the escape methods may be useful. --- .../sling/api/resource/ResourceResolver.java | 5 +- .../apache/sling/api/resource/ResourceUtil.java | 66 ++++++++++++++++++++++ .../sling/api/resource/SyntheticResource.java | 9 ++- .../sling/api/resource/ResourceUtilTest.java | 36 ++++++++---- 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/apache/sling/api/resource/ResourceResolver.java b/src/main/java/org/apache/sling/api/resource/ResourceResolver.java index 51339d2..04e09c6 100644 --- a/src/main/java/org/apache/sling/api/resource/ResourceResolver.java +++ b/src/main/java/org/apache/sling/api/resource/ResourceResolver.java @@ -771,17 +771,18 @@ public interface ResourceResolver extends Adaptable, Closeable { * The changes are transient and require a call to {@link #commit()} for persisting. * * @param parent The parent resource - * @param name The name of the child resource - this is a plain name, not a path! + * @param name The name of the child resource - this is a plain name, not a path! The name must neither contain a slash and nor consist out of dots only. The underlying resource provider may impose further restrictions on the name. * @param properties Optional properties for the resource * @return The new resource * * @throws NullPointerException if the resource parameter or name parameter is null - * @throws IllegalArgumentException if the name contains a slash + * @throws IllegalArgumentException if the name contains a slash or consists out of dots only. * @throws UnsupportedOperationException If the underlying resource provider does not support write operations. * @throws PersistenceException If the operation fails in the underlying resource provider, e.g. in case a resource of that name does already exist. * @throws IllegalStateException if this resource resolver has already been * {@link #close() closed}. * @since 2.2 (Sling API Bundle 2.2.0) + * @see ResourceUtil#escapeForName(String) */ @NotNull Resource create(@NotNull Resource parent, @NotNull String name, Map<String, Object> properties) diff --git a/src/main/java/org/apache/sling/api/resource/ResourceUtil.java b/src/main/java/org/apache/sling/api/resource/ResourceUtil.java index 9c2f816..10291fa 100644 --- a/src/main/java/org/apache/sling/api/resource/ResourceUtil.java +++ b/src/main/java/org/apache/sling/api/resource/ResourceUtil.java @@ -19,12 +19,17 @@ package org.apache.sling.api.resource; import java.text.MessageFormat; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.sling.api.wrappers.ValueMapDecorator; import org.jetbrains.annotations.NotNull; @@ -39,6 +44,7 @@ import org.jetbrains.annotations.Nullable; */ public class ResourceUtil { + private static final Pattern UNICODE_ESCAPE_SEQUENCE_PATTERN = Pattern.compile("\\\\u[0-9a-fA-F]{4}"); /** * Resolves relative path segments '.' and '..' in the path. * The path can either be relative or absolute. Relative paths are treated @@ -291,6 +297,66 @@ public class ResourceUtil { return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1); } + /** + * Escapes the given <code>name</code> for use in a resource name. It escapes all invalid characters according to Sling API, i.e. + * it escapes the slash and names only consisting of dots. It uses Java UTF-16 unicode escape sequences for those characters. + * @param name + * @return the escaped name + * @see ResourceResolver#create(Resource, String, Map) + * @see SyntheticResource#create(ResourceResolver, String, Map) + * @see <a href="https://www.rfc-editor.org/rfc/rfc5137#section-6.3">RFC 5137, section 6.3</a> + * @since 2.14.0 (Sling API Bundle 3.0.0) + */ + public static @NotNull String escapeName(@NotNull String name) { + if (name.chars().allMatch(c -> c == '.')) { + return escapeWithUnicode(name, '.'); + } + return escapeWithUnicode(name, '/'); + } + + /** + * Unescapes the given <code>escapedName</code> previously escaped using {@link #escapeForName(String)}. + * It replaces the unicode escape sequences with the original characters. + * + * @param escapedName The escaped name to unescape. + * @return The unescaped name. + * @see Resource#getName() + * @see <a href="https://www.rfc-editor.org/rfc/rfc5137#section-6.3">RFC 5137, section 6.3</a> + * @since 2.14.0 (Sling API Bundle 3.0.0) + */ + public static @NotNull String unescapeName(@NotNull String escapedName) { + return unescapeWithUnicode(escapedName); + } + + private static String escapeWithUnicode(String text, Character... additionalCharactersToEscape) { + List<Character> charactersToEscape = new LinkedList<>(); + charactersToEscape.add('\\'); // always escape the backslash as it used for unicode escaping itself + charactersToEscape.addAll(Arrays.asList(additionalCharactersToEscape)); + for (Character characterToEscape : charactersToEscape) { + String escapedChar = getUnicodeEscapeSequence(characterToEscape); + text = text.replace(characterToEscape.toString(), escapedChar); + } + return text; + } + + private static String getUnicodeEscapeSequence(char c) { + return String.format("\\u%04X", (int) c); + } + + private static String unescapeWithUnicode(String escapedText) { + Matcher matcher = UNICODE_ESCAPE_SEQUENCE_PATTERN.matcher(escapedText); + + StringBuilder decodedString = new StringBuilder(); + + while (matcher.find()) { + String unicodeSequence = matcher.group(); + char unicodeChar = (char) Integer.parseInt(unicodeSequence.substring(2), 16); + matcher.appendReplacement(decodedString, Character.toString(unicodeChar)); + } + matcher.appendTail(decodedString); + return decodedString.toString(); + } + /** * Returns <code>true</code> if the resource <code>res</code> is a synthetic * resource. diff --git a/src/main/java/org/apache/sling/api/resource/SyntheticResource.java b/src/main/java/org/apache/sling/api/resource/SyntheticResource.java index d869aa0..b66245c 100644 --- a/src/main/java/org/apache/sling/api/resource/SyntheticResource.java +++ b/src/main/java/org/apache/sling/api/resource/SyntheticResource.java @@ -43,13 +43,18 @@ public class SyntheticResource extends AbstractResource { * Creates a synthetic resource with the given <code>path</code> and * <code>resourceType</code>. * @param resourceResolver The resource resolver - * @param path The resource path + * @param path The absolute resource path including the name. Make sure that each segment of the path only contains valid characters in Sling API resource names. * @param resourceType The type of the resource + * @throws IllegalArgumentException If the path is not valid + * @see ResourceUtil#escapeName(String) */ public SyntheticResource( @NotNull ResourceResolver resourceResolver, @NotNull String path, @NotNull String resourceType) { this.resourceResolver = resourceResolver; - this.path = path; + this.path = ResourceUtil.normalize(path); // resolve ".." and "." because otherwise the resource cannot be retrieved + if (this.path == null) { + throw new IllegalArgumentException("Invalid resource path: " + path); + } this.resourceType = resourceType; this.resourceMetadata = new ResourceMetadata(); this.resourceMetadata.setResolutionPath(path); diff --git a/src/test/java/org/apache/sling/api/resource/ResourceUtilTest.java b/src/test/java/org/apache/sling/api/resource/ResourceUtilTest.java index 8826020..1f9c597 100644 --- a/src/test/java/org/apache/sling/api/resource/ResourceUtilTest.java +++ b/src/test/java/org/apache/sling/api/resource/ResourceUtilTest.java @@ -18,16 +18,6 @@ */ package org.apache.sling.api.resource; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; - -import org.apache.sling.api.wrappers.ValueMapDecorator; -import org.junit.Test; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -38,6 +28,16 @@ import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; + +import org.apache.sling.api.wrappers.ValueMapDecorator; +import org.junit.Test; + public class ResourceUtilTest { @Test @@ -433,4 +433,20 @@ public class ResourceUtilTest { public void testFindResourceSuperType() { assertNull(ResourceUtil.findResourceSuperType(null)); } + + @Test + public void testEscapeAndUnescapeNameWithSlash() { + String nameWithSpecialChars = "this/is/..//u1234test"; + String escapedName = ResourceUtil.escapeName(nameWithSpecialChars); + assertEquals(nameWithSpecialChars, ResourceUtil.unescapeName(escapedName)); + assertFalse(escapedName.contains("/")); + } + + @Test + public void testEscapeAndUnescapeNameWithDots() { + String nameWithSpecialChars = "...."; + String escapedName = ResourceUtil.escapeName(nameWithSpecialChars); + assertEquals(nameWithSpecialChars, ResourceUtil.unescapeName(escapedName)); + assertFalse(escapedName.contains(".")); + } }
