Deal with illegal JCR characters
authorMathieu Baudier <mbaudier@argeo.org>
Thu, 16 Mar 2017 10:56:07 +0000 (11:56 +0100)
committerMathieu Baudier <mbaudier@argeo.org>
Thu, 16 Mar 2017 10:57:25 +0000 (11:57 +0100)
org.argeo.jcr/ext/test/org/argeo/jcr/fs/JcrFileSystemTest.java
org.argeo.jcr/src/org/argeo/jcr/fs/JcrFileSystemProvider.java
org.argeo.jcr/src/org/argeo/jcr/fs/JcrPath.java
org.argeo.jcr/src/org/argeo/jcr/fs/Text.java [new file with mode: 0644]

index 2ad75a2aa2fceee280106182428e1427f988a5ca..de2e8e828ff61b55ceb29382c3f7120253218ef9 100644 (file)
@@ -103,4 +103,24 @@ public class JcrFileSystemTest extends TestCase {
                assertEquals(6, attrs.size());
                log.debug("Read attributes of " + copiedFile + ": " + attrs.keySet());
        }
+
+       public void testIllegalCharacters() throws Exception {
+               FileSystemProvider fsProvider = new JackrabbitMemoryFsProvider();
+               String fileName = "tüßçt[1].txt";
+               String pathStr = "/testDir/" + fileName;
+               Path testDir = fsProvider.getPath(new URI("jcr+memory:/testDir"));
+               Files.createDirectory(testDir);
+               Path testPath = testDir.resolve(fileName);
+               assertEquals(pathStr, testPath.toString());
+               Files.createFile(testPath);
+               DirectoryStream<Path> files = Files.newDirectoryStream(testDir);
+               Path listedPath = files.iterator().next();
+               assertEquals(pathStr, listedPath.toString());
+
+               String dirName = "*[~WeirdDir~]*";
+               Path subDir = testDir.resolve(dirName);
+               Files.createDirectory(subDir);
+               subDir = testDir.resolve(dirName);
+               assertEquals(dirName, subDir.getFileName().toString());
+       }
 }
index 17665a0a4a0735557699a24b5322c08e820aee8a..dcc59476ff646b59a97a54567958c325f19bbb84 100644 (file)
@@ -47,7 +47,9 @@ public abstract class JcrFileSystemProvider extends FileSystemProvider {
                                                || parent.getPrimaryNodeType().isNodeType(NodeType.NT_LINKED_FILE))
                                        throw new JcrFsException(path + " parent is a file");
 
-                               node = parent.addNode(path.getFileName().toString(), NodeType.NT_FILE);
+                               String fileName = path.getFileName().toString();
+                               fileName = Text.escapeIllegalJcrChars(fileName);
+                               node = parent.addNode(fileName, NodeType.NT_FILE);
                                node.addMixin(NodeType.MIX_CREATED);
                                node.addMixin(NodeType.MIX_LAST_MODIFIED);
                        }
