/* * Capsule * Copyright (c) 2014-2015, Parallel Universe Software Co. All rights reserved. * * This program and the accompanying materials are licensed under the terms * of the Eclipse Public License v1.0, available at * http://www.eclipse.org/legal/epl-v10.html */ package co.paralleluniverse.capsule; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.Charset; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarInputStream; import java.util.jar.JarOutputStream; import java.util.jar.Manifest; import java.util.jar.Pack200; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import static java.nio.charset.StandardCharsets.UTF_8; import java.util.Collection; import java.util.regex.Pattern; import java.util.zip.ZipException; /** * A JAR file that can be easily modified. * This class is not thread-safe. */ public class Jar { private static final String MANIFEST_NAME = "META-INF/MANIFEST.MF"; // java.util.jar.JarFile.MANIFEST_NAME private static final String ATTR_MANIFEST_VERSION = "Manifest-Version"; private OutputStream os; private final Manifest manifest; private final JarInputStream jis; private JarOutputStream jos; private Pack200.Packer packer; private String jarPrefixStr; private Path jarPrefixFile; private boolean sealed; //<editor-fold defaultstate="collapsed" desc="Constructors"> /////////// Constructors /////////////////////////////////// /** * Creates a new, empty, JAR */ public Jar() { this.jis = null; this.manifest = new Manifest(); } /** * Reads in the JAR from the given {@code InputStream}. * Modifications will not be made to the original JAR file, but to a new copy, which is then written with {@link #write(OutputStream) write()}. */ public Jar(InputStream jar) throws IOException { this.jis = jar instanceof JarInputStream ? (JarInputStream) jar : newJarInputStream(jar); this.manifest = new Manifest(jis.getManifest()); } /** * Reads in the JAR from the given {@code Path}. * Modifications will not be made to the original JAR file, but to a new copy, which is then written with {@link #write(OutputStream) write()}. */ public Jar(Path jar) throws IOException { this.jis = newJarInputStream(Files.newInputStream(jar)); this.manifest = new Manifest(jis.getManifest()); } /** * Reads in the JAR from the given {@code File}. * Modifications will not be made to the original JAR file, but to a new copy, which is then written with {@link #write(OutputStream) write()}. */ public Jar(File jar) throws IOException { this(jar.toPath()); } /** * Reads in the JAR from the given path. * Modifications will not be made to the original JAR file, but to a new copy, which is then written with {@link #write(OutputStream) write()}. */ public Jar(String jar) throws IOException { this(Paths.get(jar)); } /** * Creates a copy */ public Jar(Jar jar) { this.jis = jar.jis; this.manifest = new Manifest(jar.manifest); } //</editor-fold> //<editor-fold defaultstate="collapsed" desc="Manifest"> /////////// Manifest /////////////////////////////////// /** * Returns the manifest of this JAR. Modifications to the manifest will be reflected in the written JAR, provided they are done * before any entries are added with {@code addEntry()}. */ public Manifest getManifest() { return manifest; } /** * Sets an attribute in the main section of the manifest. * * @param name the attribute's name * @param value the attribute's value * @return {@code this} * @throws IllegalStateException if entries have been added or the JAR has been written prior to calling this methods. */ public final Jar setAttribute(String name, String value) { verifyNotSealed(); if (jos != null) throw new IllegalStateException("Manifest cannot be modified after entries are added."); getManifest().getMainAttributes().putValue(name, value); return this; } /** * Sets an attribute in a non-main section of the manifest. * * @param section the section's name * @param name the attribute's name * @param value the attribute's value * @return {@code this} * @throws IllegalStateException if entries have been added or the JAR has been written prior to calling this methods. */ public final Jar setAttribute(String section, String name, String value) { verifyNotSealed(); if (jos != null) throw new IllegalStateException("Manifest cannot be modified after entries are added."); Attributes attr = getManifest().getAttributes(section); if (attr == null) { attr = new Attributes(); getManifest().getEntries().put(section, attr); } attr.putValue(name, value); return this; } /** * Sets an attribute in the main section of the manifest to a list. * The list elements will be joined with a single whitespace character. * * @param name the attribute's name * @param values the attribute's value * @return {@code this} * @throws IllegalStateException if entries have been added or the JAR has been written prior to calling this methods. */ public Jar setListAttribute(String name, Collection<?> values) { return setAttribute(name, join(values)); } /** * Sets an attribute in a non-main section of the manifest to a list. * The list elements will be joined with a single whitespace character. * * @param section the section's name * @param name the attribute's name * @param values the attribute's value * @return {@code this} * @throws IllegalStateException if entries have been added or the JAR has been written prior to calling this methods. */ public Jar setListAttribute(String section, String name, Collection<?> values) { return setAttribute(section, name, join(values)); } /** * Sets an attribute in the main section of the manifest to a map. * The map entries will be joined with a single whitespace character, and each key-value pair will be joined with a '='. * * @param name the attribute's name * @param values the attribute's value * @return {@code this} * @throws IllegalStateException if entries have been added or the JAR has been written prior to calling this methods. */ public Jar setMapAttribute(String name, Map<String, ?> values) { return setAttribute(name, join(values)); } /** * Sets an attribute in a non-main section of the manifest to a map. * The map entries will be joined with a single whitespace character, and each key-value pair will be joined with a '='. * * @param section the section's name * @param name the attribute's name * @param values the attribute's value * @return {@code this} * @throws IllegalStateException if entries have been added or the JAR has been written prior to calling this methods. */ public Jar setMapAttribute(String section, String name, Map<String, ?> values) { return setAttribute(section, name, join(values)); } /** * Returns an attribute's value from this JAR's manifest's main section. * * @param name the attribute's name */ public String getAttribute(String name) { return getManifest().getMainAttributes().getValue(name); } /** * Returns an attribute's value from a non-main section of this JAR's manifest. * * @param section the manifest's section * @param name the attribute's name */ public String getAttribute(String section, String name) { Attributes attr = getManifest().getAttributes(section); return attr != null ? attr.getValue(name) : null; } /** * Returns an attribute's list value from this JAR's manifest's main section. * The attributes string value will be split on whitespace into the returned list. * The returned list may be safely modified. * * @param name the attribute's name */ public List<String> getListAttribute(String name) { return split(getAttribute(name)); } /** * Returns an attribute's list value from a non-main section of this JAR's manifest. * The attributes string value will be split on whitespace into the returned list. * The returned list may be safely modified. * * @param section the manifest's section * @param name the attribute's name */ public List<String> getListAttribute(String section, String name) { return split(getAttribute(section, name)); } /** * Returns an attribute's map value from this JAR's manifest's main section. * The attributes string value will be split on whitespace into map entries, and each entry will be split on '=' to get the key-value pair. * The returned map may be safely modified. * * @param name the attribute's name */ public Map<String, String> getMapAttribute(String name, String defaultValue) { return mapSplit(getAttribute(name), defaultValue); } /** * Returns an attribute's map value from a non-main section of this JAR's manifest. * The attributes string value will be split on whitespace into map entries, and each entry will be split on '=' to get the key-value pair. * The returned map may be safely modified. * * @param section the manifest's section * @param name the attribute's name */ public Map<String, String> getMapAttribute(String section, String name, String defaultValue) { return mapSplit(getAttribute(section, name), defaultValue); } //</editor-fold> //<editor-fold defaultstate="collapsed" desc="Entries"> /////////// Entries /////////////////////////////////// /** * Adds an entry to this JAR. * * @param path the entry's path within the JAR * @param is the entry's content * @return {@code this} */ public Jar addEntry(String path, InputStream is) throws IOException { beginWriting(); addEntry(jos, path, is); return this; } /** * Adds an entry to this JAR. * * @param path the entry's path within the JAR * @param content the entry's content * @return {@code this} */ public Jar addEntry(String path, byte[] content) throws IOException { beginWriting(); addEntry(jos, path, content); return this; } /** * Adds an entry to this JAR. * * @param path the entry's path within the JAR * @param is the entry's content * @return {@code this} */ public Jar addEntry(Path path, InputStream is) throws IOException { return addEntry(path != null ? path.toString() : "", is); } /** * Adds an entry to this JAR. * * @param path the entry's path within the JAR * @param file the file to add as an entry * @return {@code this} */ public Jar addEntry(Path path, Path file) throws IOException { return addEntry(path, Files.newInputStream(file)); } /** * Adds an entry to this JAR. * * @param path the entry's path within the JAR * @param file the path of the file to add as an entry * @return {@code this} */ public Jar addEntry(Path path, String file) throws IOException { return addEntry(path, Paths.get(file)); } /** * Adds an entry to this JAR. * * @param path the entry's path within the JAR * @param file the file to add as an entry * @return {@code this} */ public Jar addEntry(String path, File file) throws IOException { try (FileInputStream fos = new FileInputStream(file)) { return addEntry(path, fos); } } /** * Adds an entry to this JAR. * * @param path the entry's path within the JAR * @param file the path of the file to add as an entry * @return {@code this} */ public Jar addEntry(String path, String file) throws IOException { try (FileInputStream fos = new FileInputStream(file)) { return addEntry(path, fos); } } /** * Adds a class entry to this JAR. * * @param clazz the class to add to the JAR. * @return {@code this} */ public Jar addClass(Class<?> clazz) throws IOException { final String resource = clazz.getName().replace('.', '/') + ".class"; return addEntry(resource, clazz.getClassLoader().getResourceAsStream(resource)); } /** * Adds a directory (with all its subdirectories) or the contents of a zip/JAR to this JAR. * * @param path the path within the JAR where the root of the directory will be placed, or {@code null} for the JAR's root * @param dirOrZip the directory to add as an entry or a zip/JAR file whose contents will be extracted and added as entries * @param filter a filter to select particular classes * @return {@code this} */ public Jar addEntries(Path path, Path dirOrZip, Filter filter) throws IOException { if (Files.isDirectory(dirOrZip)) addDir(path, dirOrZip, filter, true); else { try (JarInputStream jis1 = newJarInputStream(Files.newInputStream(dirOrZip))) { addEntries(path, jis1, filter); } } return this; } /** * Adds a directory (with all its subdirectories) or the contents of a zip/JAR to this JAR. * * @param path the path within the JAR where the root of the directory will be placed, or {@code null} for the JAR's root * @param dirOrZip the directory to add as an entry or a zip/JAR file whose contents will be extracted and added as entries * @return {@code this} */ public Jar addEntries(Path path, Path dirOrZip) throws IOException { return addEntries(path, dirOrZip, null); } /** * Adds a directory (with all its subdirectories) or the contents of a zip/JAR to this JAR. * * @param path the path within the JAR where the root of the directory/zip will be placed, or {@code null} for the JAR's root * @param dirOrZip the directory to add as an entry or a zip/JAR file whose contents will be extracted and added as entries * @param filter a filter to select particular classes * @return {@code this} */ public Jar addEntries(String path, Path dirOrZip, Filter filter) throws IOException { return addEntries(path != null ? Paths.get(path) : null, dirOrZip, filter); } /** * Adds a directory (with all its subdirectories) or the contents of a zip/JAR to this JAR. * * @param path the path within the JAR where the root of the directory/zip will be placed, or {@code null} for the JAR's root * @param dirOrZip the directory to add as an entry or a zip/JAR file whose contents will be extracted and added as entries * @return {@code this} */ public Jar addEntries(String path, Path dirOrZip) throws IOException { return addEntries(path, dirOrZip, null); } /** * Adds the contents of the zip/JAR contained in the given byte array to this JAR. * * @param path the path within the JAR where the root of the zip will be placed, or {@code null} for the JAR's root * @param zip the contents of the zip/JAR file * @return {@code this} */ public Jar addEntries(Path path, ZipInputStream zip) throws IOException { return addEntries(path, zip, null); } /** * Adds the contents of the zip/JAR contained in the given byte array to this JAR. * * @param path the path within the JAR where the root of the zip will be placed, or {@code null} for the JAR's root * @param zip the contents of the zip/JAR file * @param filter a filter to select particular classes * @return {@code this} */ public Jar addEntries(Path path, ZipInputStream zip, Filter filter) throws IOException { beginWriting(); try (ZipInputStream zis = zip) { for (ZipEntry entry; (entry = zis.getNextEntry()) != null;) { final String target = path != null ? path.resolve(entry.getName()).toString() : entry.getName(); if (target.equals(MANIFEST_NAME)) continue; if (filter == null || filter.filter(target)) addEntryNoClose(jos, target, zis); } } return this; } /** * Adds the contents of a Java package to this JAR. * * @param clazz A class whose package we wish to add to the JAR. * @return {@code this} */ public Jar addPackageOf(Class<?> clazz) throws IOException { return addPackageOf(clazz, null); } /** * Adds the contents of a Java package to this JAR. * * @param clazz a class whose package we wish to add to the JAR. * @param filter a filter to select particular classes * @return {@code this} */ public Jar addPackageOf(Class<?> clazz, Filter filter) throws IOException { try { final String path = clazz.getPackage().getName().replace('.', '/'); URL dirURL = clazz.getClassLoader().getResource(path); if (dirURL != null && dirURL.getProtocol().equals("file")) addDir(Paths.get(path), Paths.get(dirURL.toURI()), filter, false); else { if (dirURL == null) // In case of a jar file, we can't actually find a directory. dirURL = clazz.getClassLoader().getResource(clazz.getName().replace('.', '/') + ".class"); if (dirURL.getProtocol().equals("jar")) { final URI jarUri = new URI(dirURL.getPath().substring(0, dirURL.getPath().indexOf('!'))); try (JarInputStream jis1 = newJarInputStream(Files.newInputStream(Paths.get(jarUri)))) { for (JarEntry entry; (entry = jis1.getNextJarEntry()) != null;) { try { if (entry.getName().startsWith(path + '/')) { if (filter == null || filter.filter(entry.getName())) addEntryNoClose(jos, entry.getName(), jis1); } } catch (ZipException e) { if (!e.getMessage().startsWith("duplicate entry")) throw e; } } } } else throw new AssertionError(); } return this; } catch (URISyntaxException e) { throw new AssertionError(e); } } private void addDir(final Path path, final Path dir1, final Filter filter, final boolean recursive) throws IOException { final Path dir = dir1.toAbsolutePath(); Files.walkFileTree(dir, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) throws IOException { return (recursive || dir.equals(d)) ? FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { final Path p = dir.relativize(file.toAbsolutePath()); final Path target = path != null ? path.resolve(p.toString()) : p; if (!target.toString().equals(MANIFEST_NAME)) { if (filter == null || filter.filter(target.toString())) addEntry(target, file); } return FileVisitResult.CONTINUE; } }); } private static void addEntry(JarOutputStream jarOut, String path, InputStream is) throws IOException { jarOut.putNextEntry(new JarEntry(path)); copy(is, jarOut); jarOut.closeEntry(); } private static void addEntryNoClose(JarOutputStream jarOut, String path, InputStream is) throws IOException { jarOut.putNextEntry(new JarEntry(path)); copy0(is, jarOut); jarOut.closeEntry(); } private static void addEntry(JarOutputStream jarOut, String path, byte[] data) throws IOException { jarOut.putNextEntry(new JarEntry(path)); jarOut.write(data); jarOut.flush(); jarOut.closeEntry(); } //</editor-fold> //<editor-fold defaultstate="collapsed" desc="Jar File Properties"> /////////// Jar File Properties /////////////////////////////////// /** * Sets a {@link Pack200} packer to use when writing the JAR. * * @param packer * @return {@code this} */ public Jar setPacker(Pack200.Packer packer) { this.packer = packer; return this; } /** * If set to true true, a header will be added to the JAR file when written, that will make the JAR an executable file in POSIX environments. * * @param value * @return {@code this} */ public Jar setReallyExecutable(boolean value) { setJarPrefix(value ? "#!/bin/sh\n\nexec java -jar $0 \"$@\"\n" : null); return this; } /** * Sets a string that will be prepended to the JAR file's data. * * @param value the prefix, or {@code null} for none. * @return {@code this} */ public Jar setJarPrefix(String value) { verifyNotSealed(); if (jos != null) throw new IllegalStateException("Really executable cannot be set after entries are added."); if (value != null && jarPrefixFile != null) throw new IllegalStateException("A prefix has already been set (" + jarPrefixFile + ")"); this.jarPrefixStr = value; return this; } /** * Sets a file whose contents will be prepended to the JAR file's data. * * @param file the prefix file, or {@code null} for none. * @return {@code this} */ public Jar setJarPrefix(Path file) { verifyNotSealed(); if (jos != null) throw new IllegalStateException("Really executable cannot be set after entries are added."); if (file != null && jarPrefixStr != null) throw new IllegalStateException("A prefix has already been set (" + jarPrefixStr + ")"); this.jarPrefixFile = file; return this; } //</editor-fold> //<editor-fold defaultstate="collapsed" desc="Writing"> /////////// Writing /////////////////////////////////// /** * Sets an {@link OutputStream} to which the JAR will be written. * If used, this method must be called before any entries have been added or the JAR written. Calling this method prevents this * object from using an internal buffer to store the JAR, and therefore, none of the other {@code write} methods can be called. * * @param os the target OutputStream of this JAR. * @return {@code this} */ public Jar setOutputStream(OutputStream os) { if (os == null) throw new NullPointerException("The OutputStream is null"); if (jos != null) throw new IllegalStateException("Entries have already been added, the JAR has been written or setOutputStream has already been called."); this.os = os; return this; } /** * Same as {@link #setOutputStream(OutputStream) setOutputStream(Files.newOutputStream(out))}. * If used, this method must be called before any entries have been added or the JAR written. Calling this method prevents this * object from using an internal buffer to store the JAR, and therefore, none of the other {@code write} methods can be called. * * @param out the target file to which this JAR will be written. * @return {@code this} */ public Jar setOutput(Path out) throws IOException { return setOutputStream(Files.newOutputStream(out)); } /** * Same as {@link #setOutputStream(OutputStream) setOutputStream(new FileOutputStream(out))}. * If used, this method must be called before any entries have been added or the JAR written. Calling this method prevents this * object from using an internal buffer to store the JAR, and therefore, none of the other {@code write} methods can be called. * * @param out the target file to which this JAR will be written. * @return {@code this} */ public Jar setOutput(File out) throws IOException { return setOutputStream(new FileOutputStream(out)); } private void beginWriting() throws IOException { verifyNotSealed(); if (jos != null) return; if (os == null) this.os = new ByteArrayOutputStream(); writePrefix(os); if (getAttribute(ATTR_MANIFEST_VERSION) == null) setAttribute(ATTR_MANIFEST_VERSION, "1.0"); jos = new JarOutputStream(os, manifest); if (jis != null) addEntries(null, jis); } private void writePrefix(OutputStream os) throws IOException { if (jarPrefixStr != null) { final Writer out = new OutputStreamWriter(os, UTF_8); out.write(jarPrefixStr); out.flush(); } else if (jarPrefixFile != null) Files.copy(jarPrefixFile, os); if (jarPrefixStr != null || jarPrefixFile != null) { os.write('\n'); os.flush(); } } public Jar close() throws IOException { if (sealed) return this; beginWriting(); // writeManifest(); - some JDK Jar classes (like JarInputStream) assume that the manifest must be the first entry jos.close(); this.sealed = true; return this; } /** * Writes this JAR to an output stream, and closes the stream. */ public <T extends OutputStream> T write(T os) throws IOException { close(); if (!(this.os instanceof ByteArrayOutputStream)) throw new IllegalStateException("Cannot write to another target if setOutputStream has been called"); final byte[] content = ((ByteArrayOutputStream) this.os).toByteArray(); if (packer != null) packer.pack(new JarInputStream(new ByteArrayInputStream(content)), os); else os.write(content); os.close(); return os; } private void verifyNotSealed() { if (sealed) throw new IllegalStateException("This JAR has been sealed (when it was written)"); } /** * Writes this JAR to a file. */ public File write(File file) throws IOException { try (FileOutputStream fos = new FileOutputStream(file)) { write(fos); return file; } } /** * Writes this JAR to a file. */ public Path write(Path path) throws IOException { write(Files.newOutputStream(path)); return path; } /** * Writes this JAR to a file. */ public void write(String file) throws IOException { write(Paths.get(file)); } /** * Returns this JAR file as an array of bytes. */ public byte[] toByteArray() { try { return write(new ByteArrayOutputStream()).toByteArray(); } catch (IOException e) { throw new AssertionError(); } } //</editor-fold> /** * Turns a {@code String} into an {@code InputStream} containing the string's encoded characters. * * @param str the string * @param charset the {@link Charset} to use when encoding the string. * @return an {@link InputStream} containing the string's encoded characters. */ public static InputStream toInputStream(String str, Charset charset) { return new ByteArrayInputStream(str.getBytes(charset)); } //<editor-fold defaultstate="collapsed" desc="Filter"> /////////// Filter /////////////////////////////////// public static interface Filter { boolean filter(String entryName); } public static Filter matches(String regex) { final Pattern p = Pattern.compile(regex); return new Filter() { @Override public boolean filter(String entryName) { return p.matcher(entryName).matches(); } }; } public static Filter notMatches(String regex) { final Filter f = matches(regex); return new Filter() { @Override public boolean filter(String entryName) { return !f.filter(entryName); } }; } //</editor-fold> //<editor-fold defaultstate="collapsed" desc="Utils"> /////////// Utils /////////////////////////////////// private static JarInputStream newJarInputStream(InputStream in) throws IOException { return new co.paralleluniverse.common.JarInputStream(in); } private static ZipInputStream newZipInputStream(InputStream in) throws IOException { return new co.paralleluniverse.common.ZipInputStream(in); } private static void copy(InputStream is, OutputStream os) throws IOException { try { copy0(is, os); } finally { is.close(); } } private static void copy0(InputStream is, OutputStream os) throws IOException { final byte[] buffer = new byte[1024]; for (int bytesRead; (bytesRead = is.read(buffer)) != -1;) os.write(buffer, 0, bytesRead); os.flush(); } private static String join(Collection<?> list, String separator) { if (list == null) return null; StringBuilder sb = new StringBuilder(); for (Object element : list) sb.append(element.toString()).append(separator); if (!list.isEmpty()) sb.delete(sb.length() - separator.length(), sb.length()); return sb.toString(); } private static String join(Map<String, ?> map, char kvSeparator, String separator) { if (map == null) return null; StringBuilder sb = new StringBuilder(); for (Map.Entry<String, ?> entry : map.entrySet()) sb.append(entry.getKey()).append(kvSeparator).append(entry.getValue().toString()).append(separator); if (!map.isEmpty()) sb.delete(sb.length() - separator.length(), sb.length()); return sb.toString(); } private static String join(Collection<?> list) { return join(list, " "); } private static String join(Map<String, ?> map) { return join(map, '=', " "); } private static List<String> split(String str, String separator) { if (str == null) return null; String[] es = str.split(separator); final List<String> list = new ArrayList<>(es.length); for (String e : es) { e = e.trim(); if (!e.isEmpty()) list.add(e); } return list; } private static List<String> split(String list) { return split(list, " "); } private static Map<String, String> split(String map, char kvSeparator, String separator, String defaultValue) { if (map == null) return null; Map<String, String> m = new HashMap<>(); for (String entry : split(map, separator)) { final String key = getBefore(entry, kvSeparator); String value = getAfter(entry, kvSeparator); if (value == null) { if (defaultValue != null) value = defaultValue; else throw new IllegalArgumentException("Element " + entry + " in \"" + map + "\" is not a key-value entry separated with " + kvSeparator + " and no default value provided"); } m.put(key, value); } return m; } private static Map<String, String> mapSplit(String map, String defaultValue) { return split(map, '=', " ", defaultValue); } private static String getBefore(String s, char separator) { final int i = s.indexOf(separator); if (i < 0) return s; return s.substring(0, i); } private static String getAfter(String s, char separator) { final int i = s.indexOf(separator); if (i < 0) return null; return s.substring(i + 1); } private static Path nullPath() { return null; } //</editor-fold> }