From: Mathieu Baudier Date: Thu, 16 Mar 2017 10:56:07 +0000 (+0100) Subject: Deal with illegal JCR characters X-Git-Tag: argeo-commons-2.1.65~2 X-Git-Url: http://git.argeo.org/?a=commitdiff_plain;h=cc04167de22122c17425975a86f5a2d7fae53c4f;p=lgpl%2Fargeo-commons.git Deal with illegal JCR characters --- diff --git a/org.argeo.jcr/ext/test/org/argeo/jcr/fs/JcrFileSystemTest.java b/org.argeo.jcr/ext/test/org/argeo/jcr/fs/JcrFileSystemTest.java index 2ad75a2aa..de2e8e828 100644 --- a/org.argeo.jcr/ext/test/org/argeo/jcr/fs/JcrFileSystemTest.java +++ b/org.argeo.jcr/ext/test/org/argeo/jcr/fs/JcrFileSystemTest.java @@ -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 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()); + } } diff --git a/org.argeo.jcr/src/org/argeo/jcr/fs/JcrFileSystemProvider.java b/org.argeo.jcr/src/org/argeo/jcr/fs/JcrFileSystemProvider.java index 17665a0a4..dcc59476f 100644 --- a/org.argeo.jcr/src/org/argeo/jcr/fs/JcrFileSystemProvider.java +++ b/org.argeo.jcr/src/org/argeo/jcr/fs/JcrFileSystemProvider.java @@ -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(); diff --git a/org.argeo.jcr/src/org/argeo/jcr/fs/JcrPath.java b/org.argeo.jcr/src/org/argeo/jcr/fs/JcrPath.java index 1f7943484..09b3e0e7a 100644 --- a/org.argeo.jcr/src/org/argeo/jcr/fs/JcrPath.java +++ b/org.argeo.jcr/src/org/argeo/jcr/fs/JcrPath.java @@ -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 index 000000000..4643c8c9c --- /dev/null +++ b/org.argeo.jcr/src/org/argeo/jcr/fs/Text.java @@ -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; + +/** + * Hacked from org.apache.jackrabbit.util.Text in Jackrabbit JCR Commons + * 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 true, 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 strings = new ArrayList(); + 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 oldString in text + * with newString. + * + * @param text + * @param oldString + * old substring to be replaced with newString + * @param newString + * new substring to replace occurrences of oldString + * @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("<"); + } else if (ch == '>') { + buf.append(">"); + } else if (ch == '&') { + buf.append("&"); + } else if (ch == '"') { + buf.append("""); + } else if (ch == '\'') { + buf.append(isHtml ? "'" : "'"); + } + } + 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 escape() + * and unescape() METHODS. They contains the characters as + * defined 'unreserved' in section 2.3 of the RFC 2396 'URI generic syntax': + *

+ * + *

+	 * unreserved  = alphanum | mark
+	 * mark        = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
+	 * 
+ */ + 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 string using the + * escape 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 string is null. + */ + public static String escape(String string, char escape) { + return escape(string, escape, false); + } + + /** + * Does an URL encoding of the string using the + * escape 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 isPath is + * true, additionally the slash '/' is ignored, too. + * + * @param string + * the string to encode. + * @param escape + * the escape character. + * @param isPath + * if true, the string is treated as path + * @return the escaped string + * @throws NullPointerException + * if string is null. + */ + 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 string. 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 string is null. + */ + public static String escape(String string) { + return escape(string, '%'); + } + + /** + * Does a URL encoding of the path. 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 path is null. + */ + public static String escapePath(String path) { + return escape(path, '%', true); + } + + /** + * Does a URL decoding of the string using the + * escape 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 string is null. + * @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 string. 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 string is null. + * @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. + *

+ * QName EBNF:
+ *

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 *) + * + * @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. + *

+ * QName EBNF:
+ *

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 *) + * + * @since Apache Jackrabbit 2.3.2 and 2.2.10 + * @see JCR-3128 + * @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. + *

+ * Example:
+ * 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. + *

+ * 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 null if path is + * null. + */ + public static String getName(String path) { + return getName(path, '/'); + } + + /** + * Returns the name part of the path, delimited by the given + * delim. If the given path is already a name (i.e. contains no + * delim characters) it is returned. + * + * @param path + * the path + * @param delim + * the delimiter + * @return the name part or null if path is + * null. + */ + 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 qname. If the + * prefix is missing, an empty string is returned. Please note, that this + * method does not validate the name or prefix. + *

+ * 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 qname is null + */ + public static String getNamespacePrefix(String qname) { + int pos = qname.indexOf(':'); + return pos >= 0 ? qname.substring(0, pos) : ""; + } + + /** + * Returns the local name of the given qname. Please note, that + * this method does not validate the name. + *

+ * the qname has the format: qname := [prefix ':'] local; + * + * @param qname + * a qualified name + * @return the localname + * + * @see #getNamespacePrefix(String) + * + * @throws NullPointerException + * if qname is null + */ + 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 descendant path is hierarchical a + * descendant of path. + * + * @param path + * the current path + * @param descendant + * the potential descendant + * @return true if the descendant is a descendant; + * false 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 descendant path is hierarchical a + * descendant of path or equal to it. + * + * @param path + * the path to check + * @param descendant + * the potential descendant + * @return true if the descendant is a descendant + * or equal; false 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 nth relative parent of the path, where n=level. + *

+ * Example:
+ * + * Text.getRelativeParent("/foo/bar/test", 1) == "/foo/bar" + * + * + * @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 nth absolute parent of the path, where n=level. + *

+ * Example:
+ * + * Text.getAbsoluteParent("/foo/bar/test", 1) == "/foo/bar" + * + * + * @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 + * ${...} 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 + * ignoreMissing is true. In the later case, the + * missing variable is replaced by the empty string. + * + * @param value + * the original value + * @param ignoreMissing + * if true, 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); + } + } + +}