@@ -80,7 +82,9 @@ public abstract class JcrFileSystemProvider extends FileSystemProvider {
                                if (parent.getPrimaryNodeType().isNodeType(NodeType.NT_FILE)
                                                || parent.getPrimaryNodeType().isNodeType(NodeType.NT_LINKED_FILE))
                                        throw new JcrFsException(dir + " parent is a file");
-                               node = parent.addNode(dir.getFileName().toString(), NodeType.NT_FOLDER);
+                               String fileName = dir.getFileName().toString();
+                               fileName = Text.escapeIllegalJcrChars(fileName);
+                               node = parent.addNode(fileName, NodeType.NT_FOLDER);
                                node.addMixin(NodeType.MIX_CREATED);
                                node.addMixin(NodeType.MIX_LAST_MODIFIED);
                                node.getSession().save();
index 1f79434845eb84c54ce2aa9ab08bbdafb5d76113..09b3e0e7ac0c62391631f90a2ecbc2fd913d6b53 100644 (file)
@@ -51,6 +51,9 @@ public class JcrPath implements Path {
                String trimmedPath = path.substring(absolute ? 1 : 0,
                                path.charAt(path.length() - 1) == delimChar ? path.length() - 1 : path.length());
                this.path = trimmedPath.split(delimStr);
+               for (int i = 0; i < this.path.length; i++) {
+                       this.path[i] = Text.unescapeIllegalJcrChars(this.path[i]);
+               }
                this.hashCode = this.path[this.path.length - 1].hashCode();
        }
 
@@ -102,6 +105,20 @@ public class JcrPath implements Path {
                return sb.toString();
        }
 
+       public String toJcrPath() {
+               if (path == null)
+                       return "/";
+               StringBuilder sb = new StringBuilder();
+               if (isAbsolute())
+                       sb.append('/');
+               for (int i = 0; i < path.length; i++) {
+                       if (i != 0)
+                               sb.append('/');
+                       sb.append(Text.escapeIllegalJcrChars(path[i]));
+               }
+               return sb.toString();
+       }
+
        @Override
        public Path getFileName() {
                if (path == null)
@@ -288,7 +305,7 @@ public class JcrPath implements Path {
        public Node getNode() throws RepositoryException {
                if (!isAbsolute())// TODO default dir
                        throw new JcrFsException("Cannot get node from relative path");
-               String pathStr = toString();
+               String pathStr = toJcrPath();
                Session session = fs.getSession();
                // TODO synchronize on the session ?
                if (!session.itemExists(pathStr))
diff --git a/org.argeo.jcr/src/org/argeo/jcr/fs/Text.java b/org.argeo.jcr/src/org/argeo/jcr/fs/Text.java
new file mode 100644 (file)
index 0000000..4643c8c
--- /dev/null
@@ -0,0 +1,877 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.argeo.jcr.fs;
+
+import java.io.ByteArrayOutputStream;
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Properties;
+
+/**
+ * <b>Hacked from org.apache.jackrabbit.util.Text in Jackrabbit JCR Commons</b>
+ * This Class provides some text related utilities
+ */
+class Text {
+
+       /**
+        * Hidden constructor.
+        */
+       private Text() {
+       }
+
+       /**
+        * used for the md5
+        */
+       public static final char[] hexTable = "0123456789abcdef".toCharArray();
+
+       /**
+        * Calculate an MD5 hash of the string given.
+        *
+        * @param data
+        *            the data to encode
+        * @param enc
+        *            the character encoding to use
+        * @return a hex encoded string of the md5 digested input
+        */
+       public static String md5(String data, String enc) throws UnsupportedEncodingException {
+               try {
+                       return digest("MD5", data.getBytes(enc));
+               } catch (NoSuchAlgorithmException e) {
+                       throw new InternalError("MD5 digest not available???");
+               }
+       }
+
+       /**
+        * Calculate an MD5 hash of the string given using 'utf-8' encoding.
+        *
+        * @param data
+        *            the data to encode
+        * @return a hex encoded string of the md5 digested input
+        */
+       public static String md5(String data) {
+               try {
+                       return md5(data, "utf-8");
+               } catch (UnsupportedEncodingException e) {
+                       throw new InternalError("UTF8 digest not available???");
+               }
+       }
+
+       /**
+        * Digest the plain string using the given algorithm.
+        *
+        * @param algorithm
+        *            The alogrithm for the digest. This algorithm must be supported
+        *            by the MessageDigest class.
+        * @param data
+        *            The plain text String to be digested.
+        * @param enc
+        *            The character encoding to use
+        * @return The digested plain text String represented as Hex digits.
+        * @throws java.security.NoSuchAlgorithmException
+        *             if the desired algorithm is not supported by the
+        *             MessageDigest class.
+        * @throws java.io.UnsupportedEncodingException
+        *             if the encoding is not supported
+        */
+       public static String digest(String algorithm, String data, String enc)
+                       throws NoSuchAlgorithmException, UnsupportedEncodingException {
+
+               return digest(algorithm, data.getBytes(enc));
+       }
+
+       /**
+        * Digest the plain string using the given algorithm.
+        *
+        * @param algorithm
+        *            The algorithm for the digest. This algorithm must be supported
+        *            by the MessageDigest class.
+        * @param data
+        *            the data to digest with the given algorithm
+        * @return The digested plain text String represented as Hex digits.
+        * @throws java.security.NoSuchAlgorithmException
+        *             if the desired algorithm is not supported by the
+        *             MessageDigest class.
+        */
+       public static String digest(String algorithm, byte[] data) throws NoSuchAlgorithmException {
+
+               MessageDigest md = MessageDigest.getInstance(algorithm);
+               byte[] digest = md.digest(data);
+               StringBuilder res = new StringBuilder(digest.length * 2);
+               for (byte b : digest) {
+                       res.append(hexTable[(b >> 4) & 15]);
+                       res.append(hexTable[b & 15]);
+               }
+               return res.toString();
+       }
+
+       /**
+        * returns an array of strings decomposed of the original string, split at
+        * every occurrence of 'ch'. if 2 'ch' follow each other with no
+        * intermediate characters, empty "" entries are avoided.
+        *
+        * @param str
+        *            the string to decompose
+        * @param ch
+        *            the character to use a split pattern
+        * @return an array of strings
+        */
+       public static String[] explode(String str, int ch) {
+               return explode(str, ch, false);
+       }
+
+       /**
+        * returns an array of strings decomposed of the original string, split at
+        * every occurrence of 'ch'.
+        *
+        * @param str
+        *            the string to decompose
+        * @param ch
+        *            the character to use a split pattern
+        * @param respectEmpty
+        *            if <code>true</code>, empty elements are generated
+        * @return an array of strings
+        */
+       public static String[] explode(String str, int ch, boolean respectEmpty) {
+               if (str == null || str.length() == 0) {
+                       return new String[0];
+               }
+
+               ArrayList<String> strings = new ArrayList<String>();
+               int pos;
+               int lastpos = 0;
+
+               // add snipples
+               while ((pos = str.indexOf(ch, lastpos)) >= 0) {
+                       if (pos - lastpos > 0 || respectEmpty) {
+                               strings.add(str.substring(lastpos, pos));
+                       }
+                       lastpos = pos + 1;
+               }
+               // add rest
+               if (lastpos < str.length()) {
+                       strings.add(str.substring(lastpos));
+               } else if (respectEmpty && lastpos == str.length()) {
+                       strings.add("");
+               }
+
+               // return string array
+               return strings.toArray(new String[strings.size()]);
+       }
+
+       /**
+        * Concatenates all strings in the string array using the specified
+        * delimiter.
+        * 
+        * @param arr
+        * @param delim
+        * @return the concatenated string
+        */
+       public static String implode(String[] arr, String delim) {
+               StringBuilder buf = new StringBuilder();
+               for (int i = 0; i < arr.length; i++) {
+                       if (i > 0) {
+                               buf.append(delim);
+                       }
+                       buf.append(arr[i]);
+               }
+               return buf.toString();
+       }
+
+       /**
+        * Replaces all occurrences of <code>oldString</code> in <code>text</code>
+        * with <code>newString</code>.
+        *
+        * @param text
+        * @param oldString
+        *            old substring to be replaced with <code>newString</code>
+        * @param newString
+        *            new substring to replace occurrences of <code>oldString</code>
+        * @return a string
+        */
+       public static String replace(String text, String oldString, String newString) {
+               if (text == null || oldString == null || newString == null) {
+                       throw new IllegalArgumentException("null argument");
+               }
+               int pos = text.indexOf(oldString);
+               if (pos == -1) {
+                       return text;
+               }
+               int lastPos = 0;
+               StringBuilder sb = new StringBuilder(text.length());
+               while (pos != -1) {
+                       sb.append(text.substring(lastPos, pos));
+                       sb.append(newString);
+                       lastPos = pos + oldString.length();
+                       pos = text.indexOf(oldString, lastPos);
+               }
+               if (lastPos < text.length()) {
+                       sb.append(text.substring(lastPos));
+               }
+               return sb.toString();
+       }
+
+       /**
+        * Replaces XML characters in the given string that might need escaping as
+        * XML text or attribute
+        *
+        * @param text
+        *            text to be escaped
+        * @return a string
+        */
+       public static String encodeIllegalXMLCharacters(String text) {
+               return encodeMarkupCharacters(text, false);
+       }
+
+       /**
+        * Replaces HTML characters in the given string that might need escaping as
+        * HTML text or attribute
+        *
+        * @param text
+        *            text to be escaped
+        * @return a string
+        */
+       public static String encodeIllegalHTMLCharacters(String text) {
+               return encodeMarkupCharacters(text, true);
+       }
+
+       private static String encodeMarkupCharacters(String text, boolean isHtml) {
+               if (text == null) {
+                       throw new IllegalArgumentException("null argument");
+               }
+               StringBuilder buf = null;
+               int length = text.length();
+               int pos = 0;
+               for (int i = 0; i < length; i++) {
+                       int ch = text.charAt(i);
+                       switch (ch) {
+                       case '<':
+                       case '>':
+                       case '&':
+                       case '"':
+                       case '\'':
+                               if (buf == null) {
+                                       buf = new StringBuilder();
+                               }
+                               if (i > 0) {
+                                       buf.append(text.substring(pos, i));
+                               }
+                               pos = i + 1;
+                               break;
+                       default:
+                               continue;
+                       }
+                       if (ch == '<') {
+                               buf.append("&lt;");
+                       } else if (ch == '>') {
+                               buf.append("&gt;");
+                       } else if (ch == '&') {
+                               buf.append("&amp;");
+                       } else if (ch == '"') {
+                               buf.append("&quot;");
+                       } else if (ch == '\'') {
+                               buf.append(isHtml ? "&#39;" : "&apos;");
+                       }
+               }
+               if (buf == null) {
+                       return text;
+               } else {
+                       if (pos < length) {
+                               buf.append(text.substring(pos));
+                       }
+                       return buf.toString();
+               }
+       }
+
+       /**
+        * The list of characters that are not encoded by the <code>escape()</code>
+        * and <code>unescape()</code> METHODS. They contains the characters as
+        * defined 'unreserved' in section 2.3 of the RFC 2396 'URI generic syntax':
+        * <p>
+        * 
+        * <pre>
+        * unreserved  = alphanum | mark
+        * mark        = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
+        * </pre>
+        */
+       public static BitSet URISave;
+
+       /**
+        * Same as {@link #URISave} but also contains the '/'
+        */
+       public static BitSet URISaveEx;
+
+       static {
+               URISave = new BitSet(256);
+               int i;
+               for (i = 'a'; i <= 'z'; i++) {
+                       URISave.set(i);
+               }
+               for (i = 'A'; i <= 'Z'; i++) {
+                       URISave.set(i);
+               }
+               for (i = '0'; i <= '9'; i++) {
+                       URISave.set(i);
+               }
+               URISave.set('-');
+               URISave.set('_');
+               URISave.set('.');
+               URISave.set('!');
+               URISave.set('~');
+               URISave.set('*');
+               URISave.set('\'');
+               URISave.set('(');
+               URISave.set(')');
+
+               URISaveEx = (BitSet) URISave.clone();
+               URISaveEx.set('/');
+       }
+
+       /**
+        * Does an URL encoding of the <code>string</code> using the
+        * <code>escape</code> character. The characters that don't need encoding
+        * are those defined 'unreserved' in section 2.3 of the 'URI generic syntax'
+        * RFC 2396, but without the escape character.
+        *
+        * @param string
+        *            the string to encode.
+        * @param escape
+        *            the escape character.
+        * @return the escaped string
+        * @throws NullPointerException
+        *             if <code>string</code> is <code>null</code>.
+        */
+       public static String escape(String string, char escape) {
+               return escape(string, escape, false);
+       }
+
+       /**
+        * Does an URL encoding of the <code>string</code> using the
+        * <code>escape</code> character. The characters that don't need encoding
+        * are those defined 'unreserved' in section 2.3 of the 'URI generic syntax'
+        * RFC 2396, but without the escape character. If <code>isPath</code> is
+        * <code>true</code>, additionally the slash '/' is ignored, too.
+        *
+        * @param string
+        *            the string to encode.
+        * @param escape
+        *            the escape character.
+        * @param isPath
+        *            if <code>true</code>, the string is treated as path
+        * @return the escaped string
+        * @throws NullPointerException
+        *             if <code>string</code> is <code>null</code>.
+        */
+       public static String escape(String string, char escape, boolean isPath) {
+               try {
+                       BitSet validChars = isPath ? URISaveEx : URISave;
+                       byte[] bytes = string.getBytes("utf-8");
+                       StringBuilder out = new StringBuilder(bytes.length);
+                       for (byte aByte : bytes) {
+                               int c = aByte & 0xff;
+                               if (validChars.get(c) && c != escape) {
+                                       out.append((char) c);
+                               } else {
+                                       out.append(escape);
+                                       out.append(hexTable[(c >> 4) & 0x0f]);
+                                       out.append(hexTable[(c) & 0x0f]);
+                               }
+                       }
+                       return out.toString();
+               } catch (UnsupportedEncodingException e) {
+                       throw new InternalError(e.toString());
+               }
+       }
+
+       /**
+        * Does a URL encoding of the <code>string</code>. The characters that don't
+        * need encoding are those defined 'unreserved' in section 2.3 of the 'URI
+        * generic syntax' RFC 2396.
+        *
+        * @param string
+        *            the string to encode
+        * @return the escaped string
+        * @throws NullPointerException
+        *             if <code>string</code> is <code>null</code>.
+        */
+       public static String escape(String string) {
+               return escape(string, '%');
+       }
+
+       /**
+        * Does a URL encoding of the <code>path</code>. The characters that don't
+        * need encoding are those defined 'unreserved' in section 2.3 of the 'URI
+        * generic syntax' RFC 2396. In contrast to the {@link #escape(String)}
+        * method, not the entire path string is escaped, but every individual part
+        * (i.e. the slashes are not escaped).
+        *
+        * @param path
+        *            the path to encode
+        * @return the escaped path
+        * @throws NullPointerException
+        *             if <code>path</code> is <code>null</code>.
+        */
+       public static String escapePath(String path) {
+               return escape(path, '%', true);
+       }
+
+       /**
+        * Does a URL decoding of the <code>string</code> using the
+        * <code>escape</code> character. Please note that in opposite to the
+        * {@link java.net.URLDecoder} it does not transform the + into spaces.
+        *
+        * @param string
+        *            the string to decode
+        * @param escape
+        *            the escape character
+        * @return the decoded string
+        * @throws NullPointerException
+        *             if <code>string</code> is <code>null</code>.
+        * @throws IllegalArgumentException
+        *             if the 2 characters following the escape character do not
+        *             represent a hex-number or if not enough characters follow an
+        *             escape character
+        */
+       public static String unescape(String string, char escape) {
+               try {
+                       byte[] utf8 = string.getBytes("utf-8");
+
+                       // Check whether escape occurs at invalid position
+                       if ((utf8.length >= 1 && utf8[utf8.length - 1] == escape)
+                                       || (utf8.length >= 2 && utf8[utf8.length - 2] == escape)) {
+                               throw new IllegalArgumentException("Premature end of escape sequence at end of input");
+                       }
+
+                       ByteArrayOutputStream out = new ByteArrayOutputStream(utf8.length);
+                       for (int k = 0; k < utf8.length; k++) {
+                               byte b = utf8[k];
+                               if (b == escape) {
+                                       out.write((decodeDigit(utf8[++k]) << 4) + decodeDigit(utf8[++k]));
+                               } else {
+                                       out.write(b);
+                               }
+                       }
+
+                       return new String(out.toByteArray(), "utf-8");
+               } catch (UnsupportedEncodingException e) {
+                       throw new InternalError(e.toString());
+               }
+       }
+
+       /**
+        * Does a URL decoding of the <code>string</code>. Please note that in
+        * opposite to the {@link java.net.URLDecoder} it does not transform the +
+        * into spaces.
+        *
+        * @param string
+        *            the string to decode
+        * @return the decoded string
+        * @throws NullPointerException
+        *             if <code>string</code> is <code>null</code>.
+        * @throws ArrayIndexOutOfBoundsException
+        *             if not enough character follow an escape character
+        * @throws IllegalArgumentException
+        *             if the 2 characters following the escape character do not
+        *             represent a hex-number.
+        */
+       public static String unescape(String string) {
+               return unescape(string, '%');
+       }
+
+       /**
+        * Escapes all illegal JCR name characters of a string. The encoding is
+        * loosely modeled after URI encoding, but only encodes the characters it
+        * absolutely needs to in order to make the resulting string a valid JCR
+        * name. Use {@link #unescapeIllegalJcrChars(String)} for decoding.
+        * <p>
+        * QName EBNF:<br>
+        * <xmp> simplename ::= onecharsimplename | twocharsimplename |
+        * threeormorecharname onecharsimplename ::= (* Any Unicode character
+        * except: '.', '/', ':', '[', ']', '*', '|' or any whitespace character *)
+        * twocharsimplename ::= '.' onecharsimplename | onecharsimplename '.' |
+        * onecharsimplename onecharsimplename threeormorecharname ::= nonspace
+        * string nonspace string ::= char | string char char ::= nonspace | ' '
+        * nonspace ::= (* Any Unicode character except: '/', ':', '[', ']', '*',
+        * '|' or any whitespace character *) </xmp>
+        *
+        * @param name
+        *            the name to escape
+        * @return the escaped name
+        */
+       public static String escapeIllegalJcrChars(String name) {
+               return escapeIllegalChars(name, "%/:[]*|\t\r\n");
+       }
+
+       /**
+        * Escapes all illegal JCR 1.0 name characters of a string. Use
+        * {@link #unescapeIllegalJcrChars(String)} for decoding.
+        * <p>
+        * QName EBNF:<br>
+        * <xmp> simplename ::= onecharsimplename | twocharsimplename |
+        * threeormorecharname onecharsimplename ::= (* Any Unicode character
+        * except: '.', '/', ':', '[', ']', '*', ''', '"', '|' or any whitespace
+        * character *) twocharsimplename ::= '.' onecharsimplename |
+        * onecharsimplename '.' | onecharsimplename onecharsimplename
+        * threeormorecharname ::= nonspace string nonspace string ::= char | string
+        * char char ::= nonspace | ' ' nonspace ::= (* Any Unicode character
+        * except: '/', ':', '[', ']', '*', ''', '"', '|' or any whitespace
+        * character *) </xmp>
+        *
+        * @since Apache Jackrabbit 2.3.2 and 2.2.10
+        * @see <a href=
+        *      "https://issues.apache.org/jira/browse/JCR-3128">JCR-3128</a>
+        * @param name
+        *            the name to escape
+        * @return the escaped name
+        */
+       public static String escapeIllegalJcr10Chars(String name) {
+               return escapeIllegalChars(name, "%/:[]*'\"|\t\r\n");
+       }
+
+       private static String escapeIllegalChars(String name, String illegal) {
+               StringBuilder buffer = new StringBuilder(name.length() * 2);
+               for (int i = 0; i < name.length(); i++) {
+                       char ch = name.charAt(i);
+                       if (illegal.indexOf(ch) != -1 || (ch == '.' && name.length() < 3)
+                                       || (ch == ' ' && (i == 0 || i == name.length() - 1))) {
+                               buffer.append('%');
+                               buffer.append(Character.toUpperCase(Character.forDigit(ch / 16, 16)));
+                               buffer.append(Character.toUpperCase(Character.forDigit(ch % 16, 16)));
+                       } else {
+                               buffer.append(ch);
+                       }
+               }
+               return buffer.toString();
+       }
+
+       /**
+        * Escapes illegal XPath search characters at the end of a string.
+        * <p>
+        * Example:<br>
+        * A search string like 'test?' will run into a ParseException documented in
+        * http://issues.apache.org/jira/browse/JCR-1248
+        *
+        * @param s
+        *            the string to encode
+        * @return the escaped string
+        */
+       public static String escapeIllegalXpathSearchChars(String s) {
+               StringBuilder sb = new StringBuilder();
+               sb.append(s.substring(0, (s.length() - 1)));
+               char c = s.charAt(s.length() - 1);
+               // NOTE: keep this in sync with _ESCAPED_CHAR below!
+               if (c == '!' || c == '(' || c == ':' || c == '^' || c == '[' || c == ']' || c == '{' || c == '}' || c == '?') {
+                       sb.append('\\');
+               }
+               sb.append(c);
+               return sb.toString();
+       }
+
+       /**
+        * Unescapes previously escaped jcr chars.
+        * <p>
+        * Please note, that this does not exactly the same as the url related
+        * {@link #unescape(String)}, since it handles the byte-encoding
+        * differently.
+        *
+        * @param name
+        *            the name to unescape
+        * @return the unescaped name
+        */
+       public static String unescapeIllegalJcrChars(String name) {
+               StringBuilder buffer = new StringBuilder(name.length());
+               int i = name.indexOf('%');
+               while (i > -1 && i + 2 < name.length()) {
+                       buffer.append(name.toCharArray(), 0, i);
+                       int a = Character.digit(name.charAt(i + 1), 16);
+                       int b = Character.digit(name.charAt(i + 2), 16);
+                       if (a > -1 && b > -1) {
+                               buffer.append((char) (a * 16 + b));
+                               name = name.substring(i + 3);
+                       } else {
+                               buffer.append('%');
+                               name = name.substring(i + 1);
+                       }
+                       i = name.indexOf('%');
+               }
+               buffer.append(name);
+               return buffer.toString();
+       }
+
+       /**
+        * Returns the name part of the path. If the given path is already a name
+        * (i.e. contains no slashes) it is returned.
+        *
+        * @param path
+        *            the path
+        * @return the name part or <code>null</code> if <code>path</code> is
+        *         <code>null</code>.
+        */
+       public static String getName(String path) {
+               return getName(path, '/');
+       }
+
+       /**
+        * Returns the name part of the path, delimited by the given
+        * <code>delim</code>. If the given path is already a name (i.e. contains no
+        * <code>delim</code> characters) it is returned.
+        *
+        * @param path
+        *            the path
+        * @param delim
+        *            the delimiter
+        * @return the name part or <code>null</code> if <code>path</code> is
+        *         <code>null</code>.
+        */
+       public static String getName(String path, char delim) {
+               return path == null ? null : path.substring(path.lastIndexOf(delim) + 1);
+       }
+
+       /**
+        * Same as {@link #getName(String)} but adding the possibility to pass paths
+        * that end with a trailing '/'
+        *
+        * @see #getName(String)
+        */
+       public static String getName(String path, boolean ignoreTrailingSlash) {
+               if (ignoreTrailingSlash && path != null && path.endsWith("/") && path.length() > 1) {
+                       path = path.substring(0, path.length() - 1);
+               }
+               return getName(path);
+       }
+
+       /**
+        * Returns the namespace prefix of the given <code>qname</code>. If the
+        * prefix is missing, an empty string is returned. Please note, that this
+        * method does not validate the name or prefix.
+        * </p>
+        * the qname has the format: qname := [prefix ':'] local;
+        *
+        * @param qname
+        *            a qualified name
+        * @return the prefix of the name or "".
+        *
+        * @see #getLocalName(String)
+        *
+        * @throws NullPointerException
+        *             if <code>qname</code> is <code>null</code>
+        */
+       public static String getNamespacePrefix(String qname) {
+               int pos = qname.indexOf(':');
+               return pos >= 0 ? qname.substring(0, pos) : "";
+       }
+
+       /**
+        * Returns the local name of the given <code>qname</code>. Please note, that
+        * this method does not validate the name.
+        * </p>
+        * the qname has the format: qname := [prefix ':'] local;
+        *
+        * @param qname
+        *            a qualified name
+        * @return the localname
+        *
+        * @see #getNamespacePrefix(String)
+        *
+        * @throws NullPointerException
+        *             if <code>qname</code> is <code>null</code>
+        */
+       public static String getLocalName(String qname) {
+               int pos = qname.indexOf(':');
+               return pos >= 0 ? qname.substring(pos + 1) : qname;
+       }
+
+       /**
+        * Determines, if two paths denote hierarchical siblins.
+        *
+        * @param p1
+        *            first path
+        * @param p2
+        *            second path
+        * @return true if on same level, false otherwise
+        */
+       public static boolean isSibling(String p1, String p2) {
+               int pos1 = p1.lastIndexOf('/');
+               int pos2 = p2.lastIndexOf('/');
+               return (pos1 == pos2 && pos1 >= 0 && p1.regionMatches(0, p2, 0, pos1));
+       }
+
+       /**
+        * Determines if the <code>descendant</code> path is hierarchical a
+        * descendant of <code>path</code>.
+        *
+        * @param path
+        *            the current path
+        * @param descendant
+        *            the potential descendant
+        * @return <code>true</code> if the <code>descendant</code> is a descendant;
+        *         <code>false</code> otherwise.
+        */
+       public static boolean isDescendant(String path, String descendant) {
+               String pattern = path.endsWith("/") ? path : path + "/";
+               return !pattern.equals(descendant) && descendant.startsWith(pattern);
+       }
+
+       /**
+        * Determines if the <code>descendant</code> path is hierarchical a
+        * descendant of <code>path</code> or equal to it.
+        *
+        * @param path
+        *            the path to check
+        * @param descendant
+        *            the potential descendant
+        * @return <code>true</code> if the <code>descendant</code> is a descendant
+        *         or equal; <code>false</code> otherwise.
+        */
+       public static boolean isDescendantOrEqual(String path, String descendant) {
+               if (path.equals(descendant)) {
+                       return true;
+               } else {
+                       String pattern = path.endsWith("/") ? path : path + "/";
+                       return descendant.startsWith(pattern);
+               }
+       }
+
+       /**
+        * Returns the n<sup>th</sup> relative parent of the path, where n=level.
+        * <p>
+        * Example:<br>
+        * <code>
+        * Text.getRelativeParent("/foo/bar/test", 1) == "/foo/bar"
+        * </code>
+        *
+        * @param path
+        *            the path of the page
+        * @param level
+        *            the level of the parent
+        */
+       public static String getRelativeParent(String path, int level) {
+               int idx = path.length();
+               while (level > 0) {
+                       idx = path.lastIndexOf('/', idx - 1);
+                       if (idx < 0) {
+                               return "";
+                       }
+                       level--;
+               }
+               return (idx == 0) ? "/" : path.substring(0, idx);
+       }
+
+       /**
+        * Same as {@link #getRelativeParent(String, int)} but adding the
+        * possibility to pass paths that end with a trailing '/'
+        *
+        * @see #getRelativeParent(String, int)
+        */
+       public static String getRelativeParent(String path, int level, boolean ignoreTrailingSlash) {
+               if (ignoreTrailingSlash && path.endsWith("/") && path.length() > 1) {
+                       path = path.substring(0, path.length() - 1);
+               }
+               return getRelativeParent(path, level);
+       }
+
+       /**
+        * Returns the n<sup>th</sup> absolute parent of the path, where n=level.
+        * <p>
+        * Example:<br>
+        * <code>
+        * Text.getAbsoluteParent("/foo/bar/test", 1) == "/foo/bar"
+        * </code>
+        *
+        * @param path
+        *            the path of the page
+        * @param level
+        *            the level of the parent
+        */
+       public static String getAbsoluteParent(String path, int level) {
+               int idx = 0;
+               int len = path.length();
+               while (level >= 0 && idx < len) {
+                       idx = path.indexOf('/', idx + 1);
+                       if (idx < 0) {
+                               idx = len;
+                       }
+                       level--;
+               }
+               return level >= 0 ? "" : path.substring(0, idx);
+       }
+
+       /**
+        * Performs variable replacement on the given string value. Each
+        * <code>${...}</code> sequence within the given value is replaced with the
+        * value of the named parser variable. If a variable is not found in the
+        * properties an IllegalArgumentException is thrown unless
+        * <code>ignoreMissing</code> is <code>true</code>. In the later case, the
+        * missing variable is replaced by the empty string.
+        *
+        * @param value
+        *            the original value
+        * @param ignoreMissing
+        *            if <code>true</code>, missing variables are replaced by the
+        *            empty string.
+        * @return value after variable replacements
+        * @throws IllegalArgumentException
+        *             if the replacement of a referenced variable is not found
+        */
+       public static String replaceVariables(Properties variables, String value, boolean ignoreMissing)
+                       throws IllegalArgumentException {
+               StringBuilder result = new StringBuilder();
+
+               // Value:
+               // +--+-+--------+-+-----------------+
+               // | |p|--> |q|--> |
+               // +--+-+--------+-+-----------------+
+               int p = 0, q = value.indexOf("${"); // Find first ${
+               while (q != -1) {
+                       result.append(value.substring(p, q)); // Text before ${
+                       p = q;
+                       q = value.indexOf("}", q + 2); // Find }
+                       if (q != -1) {
+                               String variable = value.substring(p + 2, q);
+                               String replacement = variables.getProperty(variable);
+                               if (replacement == null) {
+                                       if (ignoreMissing) {
+                                               replacement = "";
+                                       } else {
+                                               throw new IllegalArgumentException("Replacement not found for ${" + variable + "}.");
+                                       }
+                               }
+                               result.append(replacement);
+                               p = q + 1;
+                               q = value.indexOf("${", p); // Find next ${
+                       }
+               }
+               result.append(value.substring(p, value.length())); // Trailing text
+
+               return result.toString();
+       }
+
+       private static byte decodeDigit(byte b) {
+               if (b >= 0x30 && b <= 0x39) {
+                       return (byte) (b - 0x30);
+               } else if (b >= 0x41 && b <= 0x46) {
+                       return (byte) (b - 0x37);
+               } else if (b >= 0x61 && b <= 0x66) {
+                       return (byte) (b - 0x57);
+               } else {
+                       throw new IllegalArgumentException("Escape sequence is not hexadecimal: " + (char) b);
+               }
+       }
+
+}