diff --git a/build.gradle b/build.gradle index c0fdcba..4856d6e 100644 --- a/build.gradle +++ b/build.gradle @@ -11,12 +11,6 @@ allprojects { apply plugin: 'net.woggioni.gradle.lombok' repositories { - maven { - url = woggioniMavenRepositoryUrl - content { - includeModule 'net.woggioni', 'xclassloader' - } - } mavenCentral() } diff --git a/common/src/main/java/net/woggioni/envelope/Constants.java b/common/src/main/java/net/woggioni/envelope/Constants.java index 4b19820..ff35a1b 100644 --- a/common/src/main/java/net/woggioni/envelope/Constants.java +++ b/common/src/main/java/net/woggioni/envelope/Constants.java @@ -15,6 +15,8 @@ public class Constants { public static final String JAVA_AGENTS_FILE = METADATA_FOLDER + "/javaAgents.properties"; public static final String SYSTEM_PROPERTIES_FILE = METADATA_FOLDER + "/system.properties"; + public static final String LIBRARIES_TOC = METADATA_FOLDER + "/libraries.txt"; + public static class ManifestAttributes { public static final String MAIN_MODULE = "Executable-Jar-Main-Module"; public static final String MAIN_CLASS = "Executable-Jar-Main-Class"; diff --git a/gradle.properties b/gradle.properties index 942af9d..2203ccc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,5 @@ lys-gradle-plugins.version = 2022.06 version.envelope=2022.06 version.gradle=7.4.2 version.lombok=1.18.22 -version.xclassloader=1.0-SNAPSHOT version.junitJupiter=5.7.2 version.junitPlatform=1.7.0 diff --git a/launcher/build.gradle b/launcher/build.gradle index 7bb831b..9174927 100644 --- a/launcher/build.gradle +++ b/launcher/build.gradle @@ -22,7 +22,7 @@ configurations { dependencies { embedded project(path: ":common", configuration: 'archives') - embedded group: "net.woggioni", name: "xclassloader", version: getProperty("version.xclassloader") + embedded project(path: ":loader", configuration: 'archives') } java { diff --git a/launcher/src/main/java/net/woggioni/envelope/Launcher.java b/launcher/src/main/java/net/woggioni/envelope/Launcher.java index ad24a9d..1c9471e 100644 --- a/launcher/src/main/java/net/woggioni/envelope/Launcher.java +++ b/launcher/src/main/java/net/woggioni/envelope/Launcher.java @@ -1,26 +1,36 @@ package net.woggioni.envelope; import lombok.SneakyThrows; -import net.woggioni.xclassloader.jar.JarFile; +import net.woggioni.envelope.loader.JarFile; import java.io.File; import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; import java.lang.reflect.Method; import java.net.URI; import java.net.URL; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Enumeration; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Properties; import java.util.function.Consumer; import java.util.jar.Attributes; +import java.util.jar.JarEntry; import java.util.jar.Manifest; import static java.util.jar.JarFile.MANIFEST_NAME; public class Launcher { + @SneakyThrows + static URL getURL(JarFile jarFile) { + return jarFile.getUrl(); + } + @SneakyThrows private static JarFile findCurrentJar() { String launcherClassName = Launcher.class.getName(); @@ -86,7 +96,25 @@ public class Launcher { String mainClassName = mainAttributes.getValue(Constants.ManifestAttributes.MAIN_CLASS); String mainModuleName = mainAttributes.getValue(Constants.ManifestAttributes.MAIN_MODULE); - + StringBuilder sb = new StringBuilder(); + List classpath = new ArrayList<>(); + URL libraryTocResource = Launcher.class.getClassLoader().getResource(Constants.LIBRARIES_TOC); + if(libraryTocResource == null) throw new RuntimeException( + Constants.LIBRARIES_TOC + " not found"); + try(Reader reader = new InputStreamReader(libraryTocResource.openStream())) { + while(true) { + int c = reader.read(); + boolean entryEnd = c == '/' || c < 0; + if(entryEnd) { + String entryName = Constants.LIBRARIES_FOLDER + '/' + sb; + JarEntry entry = currentJar.getJarEntry(entryName); + classpath.add(currentJar.getNestedJarFile(entry)); + sb.setLength(0); + if(c < 0) break; + } + else sb.append((char) c); + } + } Consumer> runner = new Consumer>() { @Override @SneakyThrows @@ -108,7 +136,7 @@ public class Launcher { currentJar, mainModuleName, mainClassName, - Constants.LIBRARIES_FOLDER, + classpath, runner); } diff --git a/launcher/src/main/java/net/woggioni/envelope/MainRunner.java b/launcher/src/main/java/net/woggioni/envelope/MainRunner.java index 065cfd4..3a7a786 100644 --- a/launcher/src/main/java/net/woggioni/envelope/MainRunner.java +++ b/launcher/src/main/java/net/woggioni/envelope/MainRunner.java @@ -1,33 +1,23 @@ package net.woggioni.envelope; import lombok.SneakyThrows; -import net.woggioni.xclassloader.jar.JarFile; +import net.woggioni.envelope.loader.JarFile; import java.net.URL; import java.net.URLClassLoader; -import java.util.ArrayList; -import java.util.Enumeration; import java.util.List; import java.util.function.Consumer; -import java.util.jar.JarEntry; +import java.util.stream.Collectors; class MainRunner { @SneakyThrows static void run(JarFile currentJarFile, - String mainModuleName, - String mainClassName, - String librariesFolder, - Consumer> runner) { - List jarList = new ArrayList<>(); - Enumeration entries = currentJarFile.entries(); - while(entries.hasMoreElements()) { - JarEntry entry = entries.nextElement(); - String name = entry.getName(); - if(!entry.isDirectory() && name.startsWith(librariesFolder) && name.endsWith(".jar")) { - jarList.add(currentJarFile.getNestedJarFile(entry).getUrl()); - } - } - try (URLClassLoader cl = new URLClassLoader(jarList.toArray(new URL[0]), ClassLoader.getSystemClassLoader().getParent())) { + String mainModuleName, + String mainClassName, + List classpath, + Consumer> runner) { + URL[] urls = classpath.stream().map(Launcher::getURL).toArray(URL[]::new); + try (URLClassLoader cl = new URLClassLoader(urls, ClassLoader.getSystemClassLoader().getParent())) { Thread.currentThread().setContextClassLoader(cl); runner.accept(cl.loadClass(mainClassName)); } diff --git a/launcher/src/main/java11/module-info.java b/launcher/src/main/java11/module-info.java index 34c8c1f..1e8f4d2 100644 --- a/launcher/src/main/java11/module-info.java +++ b/launcher/src/main/java11/module-info.java @@ -1,6 +1,6 @@ module net.woggioni.envelope { requires java.logging; requires static lombok; - requires net.woggioni.xclassloader; + requires net.woggioni.envelope.loader; requires java.instrument; } \ No newline at end of file diff --git a/launcher/src/main/java11/net/woggioni/envelope/MainRunner.java b/launcher/src/main/java11/net/woggioni/envelope/MainRunner.java index a581b06..b32f7d4 100644 --- a/launcher/src/main/java11/net/woggioni/envelope/MainRunner.java +++ b/launcher/src/main/java11/net/woggioni/envelope/MainRunner.java @@ -19,9 +19,9 @@ import java.net.URLClassLoader; import lombok.SneakyThrows; -import net.woggioni.xclassloader.ModuleClassLoader; -import net.woggioni.xclassloader.JarFileModuleFinder; -import net.woggioni.xclassloader.jar.JarFile; +import net.woggioni.envelope.loader.ModuleClassLoader; +import net.woggioni.envelope.loader.JarFileModuleFinder; +import net.woggioni.envelope.loader.JarFile; import java.util.jar.JarEntry; class MainRunner { @@ -34,35 +34,18 @@ class MainRunner { static void run(JarFile currentJarFile, String mainModuleName, String mainClassName, - String librariesFolder, + List classpath, Consumer> runner) { if(mainModuleName == null) { - List jarList = new ArrayList<>(); - Enumeration entries = currentJarFile.entries(); - while(entries.hasMoreElements()) { - JarEntry entry = entries.nextElement(); - String name = entry.getName(); - if(!entry.isDirectory() && name.startsWith(librariesFolder) && name.endsWith(".jar")) { - jarList.add(currentJarFile.getNestedJarFile(entry).getUrl()); - } - } - try (URLClassLoader cl = new URLClassLoader(jarList.toArray(new URL[0]), ClassLoader.getSystemClassLoader().getParent())) { + URL[] urls = classpath.stream().map(Launcher::getURL).toArray(URL[]::new); + try (URLClassLoader cl = new URLClassLoader(urls, ClassLoader.getSystemClassLoader().getParent())) { Thread.currentThread().setContextClassLoader(cl); runner.accept(cl.loadClass(mainClassName)); } } else { - List jarList = new ArrayList<>(); - Enumeration entries = currentJarFile.entries(); - while(entries.hasMoreElements()) { - JarEntry entry = entries.nextElement(); - String name = entry.getName(); - if(!entry.isDirectory() && name.startsWith("LIB-INF") && name.endsWith(".jar")) { - jarList.add(currentJarFile.getNestedJarFile(entry)); - } - } ModuleLayer bootLayer = ModuleLayer.boot(); Configuration bootConfiguration = bootLayer.configuration(); - JarFileModuleFinder jarFileModuleFinder = new JarFileModuleFinder(jarList); + JarFileModuleFinder jarFileModuleFinder = new JarFileModuleFinder(classpath); Configuration cfg = bootConfiguration.resolve(jarFileModuleFinder, ModuleFinder.of(), Collections.singletonList(mainModuleName)); Map packageMap = new TreeMap<>(); ModuleLayer.Controller controller = diff --git a/loader/build.gradle b/loader/build.gradle new file mode 100644 index 0000000..4e2f8bc --- /dev/null +++ b/loader/build.gradle @@ -0,0 +1,7 @@ +plugins { + id 'net.woggioni.gradle.multi-release-jar' +} + +ext { + setProperty('jpms.module.name', 'net.woggioni.envelope.loader') +} \ No newline at end of file diff --git a/loader/src/main/java/net/woggioni/envelope/loader/AbstractJarFile.java b/loader/src/main/java/net/woggioni/envelope/loader/AbstractJarFile.java new file mode 100644 index 0000000..3bed58b --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/AbstractJarFile.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.Permission; + +/** + * Base class for extended variants of {@link java.util.jar.JarFile}. + * + * @author Phillip Webb + */ +abstract class AbstractJarFile extends java.util.jar.JarFile { + + /** + * Create a new {@link AbstractJarFile}. + * @param file the root jar file. + * @throws IOException on IO error + */ + AbstractJarFile(File file) throws IOException { + super(file); + } + + /** + * Return a URL that can be used to access this JAR file. NOTE: the specified URL + * cannot be serialized and or cloned. + * @return the URL + * @throws MalformedURLException if the URL is malformed + */ + abstract URL getUrl() throws MalformedURLException; + + /** + * Return the {@link JarFileType} of this instance. + * @return the jar file type + */ + abstract JarFileType getType(); + + /** + * Return the security permission for this JAR. + * @return the security permission. + */ + abstract Permission getPermission(); + + /** + * Return an {@link InputStream} for the entire jar contents. + * @return the contents input stream + * @throws IOException on IO error + */ + abstract InputStream getInputStream() throws IOException; + + /** + * The type of a {@link JarFile}. + */ + enum JarFileType { + + DIRECT, NESTED_DIRECTORY, NESTED_JAR + + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/Bytes.java b/loader/src/main/java/net/woggioni/envelope/loader/Bytes.java new file mode 100644 index 0000000..71ab0db --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/Bytes.java @@ -0,0 +1,415 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Utilities for dealing with bytes from ZIP files. + * + * @author Phillip Webb + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class Bytes { + + static long littleEndianValue(byte[] bytes, int offset, int length) { + long value = 0; + for (int i = length - 1; i >= 0; i--) { + value = ((value << 8) | (bytes[offset + i] & 0xFF)); + } + return value; + } + + /** + * Simple wrapper around a byte array that represents an ASCII. Used for performance + * reasons to save constructing Strings for ZIP data. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ + static final class AsciiBytes { + + private static final String EMPTY_STRING = ""; + + private static final int[] INITIAL_BYTE_BITMASK = { 0x7F, 0x1F, 0x0F, 0x07 }; + + private static final int SUBSEQUENT_BYTE_BITMASK = 0x3F; + + private final byte[] bytes; + + private final int offset; + + private final int length; + + private String string; + + private int hash; + + /** + * Create a new {@link AsciiBytes} from the specified String. + * @param string the source string + */ + AsciiBytes(String string) { + this(string.getBytes(StandardCharsets.UTF_8)); + this.string = string; + } + + /** + * Create a new {@link AsciiBytes} from the specified bytes. NOTE: underlying bytes + * are not expected to change. + * @param bytes the source bytes + */ + AsciiBytes(byte[] bytes) { + this(bytes, 0, bytes.length); + } + + /** + * Create a new {@link AsciiBytes} from the specified bytes. NOTE: underlying bytes + * are not expected to change. + * @param bytes the source bytes + * @param offset the offset + * @param length the length + */ + AsciiBytes(byte[] bytes, int offset, int length) { + if (offset < 0 || length < 0 || (offset + length) > bytes.length) { + throw new IndexOutOfBoundsException(); + } + this.bytes = bytes; + this.offset = offset; + this.length = length; + } + + int length() { + return this.length; + } + + boolean startsWith(AsciiBytes prefix) { + if (this == prefix) { + return true; + } + if (prefix.length > this.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (this.bytes[i + this.offset] != prefix.bytes[i + prefix.offset]) { + return false; + } + } + return true; + } + + boolean endsWith(AsciiBytes postfix) { + if (this == postfix) { + return true; + } + if (postfix.length > this.length) { + return false; + } + for (int i = 0; i < postfix.length; i++) { + if (this.bytes[this.offset + (this.length - 1) - i] != postfix.bytes[postfix.offset + (postfix.length - 1) + - i]) { + return false; + } + } + return true; + } + + AsciiBytes substring(int beginIndex) { + return substring(beginIndex, this.length); + } + + AsciiBytes substring(int beginIndex, int endIndex) { + int length = endIndex - beginIndex; + if (this.offset + length > this.bytes.length) { + throw new IndexOutOfBoundsException(); + } + return new AsciiBytes(this.bytes, this.offset + beginIndex, length); + } + + boolean matches(CharSequence name, char suffix) { + int charIndex = 0; + int nameLen = name.length(); + int totalLen = nameLen + ((suffix != 0) ? 1 : 0); + for (int i = this.offset; i < this.offset + this.length; i++) { + int b = this.bytes[i]; + int remainingUtfBytes = getNumberOfUtfBytes(b) - 1; + b &= INITIAL_BYTE_BITMASK[remainingUtfBytes]; + for (int j = 0; j < remainingUtfBytes; j++) { + b = (b << 6) + (this.bytes[++i] & SUBSEQUENT_BYTE_BITMASK); + } + char c = getChar(name, suffix, charIndex++); + if (b <= 0xFFFF) { + if (c != b) { + return false; + } + } + else { + if (c != ((b >> 0xA) + 0xD7C0)) { + return false; + } + c = getChar(name, suffix, charIndex++); + if (c != ((b & 0x3FF) + 0xDC00)) { + return false; + } + } + } + return charIndex == totalLen; + } + + private char getChar(CharSequence name, char suffix, int index) { + if (index < name.length()) { + return name.charAt(index); + } + if (index == name.length()) { + return suffix; + } + return 0; + } + + private int getNumberOfUtfBytes(int b) { + if ((b & 0x80) == 0) { + return 1; + } + int numberOfUtfBytes = 0; + while ((b & 0x80) != 0) { + b <<= 1; + numberOfUtfBytes++; + } + return numberOfUtfBytes; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (this == obj) { + return true; + } + if (obj.getClass() == AsciiBytes.class) { + AsciiBytes other = (AsciiBytes) obj; + if (this.length == other.length) { + for (int i = 0; i < this.length; i++) { + if (this.bytes[this.offset + i] != other.bytes[other.offset + i]) { + return false; + } + } + return true; + } + } + return false; + } + + @Override + public int hashCode() { + int hash = this.hash; + if (hash == 0 && this.bytes.length > 0) { + for (int i = this.offset; i < this.offset + this.length; i++) { + int b = this.bytes[i]; + int remainingUtfBytes = getNumberOfUtfBytes(b) - 1; + b &= INITIAL_BYTE_BITMASK[remainingUtfBytes]; + for (int j = 0; j < remainingUtfBytes; j++) { + b = (b << 6) + (this.bytes[++i] & SUBSEQUENT_BYTE_BITMASK); + } + if (b <= 0xFFFF) { + hash = 31 * hash + b; + } + else { + hash = 31 * hash + ((b >> 0xA) + 0xD7C0); + hash = 31 * hash + ((b & 0x3FF) + 0xDC00); + } + } + this.hash = hash; + } + return hash; + } + + @Override + public String toString() { + if (this.string == null) { + if (this.length == 0) { + this.string = EMPTY_STRING; + } + else { + this.string = new String(this.bytes, this.offset, this.length, StandardCharsets.UTF_8); + } + } + return this.string; + } + + static String toString(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + static int hashCode(CharSequence charSequence) { + // We're compatible with String's hashCode() + if (charSequence instanceof StringSequence) { + // ... but save making an unnecessary String for StringSequence + return charSequence.hashCode(); + } + return charSequence.toString().hashCode(); + } + + static int hashCode(int hash, char suffix) { + return (suffix != 0) ? (31 * hash + suffix) : hash; + } + + } + + /** + * A {@link CharSequence} backed by a single shared {@link String}. Unlike a regular + * {@link String}, {@link #subSequence(int, int)} operations will not copy the underlying + * character array. + * + * @author Phillip Webb + */ + static final class StringSequence implements CharSequence { + + private final String source; + + private final int start; + + private final int end; + + private int hash; + + StringSequence(String source) { + this(source, 0, (source != null) ? source.length() : -1); + } + + StringSequence(String source, int start, int end) { + Objects.requireNonNull(source, "Source must not be null"); + if (start < 0) { + throw new StringIndexOutOfBoundsException(start); + } + if (end > source.length()) { + throw new StringIndexOutOfBoundsException(end); + } + this.source = source; + this.start = start; + this.end = end; + } + + StringSequence subSequence(int start) { + return subSequence(start, length()); + } + + @Override + public StringSequence subSequence(int start, int end) { + int subSequenceStart = this.start + start; + int subSequenceEnd = this.start + end; + if (subSequenceStart > this.end) { + throw new StringIndexOutOfBoundsException(start); + } + if (subSequenceEnd > this.end) { + throw new StringIndexOutOfBoundsException(end); + } + if (start == 0 && subSequenceEnd == this.end) { + return this; + } + return new StringSequence(this.source, subSequenceStart, subSequenceEnd); + } + + /** + * Returns {@code true} if the sequence is empty. Public to be compatible with JDK 15. + * @return {@code true} if {@link #length()} is {@code 0}, otherwise {@code false} + */ + public boolean isEmpty() { + return length() == 0; + } + + @Override + public int length() { + return this.end - this.start; + } + + @Override + public char charAt(int index) { + return this.source.charAt(this.start + index); + } + + int indexOf(char ch) { + return this.source.indexOf(ch, this.start) - this.start; + } + + int indexOf(String str) { + return this.source.indexOf(str, this.start) - this.start; + } + + int indexOf(String str, int fromIndex) { + return this.source.indexOf(str, this.start + fromIndex) - this.start; + } + + boolean startsWith(String prefix) { + return startsWith(prefix, 0); + } + + boolean startsWith(String prefix, int offset) { + int prefixLength = prefix.length(); + int length = length(); + if (length - prefixLength - offset < 0) { + return false; + } + return this.source.startsWith(prefix, this.start + offset); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof CharSequence)) { + return false; + } + CharSequence other = (CharSequence) obj; + int n = length(); + if (n != other.length()) { + return false; + } + int i = 0; + while (n-- != 0) { + if (charAt(i) != other.charAt(i)) { + return false; + } + i++; + } + return true; + } + + @Override + public int hashCode() { + int hash = this.hash; + if (hash == 0 && length() > 0) { + for (int i = this.start; i < this.end; i++) { + hash = 31 * hash + this.source.charAt(i); + } + this.hash = hash; + } + return hash; + } + + @Override + public String toString() { + return this.source.substring(this.start, this.end); + } + + } +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryEndRecord.java b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryEndRecord.java new file mode 100644 index 0000000..841a764 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryEndRecord.java @@ -0,0 +1,256 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.IOException; + +/** + * A ZIP File "End of central directory record" (EOCD). + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Camille Vienot + * @see Zip File Format + */ +class CentralDirectoryEndRecord { + + private static final int MINIMUM_SIZE = 22; + + private static final int MAXIMUM_COMMENT_LENGTH = 0xFFFF; + + private static final int MAXIMUM_SIZE = MINIMUM_SIZE + MAXIMUM_COMMENT_LENGTH; + + private static final int SIGNATURE = 0x06054b50; + + private static final int COMMENT_LENGTH_OFFSET = 20; + + private static final int READ_BLOCK_SIZE = 256; + + private final Zip64End zip64End; + + private byte[] block; + + private int offset; + + private int size; + + /** + * Create a new {@link CentralDirectoryEndRecord} instance from the specified + * {@link RandomAccessData}, searching backwards from the end until a valid block is + * located. + * @param data the source data + * @throws IOException in case of I/O errors + */ + CentralDirectoryEndRecord(RandomAccessData data) throws IOException { + this.block = createBlockFromEndOfData(data, READ_BLOCK_SIZE); + this.size = MINIMUM_SIZE; + this.offset = this.block.length - this.size; + while (!isValid()) { + this.size++; + if (this.size > this.block.length) { + if (this.size >= MAXIMUM_SIZE || this.size > data.getSize()) { + throw new IOException( + "Unable to find ZIP central directory records after reading " + this.size + " bytes"); + } + this.block = createBlockFromEndOfData(data, this.size + READ_BLOCK_SIZE); + } + this.offset = this.block.length - this.size; + } + long startOfCentralDirectoryEndRecord = data.getSize() - this.size; + Zip64Locator zip64Locator = Zip64Locator.find(data, startOfCentralDirectoryEndRecord); + this.zip64End = (zip64Locator != null) ? new Zip64End(data, zip64Locator) : null; + } + + private byte[] createBlockFromEndOfData(RandomAccessData data, int size) throws IOException { + int length = (int) Math.min(data.getSize(), size); + return data.read(data.getSize() - length, length); + } + + private boolean isValid() { + if (this.block.length < MINIMUM_SIZE || Bytes.littleEndianValue(this.block, this.offset + 0, 4) != SIGNATURE) { + return false; + } + // Total size must be the structure size + comment + long commentLength = Bytes.littleEndianValue(this.block, this.offset + COMMENT_LENGTH_OFFSET, 2); + return this.size == MINIMUM_SIZE + commentLength; + } + + /** + * Returns the location in the data that the archive actually starts. For most files + * the archive data will start at 0, however, it is possible to have prefixed bytes + * (often used for startup scripts) at the beginning of the data. + * @param data the source data + * @return the offset within the data where the archive begins + */ + long getStartOfArchive(RandomAccessData data) { + long length = Bytes.littleEndianValue(this.block, this.offset + 12, 4); + long specifiedOffset = (this.zip64End != null) ? this.zip64End.centralDirectoryOffset + : Bytes.littleEndianValue(this.block, this.offset + 16, 4); + long zip64EndSize = (this.zip64End != null) ? this.zip64End.getSize() : 0L; + int zip64LocSize = (this.zip64End != null) ? Zip64Locator.ZIP64_LOCSIZE : 0; + long actualOffset = data.getSize() - this.size - length - zip64EndSize - zip64LocSize; + return actualOffset - specifiedOffset; + } + + /** + * Return the bytes of the "Central directory" based on the offset indicated in this + * record. + * @param data the source data + * @return the central directory data + */ + RandomAccessData getCentralDirectory(RandomAccessData data) { + if (this.zip64End != null) { + return this.zip64End.getCentralDirectory(data); + } + long offset = Bytes.littleEndianValue(this.block, this.offset + 16, 4); + long length = Bytes.littleEndianValue(this.block, this.offset + 12, 4); + return data.getSubsection(offset, length); + } + + /** + * Return the number of ZIP entries in the file. + * @return the number of records in the zip + */ + int getNumberOfRecords() { + if (this.zip64End != null) { + return this.zip64End.getNumberOfRecords(); + } + long numberOfRecords = Bytes.littleEndianValue(this.block, this.offset + 10, 2); + return (int) numberOfRecords; + } + + String getComment() { + int commentLength = (int) Bytes.littleEndianValue(this.block, this.offset + COMMENT_LENGTH_OFFSET, 2); + Bytes.AsciiBytes comment = new Bytes.AsciiBytes(this.block, this.offset + COMMENT_LENGTH_OFFSET + 2, commentLength); + return comment.toString(); + } + + boolean isZip64() { + return this.zip64End != null; + } + + /** + * A Zip64 end of central directory record. + * + * @see Chapter + * 4.3.14 of Zip64 specification + */ + private static final class Zip64End { + + private static final int ZIP64_ENDTOT = 32; // total number of entries + + private static final int ZIP64_ENDSIZ = 40; // central directory size in bytes + + private static final int ZIP64_ENDOFF = 48; // offset of first CEN header + + private final Zip64Locator locator; + + private final long centralDirectoryOffset; + + private final long centralDirectoryLength; + + private final int numberOfRecords; + + private Zip64End(RandomAccessData data, Zip64Locator locator) throws IOException { + this.locator = locator; + byte[] block = data.read(locator.getZip64EndOffset(), 56); + this.centralDirectoryOffset = Bytes.littleEndianValue(block, ZIP64_ENDOFF, 8); + this.centralDirectoryLength = Bytes.littleEndianValue(block, ZIP64_ENDSIZ, 8); + this.numberOfRecords = (int) Bytes.littleEndianValue(block, ZIP64_ENDTOT, 8); + } + + /** + * Return the size of this zip 64 end of central directory record. + * @return size of this zip 64 end of central directory record + */ + private long getSize() { + return this.locator.getZip64EndSize(); + } + + /** + * Return the bytes of the "Central directory" based on the offset indicated in + * this record. + * @param data the source data + * @return the central directory data + */ + private RandomAccessData getCentralDirectory(RandomAccessData data) { + return data.getSubsection(this.centralDirectoryOffset, this.centralDirectoryLength); + } + + /** + * Return the number of entries in the zip64 archive. + * @return the number of records in the zip + */ + private int getNumberOfRecords() { + return this.numberOfRecords; + } + + } + + /** + * A Zip64 end of central directory locator. + * + * @see Chapter + * 4.3.15 of Zip64 specification + */ + private static final class Zip64Locator { + + static final int SIGNATURE = 0x07064b50; + + static final int ZIP64_LOCSIZE = 20; // locator size + + static final int ZIP64_LOCOFF = 8; // offset of zip64 end + + private final long zip64EndOffset; + + private final long offset; + + private Zip64Locator(long offset, byte[] block) { + this.offset = offset; + this.zip64EndOffset = Bytes.littleEndianValue(block, ZIP64_LOCOFF, 8); + } + + /** + * Return the size of the zip 64 end record located by this zip64 end locator. + * @return size of the zip 64 end record located by this zip64 end locator + */ + private long getZip64EndSize() { + return this.offset - this.zip64EndOffset; + } + + /** + * Return the offset to locate {@link Zip64End}. + * @return offset of the Zip64 end of central directory record + */ + private long getZip64EndOffset() { + return this.zip64EndOffset; + } + + private static Zip64Locator find(RandomAccessData data, long centralDirectoryEndOffset) throws IOException { + long offset = centralDirectoryEndOffset - ZIP64_LOCSIZE; + if (offset >= 0) { + byte[] block = data.read(offset, ZIP64_LOCSIZE); + if (Bytes.littleEndianValue(block, 0, 4) == SIGNATURE) { + return new Zip64Locator(offset, block); + } + } + return null; + } + + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryFileHeader.java b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryFileHeader.java new file mode 100644 index 0000000..905f1ee --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryFileHeader.java @@ -0,0 +1,218 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.IOException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.ValueRange; + +/** + * A ZIP File "Central directory file header record" (CDFH). + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Dmytro Nosan + * @see Zip File Format + */ + +final class CentralDirectoryFileHeader implements FileHeader { + + private static final Bytes.AsciiBytes SLASH = new Bytes.AsciiBytes("/"); + + private static final byte[] NO_EXTRA = {}; + + private static final Bytes.AsciiBytes NO_COMMENT = new Bytes.AsciiBytes(""); + + private byte[] header; + + private int headerOffset; + + private Bytes.AsciiBytes name; + + private byte[] extra; + + private Bytes.AsciiBytes comment; + + private long localHeaderOffset; + + CentralDirectoryFileHeader() { + } + + CentralDirectoryFileHeader(byte[] header, int headerOffset, Bytes.AsciiBytes name, byte[] extra, Bytes.AsciiBytes comment, + long localHeaderOffset) { + this.header = header; + this.headerOffset = headerOffset; + this.name = name; + this.extra = extra; + this.comment = comment; + this.localHeaderOffset = localHeaderOffset; + } + + void load(byte[] data, int dataOffset, RandomAccessData variableData, long variableOffset, JarEntryFilter filter) + throws IOException { + // Load fixed part + this.header = data; + this.headerOffset = dataOffset; + long compressedSize = Bytes.littleEndianValue(data, dataOffset + 20, 4); + long uncompressedSize = Bytes.littleEndianValue(data, dataOffset + 24, 4); + long nameLength = Bytes.littleEndianValue(data, dataOffset + 28, 2); + long extraLength = Bytes.littleEndianValue(data, dataOffset + 30, 2); + long commentLength = Bytes.littleEndianValue(data, dataOffset + 32, 2); + long localHeaderOffset = Bytes.littleEndianValue(data, dataOffset + 42, 4); + // Load variable part + dataOffset += 46; + if (variableData != null) { + data = variableData.read(variableOffset + 46, nameLength + extraLength + commentLength); + dataOffset = 0; + } + this.name = new Bytes.AsciiBytes(data, dataOffset, (int) nameLength); + if (filter != null) { + this.name = filter.apply(this.name); + } + this.extra = NO_EXTRA; + this.comment = NO_COMMENT; + if (extraLength > 0) { + this.extra = new byte[(int) extraLength]; + System.arraycopy(data, (int) (dataOffset + nameLength), this.extra, 0, this.extra.length); + } + this.localHeaderOffset = getLocalHeaderOffset(compressedSize, uncompressedSize, localHeaderOffset, this.extra); + if (commentLength > 0) { + this.comment = new Bytes.AsciiBytes(data, (int) (dataOffset + nameLength + extraLength), (int) commentLength); + } + } + + private long getLocalHeaderOffset(long compressedSize, long uncompressedSize, long localHeaderOffset, byte[] extra) + throws IOException { + if (localHeaderOffset != 0xFFFFFFFFL) { + return localHeaderOffset; + } + int extraOffset = 0; + while (extraOffset < extra.length - 2) { + int id = (int) Bytes.littleEndianValue(extra, extraOffset, 2); + int length = (int) Bytes.littleEndianValue(extra, extraOffset, 2); + extraOffset += 4; + if (id == 1) { + int localHeaderExtraOffset = 0; + if (compressedSize == 0xFFFFFFFFL) { + localHeaderExtraOffset += 4; + } + if (uncompressedSize == 0xFFFFFFFFL) { + localHeaderExtraOffset += 4; + } + return Bytes.littleEndianValue(extra, extraOffset + localHeaderExtraOffset, 8); + } + extraOffset += length; + } + throw new IOException("Zip64 Extended Information Extra Field not found"); + } + + Bytes.AsciiBytes getName() { + return this.name; + } + + @Override + public boolean hasName(CharSequence name, char suffix) { + return this.name.matches(name, suffix); + } + + boolean isDirectory() { + return this.name.endsWith(SLASH); + } + + @Override + public int getMethod() { + return (int) Bytes.littleEndianValue(this.header, this.headerOffset + 10, 2); + } + + long getTime() { + long datetime = Bytes.littleEndianValue(this.header, this.headerOffset + 12, 4); + return decodeMsDosFormatDateTime(datetime); + } + + /** + * Decode MS-DOS Date Time details. See + * Microsoft's documentation for more details of the format. + * @param datetime the date and time + * @return the date and time as milliseconds since the epoch + */ + private long decodeMsDosFormatDateTime(long datetime) { + int year = getChronoValue(((datetime >> 25) & 0x7f) + 1980, ChronoField.YEAR); + int month = getChronoValue((datetime >> 21) & 0x0f, ChronoField.MONTH_OF_YEAR); + int day = getChronoValue((datetime >> 16) & 0x1f, ChronoField.DAY_OF_MONTH); + int hour = getChronoValue((datetime >> 11) & 0x1f, ChronoField.HOUR_OF_DAY); + int minute = getChronoValue((datetime >> 5) & 0x3f, ChronoField.MINUTE_OF_HOUR); + int second = getChronoValue((datetime << 1) & 0x3e, ChronoField.SECOND_OF_MINUTE); + return ZonedDateTime.of(year, month, day, hour, minute, second, 0, ZoneId.systemDefault()).toInstant() + .truncatedTo(ChronoUnit.SECONDS).toEpochMilli(); + } + + long getCrc() { + return Bytes.littleEndianValue(this.header, this.headerOffset + 16, 4); + } + + @Override + public long getCompressedSize() { + return Bytes.littleEndianValue(this.header, this.headerOffset + 20, 4); + } + + @Override + public long getSize() { + return Bytes.littleEndianValue(this.header, this.headerOffset + 24, 4); + } + + byte[] getExtra() { + return this.extra; + } + + boolean hasExtra() { + return this.extra.length > 0; + } + + Bytes.AsciiBytes getComment() { + return this.comment; + } + + @Override + public long getLocalHeaderOffset() { + return this.localHeaderOffset; + } + + @Override + public CentralDirectoryFileHeader clone() { + byte[] header = new byte[46]; + System.arraycopy(this.header, this.headerOffset, header, 0, header.length); + return new CentralDirectoryFileHeader(header, 0, this.name, header, this.comment, this.localHeaderOffset); + } + + static CentralDirectoryFileHeader fromRandomAccessData(RandomAccessData data, long offset, JarEntryFilter filter) + throws IOException { + CentralDirectoryFileHeader fileHeader = new CentralDirectoryFileHeader(); + byte[] bytes = data.read(offset, 46); + fileHeader.load(bytes, 0, data, offset, filter); + return fileHeader; + } + + private static int getChronoValue(long value, ChronoField field) { + ValueRange range = field.range(); + return Math.toIntExact(Math.min(Math.max(value, range.getMinimum()), range.getMaximum())); + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryParser.java b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryParser.java new file mode 100644 index 0000000..d57aa2c --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryParser.java @@ -0,0 +1,99 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Parses the central directory from a JAR file. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @see CentralDirectoryVisitor + */ +class CentralDirectoryParser { + + private static final int CENTRAL_DIRECTORY_HEADER_BASE_SIZE = 46; + + private final List visitors = new ArrayList<>(); + + T addVisitor(T visitor) { + this.visitors.add(visitor); + return visitor; + } + + /** + * Parse the source data, triggering {@link CentralDirectoryVisitor visitors}. + * @param data the source data + * @param skipPrefixBytes if prefix bytes should be skipped + * @return the actual archive data without any prefix bytes + * @throws IOException on error + */ + RandomAccessData parse(RandomAccessData data, boolean skipPrefixBytes) throws IOException { + CentralDirectoryEndRecord endRecord = new CentralDirectoryEndRecord(data); + if (skipPrefixBytes) { + data = getArchiveData(endRecord, data); + } + RandomAccessData centralDirectoryData = endRecord.getCentralDirectory(data); + visitStart(endRecord, centralDirectoryData); + parseEntries(endRecord, centralDirectoryData); + visitEnd(); + return data; + } + + private void parseEntries(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) + throws IOException { + byte[] bytes = centralDirectoryData.read(0, centralDirectoryData.getSize()); + CentralDirectoryFileHeader fileHeader = new CentralDirectoryFileHeader(); + int dataOffset = 0; + for (int i = 0; i < endRecord.getNumberOfRecords(); i++) { + fileHeader.load(bytes, dataOffset, null, 0, null); + visitFileHeader(dataOffset, fileHeader); + dataOffset += CENTRAL_DIRECTORY_HEADER_BASE_SIZE + fileHeader.getName().length() + + fileHeader.getComment().length() + fileHeader.getExtra().length; + } + } + + private RandomAccessData getArchiveData(CentralDirectoryEndRecord endRecord, RandomAccessData data) { + long offset = endRecord.getStartOfArchive(data); + if (offset == 0) { + return data; + } + return data.getSubsection(offset, data.getSize() - offset); + } + + private void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + for (CentralDirectoryVisitor visitor : this.visitors) { + visitor.visitStart(endRecord, centralDirectoryData); + } + } + + private void visitFileHeader(long dataOffset, CentralDirectoryFileHeader fileHeader) { + for (CentralDirectoryVisitor visitor : this.visitors) { + visitor.visitFileHeader(fileHeader, dataOffset); + } + } + + private void visitEnd() { + for (CentralDirectoryVisitor visitor : this.visitors) { + visitor.visitEnd(); + } + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryVisitor.java b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryVisitor.java new file mode 100644 index 0000000..b619ce6 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/CentralDirectoryVisitor.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +/** + * Callback visitor triggered by {@link CentralDirectoryParser}. + * + * @author Phillip Webb + */ +interface CentralDirectoryVisitor { + + void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData); + + void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset); + + void visitEnd(); + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/FileHeader.java b/loader/src/main/java/net/woggioni/envelope/loader/FileHeader.java new file mode 100644 index 0000000..9c3a708 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/FileHeader.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.util.zip.ZipEntry; + +/** + * A file header record that has been loaded from a Jar file. + * + * @author Phillip Webb + * @see JarEntry + * @see CentralDirectoryFileHeader + */ +interface FileHeader { + + /** + * Returns {@code true} if the header has the given name. + * @param name the name to test + * @param suffix an additional suffix (or {@code 0}) + * @return {@code true} if the header has the given name + */ + boolean hasName(CharSequence name, char suffix); + + /** + * Return the offset of the load file header within the archive data. + * @return the local header offset + */ + long getLocalHeaderOffset(); + + /** + * Return the compressed size of the entry. + * @return the compressed size. + */ + long getCompressedSize(); + + /** + * Return the uncompressed size of the entry. + * @return the uncompressed size. + */ + long getSize(); + + /** + * Return the method used to compress the data. + * @return the zip compression method + * @see ZipEntry#STORED + * @see ZipEntry#DEFLATED + */ + int getMethod(); + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/Handler.java b/loader/src/main/java/net/woggioni/envelope/loader/Handler.java new file mode 100644 index 0000000..1f0ecbd --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/Handler.java @@ -0,0 +1,466 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * {@link URLStreamHandler} for Spring Boot loader {@link JarFile}s. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + * @see JarFile#registerUrlProtocolHandler() + */ +public class Handler extends URLStreamHandler { + + // NOTE: in order to be found as a URL protocol handler, this class must be public, + // must be named Handler and must be in a package ending '.jar' + + private static final String JAR_PROTOCOL = "jar:"; + + private static final String FILE_PROTOCOL = "file:"; + + private static final String TOMCAT_WARFILE_PROTOCOL = "war:file:"; + + private static final String SEPARATOR = "!/"; + + private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR, Pattern.LITERAL); + + private static final String CURRENT_DIR = "/./"; + + private static final Pattern CURRENT_DIR_PATTERN = Pattern.compile(CURRENT_DIR, Pattern.LITERAL); + + private static final String PARENT_DIR = "/../"; + + private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs"; + + private static final String[] FALLBACK_HANDLERS = { "sun.net.www.protocol.jar.Handler" }; + + private static URL jarContextUrl; + + private static SoftReference> rootFileCache; + + static { + rootFileCache = new SoftReference<>(null); + } + + private final JarFile jarFile; + + private URLStreamHandler fallbackHandler; + + public Handler() { + this(null); + } + + public Handler(JarFile jarFile) { + this.jarFile = jarFile; + } + + @Override + protected URLConnection openConnection(URL url) throws IOException { + if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) { + return JarURLConnection.get(url, this.jarFile); + } + try { + return JarURLConnection.get(url, getRootJarFileFromUrl(url)); + } + catch (Exception ex) { + return openFallbackConnection(url, ex); + } + } + + private boolean isUrlInJarFile(URL url, JarFile jarFile) throws MalformedURLException { + // Try the path first to save building a new url string each time + return url.getPath().startsWith(jarFile.getUrl().getPath()) + && url.toString().startsWith(jarFile.getUrlString()); + } + + private URLConnection openFallbackConnection(URL url, Exception reason) throws IOException { + try { + URLConnection connection = openFallbackTomcatConnection(url); + connection = (connection != null) ? connection : openFallbackContextConnection(url); + return (connection != null) ? connection : openFallbackHandlerConnection(url); + } + catch (Exception ex) { + if (reason instanceof IOException) { + log(false, "Unable to open fallback handler", ex); + throw (IOException) reason; + } + log(true, "Unable to open fallback handler", ex); + if (reason instanceof RuntimeException) { + throw (RuntimeException) reason; + } + throw new IllegalStateException(reason); + } + } + + /** + * Attempt to open a Tomcat formatted 'jar:war:file:...' URL. This method allows us to + * use our own nested JAR support to open the content rather than the logic in + * {@code sun.net.www.protocol.jar.URLJarFile} which will extract the nested jar to + * the temp folder to that its content can be accessed. + * @param url the URL to open + * @return a {@link URLConnection} or {@code null} + */ + private URLConnection openFallbackTomcatConnection(URL url) { + String file = url.getFile(); + if (isTomcatWarUrl(file)) { + file = file.substring(TOMCAT_WARFILE_PROTOCOL.length()); + file = file.replaceFirst("\\*/", "!/"); + try { + URLConnection connection = openConnection(new URL("jar:file:" + file)); + connection.getInputStream().close(); + return connection; + } + catch (IOException ex) { + } + } + return null; + } + + private boolean isTomcatWarUrl(String file) { + if (file.startsWith(TOMCAT_WARFILE_PROTOCOL) || !file.contains("*/")) { + try { + URLConnection connection = new URL(file).openConnection(); + if (connection.getClass().getName().startsWith("org.apache.catalina")) { + return true; + } + } + catch (Exception ex) { + } + } + return false; + } + + /** + * Attempt to open a fallback connection by using a context URL captured before the + * jar handler was replaced with our own version. Since this method doesn't use + * reflection it won't trigger "illegal reflective access operation has occurred" + * warnings on Java 13+. + * @param url the URL to open + * @return a {@link URLConnection} or {@code null} + */ + private URLConnection openFallbackContextConnection(URL url) { + try { + if (jarContextUrl != null) { + return new URL(jarContextUrl, url.toExternalForm()).openConnection(); + } + } + catch (Exception ex) { + } + return null; + } + + /** + * Attempt to open a fallback connection by using reflection to access Java's default + * jar {@link URLStreamHandler}. + * @param url the URL to open + * @return the {@link URLConnection} + * @throws Exception if not connection could be opened + */ + private URLConnection openFallbackHandlerConnection(URL url) throws Exception { + URLStreamHandler fallbackHandler = getFallbackHandler(); + return new URL(null, url.toExternalForm(), fallbackHandler).openConnection(); + } + + private URLStreamHandler getFallbackHandler() { + if (this.fallbackHandler != null) { + return this.fallbackHandler; + } + for (String handlerClassName : FALLBACK_HANDLERS) { + try { + Class handlerClass = Class.forName(handlerClassName); + this.fallbackHandler = (URLStreamHandler) handlerClass.getDeclaredConstructor().newInstance(); + return this.fallbackHandler; + } + catch (Exception ex) { + // Ignore + } + } + throw new IllegalStateException("Unable to find fallback handler"); + } + + private void log(boolean warning, String message, Exception cause) { + try { + Level level = warning ? Level.WARNING : Level.FINEST; + Logger.getLogger(getClass().getName()).log(level, message, cause); + } + catch (Exception ex) { + if (warning) { + System.err.println("WARNING: " + message); + } + } + } + + @Override + protected void parseURL(URL context, String spec, int start, int limit) { + if (spec.regionMatches(true, 0, JAR_PROTOCOL, 0, JAR_PROTOCOL.length())) { + setFile(context, getFileFromSpec(spec.substring(start, limit))); + } + else { + setFile(context, getFileFromContext(context, spec.substring(start, limit))); + } + } + + private String getFileFromSpec(String spec) { + int separatorIndex = spec.lastIndexOf("!/"); + if (separatorIndex == -1) { + throw new IllegalArgumentException("No !/ in spec '" + spec + "'"); + } + try { + new URL(spec.substring(0, separatorIndex)); + return spec; + } + catch (MalformedURLException ex) { + throw new IllegalArgumentException("Invalid spec URL '" + spec + "'", ex); + } + } + + private String getFileFromContext(URL context, String spec) { + String file = context.getFile(); + if (spec.startsWith("/")) { + return trimToJarRoot(file) + SEPARATOR + spec.substring(1); + } + if (file.endsWith("/")) { + return file + spec; + } + int lastSlashIndex = file.lastIndexOf('/'); + if (lastSlashIndex == -1) { + throw new IllegalArgumentException("No / found in context URL's file '" + file + "'"); + } + return file.substring(0, lastSlashIndex + 1) + spec; + } + + private String trimToJarRoot(String file) { + int lastSeparatorIndex = file.lastIndexOf(SEPARATOR); + if (lastSeparatorIndex == -1) { + throw new IllegalArgumentException("No !/ found in context URL's file '" + file + "'"); + } + return file.substring(0, lastSeparatorIndex); + } + + private void setFile(URL context, String file) { + String path = normalize(file); + String query = null; + int queryIndex = path.lastIndexOf('?'); + if (queryIndex != -1) { + query = path.substring(queryIndex + 1); + path = path.substring(0, queryIndex); + } + setURL(context, JAR_PROTOCOL, null, -1, null, null, path, query, context.getRef()); + } + + private String normalize(String file) { + if (!file.contains(CURRENT_DIR) && !file.contains(PARENT_DIR)) { + return file; + } + int afterLastSeparatorIndex = file.lastIndexOf(SEPARATOR) + SEPARATOR.length(); + String afterSeparator = file.substring(afterLastSeparatorIndex); + afterSeparator = replaceParentDir(afterSeparator); + afterSeparator = replaceCurrentDir(afterSeparator); + return file.substring(0, afterLastSeparatorIndex) + afterSeparator; + } + + private String replaceParentDir(String file) { + int parentDirIndex; + while ((parentDirIndex = file.indexOf(PARENT_DIR)) >= 0) { + int precedingSlashIndex = file.lastIndexOf('/', parentDirIndex - 1); + if (precedingSlashIndex >= 0) { + file = file.substring(0, precedingSlashIndex) + file.substring(parentDirIndex + 3); + } + else { + file = file.substring(parentDirIndex + 4); + } + } + return file; + } + + private String replaceCurrentDir(String file) { + return CURRENT_DIR_PATTERN.matcher(file).replaceAll("/"); + } + + @Override + protected int hashCode(URL u) { + return hashCode(u.getProtocol(), u.getFile()); + } + + private int hashCode(String protocol, String file) { + int result = (protocol != null) ? protocol.hashCode() : 0; + int separatorIndex = file.indexOf(SEPARATOR); + if (separatorIndex == -1) { + return result + file.hashCode(); + } + String source = file.substring(0, separatorIndex); + String entry = canonicalize(file.substring(separatorIndex + 2)); + try { + result += new URL(source).hashCode(); + } + catch (MalformedURLException ex) { + result += source.hashCode(); + } + result += entry.hashCode(); + return result; + } + + @Override + protected boolean sameFile(URL u1, URL u2) { + if (!u1.getProtocol().equals("jar") || !u2.getProtocol().equals("jar")) { + return false; + } + int separator1 = u1.getFile().indexOf(SEPARATOR); + int separator2 = u2.getFile().indexOf(SEPARATOR); + if (separator1 == -1 || separator2 == -1) { + return super.sameFile(u1, u2); + } + String nested1 = u1.getFile().substring(separator1 + SEPARATOR.length()); + String nested2 = u2.getFile().substring(separator2 + SEPARATOR.length()); + if (!nested1.equals(nested2)) { + String canonical1 = canonicalize(nested1); + String canonical2 = canonicalize(nested2); + if (!canonical1.equals(canonical2)) { + return false; + } + } + String root1 = u1.getFile().substring(0, separator1); + String root2 = u2.getFile().substring(0, separator2); + try { + return super.sameFile(new URL(root1), new URL(root2)); + } + catch (MalformedURLException ex) { + // Continue + } + return super.sameFile(u1, u2); + } + + private String canonicalize(String path) { + return SEPARATOR_PATTERN.matcher(path).replaceAll("/"); + } + + public JarFile getRootJarFileFromUrl(URL url) throws IOException { + String spec = url.getFile(); + int separatorIndex = spec.indexOf(SEPARATOR); + if (separatorIndex == -1) { + throw new MalformedURLException("Jar URL does not contain !/ separator"); + } + String name = spec.substring(0, separatorIndex); + return getRootJarFile(name); + } + + private JarFile getRootJarFile(String name) throws IOException { + try { + if (!name.startsWith(FILE_PROTOCOL)) { + throw new IllegalStateException("Not a file URL"); + } + File file = new File(URI.create(name)); + Map cache = rootFileCache.get(); + JarFile result = (cache != null) ? cache.get(file) : null; + if (result == null) { + result = new JarFile(file); + addToRootFileCache(file, result); + } + return result; + } + catch (Exception ex) { + throw new IOException("Unable to open root Jar file '" + name + "'", ex); + } + } + + /** + * Add the given {@link JarFile} to the root file cache. + * @param sourceFile the source file to add + * @param jarFile the jar file. + */ + static void addToRootFileCache(File sourceFile, JarFile jarFile) { + Map cache = rootFileCache.get(); + if (cache == null) { + cache = new ConcurrentHashMap<>(); + rootFileCache = new SoftReference<>(cache); + } + cache.put(sourceFile, jarFile); + } + + /** + * If possible, capture a URL that is configured with the original jar handler so that + * we can use it as a fallback context later. We can only do this if we know that we + * can reset the handlers after. + */ + static void captureJarContextUrl() { + if (canResetCachedUrlHandlers()) { + String handlers = System.getProperty(PROTOCOL_HANDLER, ""); + try { + System.clearProperty(PROTOCOL_HANDLER); + try { + resetCachedUrlHandlers(); + jarContextUrl = new URL("jar:file:context.jar!/"); + URLConnection connection = jarContextUrl.openConnection(); + if (connection instanceof JarURLConnection) { + jarContextUrl = null; + } + } + catch (Exception ex) { + } + } + finally { + if (handlers == null) { + System.clearProperty(PROTOCOL_HANDLER); + } + else { + System.setProperty(PROTOCOL_HANDLER, handlers); + } + } + resetCachedUrlHandlers(); + } + } + + private static boolean canResetCachedUrlHandlers() { + try { + resetCachedUrlHandlers(); + return true; + } + catch (Error ex) { + return false; + } + } + + private static void resetCachedUrlHandlers() { + URL.setURLStreamHandlerFactory(null); + } + + /** + * Set if a generic static exception can be thrown when a URL cannot be connected. + * This optimization is used during class loading to save creating lots of exceptions + * which are then swallowed. + * @param useFastConnectionExceptions if fast connection exceptions can be used. + */ + public static void setUseFastConnectionExceptions(boolean useFastConnectionExceptions) { + JarURLConnection.setUseFastExceptions(useFastConnectionExceptions); + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/JarEntry.java b/loader/src/main/java/net/woggioni/envelope/loader/JarEntry.java new file mode 100644 index 0000000..a7a30d8 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/JarEntry.java @@ -0,0 +1,120 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.CodeSigner; +import java.security.cert.Certificate; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +/** + * Extended variant of {@link java.util.jar.JarEntry} returned by {@link JarFile}s. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarEntry extends java.util.jar.JarEntry implements FileHeader { + + private final int index; + + private final Bytes.AsciiBytes name; + + private final Bytes.AsciiBytes headerName; + + private final JarFile jarFile; + + private long localHeaderOffset; + + private volatile JarEntryCertification certification; + + JarEntry(JarFile jarFile, int index, CentralDirectoryFileHeader header, Bytes.AsciiBytes nameAlias) { + super((nameAlias != null) ? nameAlias.toString() : header.getName().toString()); + this.index = index; + this.name = (nameAlias != null) ? nameAlias : header.getName(); + this.headerName = header.getName(); + this.jarFile = jarFile; + this.localHeaderOffset = header.getLocalHeaderOffset(); + setCompressedSize(header.getCompressedSize()); + setMethod(header.getMethod()); + setCrc(header.getCrc()); + setComment(header.getComment().toString()); + setSize(header.getSize()); + setTime(header.getTime()); + if (header.hasExtra()) { + setExtra(header.getExtra()); + } + } + + int getIndex() { + return this.index; + } + + Bytes.AsciiBytes getAsciiBytesName() { + return this.name; + } + + @Override + public boolean hasName(CharSequence name, char suffix) { + return this.headerName.matches(name, suffix); + } + + /** + * Return a {@link URL} for this {@link JarEntry}. + * @return the URL for the entry + * @throws MalformedURLException if the URL is not valid + */ + URL getUrl() throws MalformedURLException { + return new URL(this.jarFile.getUrl(), getName()); + } + + @Override + public Attributes getAttributes() throws IOException { + Manifest manifest = this.jarFile.getManifest(); + return (manifest != null) ? manifest.getAttributes(getName()) : null; + } + + @Override + public Certificate[] getCertificates() { + return getCertification().getCertificates(); + } + + @Override + public CodeSigner[] getCodeSigners() { + return getCertification().getCodeSigners(); + } + + private JarEntryCertification getCertification() { + if (!this.jarFile.isSigned()) { + return JarEntryCertification.NONE; + } + JarEntryCertification certification = this.certification; + if (certification == null) { + certification = this.jarFile.getCertification(this); + this.certification = certification; + } + return certification; + } + + @Override + public long getLocalHeaderOffset() { + return this.localHeaderOffset; + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/JarEntryCertification.java b/loader/src/main/java/net/woggioni/envelope/loader/JarEntryCertification.java new file mode 100644 index 0000000..7dace7d --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/JarEntryCertification.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.security.CodeSigner; +import java.security.cert.Certificate; + +/** + * {@link Certificate} and {@link CodeSigner} details for a {@link JarEntry} from a signed + * {@link JarFile}. + * + * @author Phillip Webb + */ +class JarEntryCertification { + + static final JarEntryCertification NONE = new JarEntryCertification(null, null); + + private final Certificate[] certificates; + + private final CodeSigner[] codeSigners; + + JarEntryCertification(Certificate[] certificates, CodeSigner[] codeSigners) { + this.certificates = certificates; + this.codeSigners = codeSigners; + } + + Certificate[] getCertificates() { + return (this.certificates != null) ? this.certificates.clone() : null; + } + + CodeSigner[] getCodeSigners() { + return (this.codeSigners != null) ? this.codeSigners.clone() : null; + } + + static JarEntryCertification from(java.util.jar.JarEntry certifiedEntry) { + Certificate[] certificates = (certifiedEntry != null) ? certifiedEntry.getCertificates() : null; + CodeSigner[] codeSigners = (certifiedEntry != null) ? certifiedEntry.getCodeSigners() : null; + if (certificates == null && codeSigners == null) { + return NONE; + } + return new JarEntryCertification(certificates, codeSigners); + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/JarEntryFilter.java b/loader/src/main/java/net/woggioni/envelope/loader/JarEntryFilter.java new file mode 100644 index 0000000..cf24e04 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/JarEntryFilter.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +/** + * Interface that can be used to filter and optionally rename jar entries. + * + * @author Phillip Webb + */ +interface JarEntryFilter { + + /** + * Apply the jar entry filter. + * @param name the current entry name. This may be different that the original entry + * name if a previous filter has been applied + * @return the new name of the entry or {@code null} if the entry should not be + * included. + */ + Bytes.AsciiBytes apply(Bytes.AsciiBytes name); + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/JarFile.java b/loader/src/main/java/net/woggioni/envelope/loader/JarFile.java new file mode 100644 index 0000000..8608f36 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/JarFile.java @@ -0,0 +1,490 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.File; +import java.io.FilePermission; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.SoftReference; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; +import java.security.Permission; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.Supplier; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import java.util.zip.ZipEntry; + +/** + * Extended variant of {@link java.util.jar.JarFile} that behaves in the same way but + * offers the following additional functionality. + *
    + *
  • A nested {@link JarFile} can be {@link #getNestedJarFile(ZipEntry) obtained} based + * on any directory entry.
  • + *
  • A nested {@link JarFile} can be {@link #getNestedJarFile(ZipEntry) obtained} for + * embedded JAR files (as long as their entry is not compressed).
  • + *
+ * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class JarFile extends AbstractJarFile implements Iterable { + + private static final String MANIFEST_NAME = "META-INF/MANIFEST.MF"; + + private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs"; + + private static final String HANDLERS_PACKAGE = "net.woggioni.xclassloader.jar"; + + private static final Bytes.AsciiBytes META_INF = new Bytes.AsciiBytes("META-INF/"); + + private static final Bytes.AsciiBytes SIGNATURE_FILE_EXTENSION = new Bytes.AsciiBytes(".SF"); + + private static final String READ_ACTION = "read"; + + private final RandomAccessDataFile rootFile; + + private final String pathFromRoot; + + private final RandomAccessData data; + + private final JarFileType type; + + private URL url; + + private String urlString; + + private JarFileEntries entries; + + private Supplier manifestSupplier; + + private SoftReference manifest; + + private boolean signed; + + private String comment; + + private volatile boolean closed; + + private volatile JarFileWrapper wrapper; + + private final List nestedJars = Collections.synchronizedList(new ArrayList<>()); + + /** + * Create a new {@link JarFile} backed by the specified file. + * @param file the root jar file + * @throws IOException if the file cannot be read + */ + public JarFile(File file) throws IOException { + this(new RandomAccessDataFile(file)); + } + + /** + * Create a new {@link JarFile} backed by the specified file. + * @param file the root jar file + * @throws IOException if the file cannot be read + */ + JarFile(RandomAccessDataFile file) throws IOException { + this(file, "", file, JarFileType.DIRECT); + } + + /** + * Private constructor used to create a new {@link JarFile} either directly or from a + * nested entry. + * @param rootFile the root jar file + * @param pathFromRoot the name of this file + * @param data the underlying data + * @param type the type of the jar file + * @throws IOException if the file cannot be read + */ + private JarFile(RandomAccessDataFile rootFile, String pathFromRoot, RandomAccessData data, JarFileType type) + throws IOException { + this(rootFile, pathFromRoot, data, null, type, null); + } + + private JarFile(RandomAccessDataFile rootFile, String pathFromRoot, RandomAccessData data, JarEntryFilter filter, + JarFileType type, Supplier manifestSupplier) throws IOException { + super(rootFile.getFile()); + this.rootFile = rootFile; + this.pathFromRoot = pathFromRoot; + CentralDirectoryParser parser = new CentralDirectoryParser(); + this.entries = parser.addVisitor(new JarFileEntries(this, filter)); + this.type = type; + parser.addVisitor(centralDirectoryVisitor()); + try { + this.data = parser.parse(data, filter == null); + } + catch (RuntimeException ex) { + try { + close(); + } + catch (IOException ioex) { + } + throw ex; + } + this.manifestSupplier = (manifestSupplier != null) ? manifestSupplier : () -> { + try (InputStream inputStream = getInputStream(MANIFEST_NAME)) { + if (inputStream == null) { + return null; + } + return new Manifest(inputStream); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + }; + } + + private CentralDirectoryVisitor centralDirectoryVisitor() { + return new CentralDirectoryVisitor() { + + @Override + public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + JarFile.this.comment = endRecord.getComment(); + } + + @Override + public void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset) { + Bytes.AsciiBytes name = fileHeader.getName(); + if (name.startsWith(META_INF) && name.endsWith(SIGNATURE_FILE_EXTENSION)) { + JarFile.this.signed = true; + } + } + + @Override + public void visitEnd() { + } + + }; + } + + JarFileWrapper getWrapper() throws IOException { + JarFileWrapper wrapper = this.wrapper; + if (wrapper == null) { + synchronized (this) { + if (this.wrapper != null) { + return this.wrapper; + } + wrapper = new JarFileWrapper(this); + this.wrapper = wrapper; + } + } + return wrapper; + } + + @Override + Permission getPermission() { + return new FilePermission(this.rootFile.getFile().getPath(), READ_ACTION); + } + + protected final RandomAccessDataFile getRootJarFile() { + return this.rootFile; + } + + RandomAccessData getData() { + return this.data; + } + + @Override + public Manifest getManifest() throws IOException { + Manifest manifest = (this.manifest != null) ? this.manifest.get() : null; + if (manifest == null) { + try { + manifest = this.manifestSupplier.get(); + } + catch (RuntimeException ex) { + throw new IOException(ex); + } + this.manifest = new SoftReference<>(manifest); + } + return manifest; + } + + @Override + public Enumeration entries() { + return new JarEntryEnumeration(this.entries.iterator()); + } + + @Override + public Stream stream() { + Spliterator spliterator = Spliterators.spliterator(iterator(), size(), + Spliterator.ORDERED | Spliterator.DISTINCT | Spliterator.IMMUTABLE | Spliterator.NONNULL); + return StreamSupport.stream(spliterator, false); + } + + /** + * Return an iterator for the contained entries. + * @since 2.3.0 + * @see Iterable#iterator() + */ + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Iterator iterator() { + return (Iterator) this.entries.iterator(this::ensureOpen); + } + + public JarEntry getJarEntry(CharSequence name) { + return this.entries.getEntry(name); + } + + @Override + public JarEntry getJarEntry(String name) { + return (JarEntry) getEntry(name); + } + + public boolean containsEntry(String name) { + return this.entries.containsEntry(name); + } + + @Override + public ZipEntry getEntry(String name) { + ensureOpen(); + return this.entries.getEntry(name); + } + + @Override + InputStream getInputStream() throws IOException { + return this.data.getInputStream(); + } + + @Override + public synchronized InputStream getInputStream(ZipEntry entry) throws IOException { + ensureOpen(); + if (entry instanceof JarEntry) { + return this.entries.getInputStream((JarEntry) entry); + } + return getInputStream((entry != null) ? entry.getName() : null); + } + + InputStream getInputStream(String name) throws IOException { + return this.entries.getInputStream(name); + } + + /** + * Return a nested {@link JarFile} loaded from the specified entry. + * @param entry the zip entry + * @return a {@link JarFile} for the entry + * @throws IOException if the nested jar file cannot be read + */ + public synchronized JarFile getNestedJarFile(ZipEntry entry) throws IOException { + return getNestedJarFile((JarEntry) entry); + } + + /** + * Return a nested {@link JarFile} loaded from the specified entry. + * @param entry the zip entry + * @return a {@link JarFile} for the entry + * @throws IOException if the nested jar file cannot be read + */ + public synchronized JarFile getNestedJarFile(JarEntry entry) throws IOException { + try { + return createJarFileFromEntry(entry); + } + catch (Exception ex) { + throw new IOException("Unable to open nested jar file '" + entry.getName() + "'", ex); + } + } + + private JarFile createJarFileFromEntry(JarEntry entry) throws IOException { + if (entry.isDirectory()) { + return createJarFileFromDirectoryEntry(entry); + } + return createJarFileFromFileEntry(entry); + } + + private JarFile createJarFileFromDirectoryEntry(JarEntry entry) throws IOException { + Bytes.AsciiBytes name = entry.getAsciiBytesName(); + JarEntryFilter filter = (candidate) -> { + if (candidate.startsWith(name) && !candidate.equals(name)) { + return candidate.substring(name.length()); + } + return null; + }; + return new JarFile(this.rootFile, this.pathFromRoot + "!/" + entry.getName().substring(0, name.length() - 1), + this.data, filter, JarFileType.NESTED_DIRECTORY, this.manifestSupplier); + } + + private JarFile createJarFileFromFileEntry(JarEntry entry) throws IOException { + if (entry.getMethod() != ZipEntry.STORED) { + throw new IllegalStateException( + "Unable to open nested entry '" + entry.getName() + "'. It has been compressed and nested " + + "jar files must be stored without compression. Please check the " + + "mechanism used to create your executable jar file"); + } + RandomAccessData entryData = this.entries.getEntryData(entry.getName()); + JarFile nestedJar = new JarFile(this.rootFile, this.pathFromRoot + "!/" + entry.getName(), entryData, + JarFileType.NESTED_JAR); + this.nestedJars.add(nestedJar); + return nestedJar; + } + + @Override + public String getComment() { + ensureOpen(); + return this.comment; + } + + @Override + public int size() { + ensureOpen(); + return this.entries.getSize(); + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + synchronized (this) { + super.close(); + if (this.type == JarFileType.DIRECT) { + this.rootFile.close(); + } + if (this.wrapper != null) { + this.wrapper.close(); + } + for (JarFile nestedJar : this.nestedJars) { + nestedJar.close(); + } + this.closed = true; + } + } + + private void ensureOpen() { + if (this.closed) { + throw new IllegalStateException("zip file closed"); + } + } + + boolean isClosed() { + return this.closed; + } + + String getUrlString() throws MalformedURLException { + if (this.urlString == null) { + this.urlString = getUrl().toString(); + } + return this.urlString; + } + + @Override + public URL getUrl() throws MalformedURLException { + if (this.url == null) { + String file = this.rootFile.getFile().toURI() + this.pathFromRoot + "!/"; + file = file.replace("file:////", "file://"); // Fix UNC paths + this.url = new URL("jar", "", -1, file, new Handler(this)); + } + return this.url; + } + + @Override + public String toString() { + return getName(); + } + + @Override + public String getName() { + return this.rootFile.getFile() + this.pathFromRoot; + } + + boolean isSigned() { + return this.signed; + } + + JarEntryCertification getCertification(JarEntry entry) { + try { + return this.entries.getCertification(entry); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + public void clearCache() { + this.entries.clearCache(); + } + + protected String getPathFromRoot() { + return this.pathFromRoot; + } + + @Override + JarFileType getType() { + return this.type; + } + + /** + * Register a {@literal 'java.protocol.handler.pkgs'} property so that a + * {@link URLStreamHandler} will be located to deal with jar URLs. + */ + public static void registerUrlProtocolHandler() { + Handler.captureJarContextUrl(); + String handlers = System.getProperty(PROTOCOL_HANDLER, ""); + System.setProperty(PROTOCOL_HANDLER, + ((handlers == null || handlers.isEmpty()) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE)); + resetCachedUrlHandlers(); + } + + /** + * Reset any cached handlers just in case a jar protocol has already been used. We + * reset the handler by trying to set a null {@link URLStreamHandlerFactory} which + * should have no effect other than clearing the handlers cache. + */ + private static void resetCachedUrlHandlers() { + try { + URL.setURLStreamHandlerFactory(null); + } + catch (Error ex) { + // Ignore + } + } + + /** + * An {@link Enumeration} on {@linkplain java.util.jar.JarEntry jar entries}. + */ + private static class JarEntryEnumeration implements Enumeration { + + private final Iterator iterator; + + JarEntryEnumeration(Iterator iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasMoreElements() { + return this.iterator.hasNext(); + } + + @Override + public java.util.jar.JarEntry nextElement() { + return this.iterator.next(); + } + + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/JarFileEntries.java b/loader/src/main/java/net/woggioni/envelope/loader/JarFileEntries.java new file mode 100644 index 0000000..f976f58 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/JarFileEntries.java @@ -0,0 +1,498 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +/** + * Provides access to entries from a {@link JarFile}. In order to reduce memory + * consumption entry details are stored using arrays. The {@code hashCodes} array stores + * the hash code of the entry name, the {@code centralDirectoryOffsets} provides the + * offset to the central directory record and {@code positions} provides the original + * order position of the entry. The arrays are stored in hashCode order so that a binary + * search can be used to find a name. + *

+ * A typical Spring Boot application will have somewhere in the region of 10,500 entries + * which should consume about 122K. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarFileEntries implements CentralDirectoryVisitor, Iterable { + + private static final Runnable NO_VALIDATION = () -> { + }; + + private static final String META_INF_PREFIX = "META-INF/"; + + private static final Name MULTI_RELEASE = new Name("Multi-Release"); + + private static final int BASE_VERSION = 8; + + private static final int RUNTIME_VERSION = javaSpecificationVersion(); + + private static final long LOCAL_FILE_HEADER_SIZE = 30; + + private static final char SLASH = '/'; + + private static final char NO_SUFFIX = 0; + + protected static final int ENTRY_CACHE_SIZE = 25; + + private final JarFile jarFile; + + private final JarEntryFilter filter; + + private RandomAccessData centralDirectoryData; + + private int size; + + private int[] hashCodes; + + private Offsets centralDirectoryOffsets; + + private int[] positions; + + private Boolean multiReleaseJar; + + private JarEntryCertification[] certifications; + + private final Map entriesCache = Collections + .synchronizedMap(new LinkedHashMap(16, 0.75f, true) { + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() >= ENTRY_CACHE_SIZE; + } + + }); + + JarFileEntries(JarFile jarFile, JarEntryFilter filter) { + this.jarFile = jarFile; + this.filter = filter; + } + + @Override + public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + int maxSize = endRecord.getNumberOfRecords(); + this.centralDirectoryData = centralDirectoryData; + this.hashCodes = new int[maxSize]; + this.centralDirectoryOffsets = Offsets.from(endRecord); + this.positions = new int[maxSize]; + } + + @Override + public void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset) { + Bytes.AsciiBytes name = applyFilter(fileHeader.getName()); + if (name != null) { + add(name, dataOffset); + } + } + + private void add(Bytes.AsciiBytes name, long dataOffset) { + this.hashCodes[this.size] = name.hashCode(); + this.centralDirectoryOffsets.set(this.size, dataOffset); + this.positions[this.size] = this.size; + this.size++; + } + + @Override + public void visitEnd() { + sort(0, this.size - 1); + int[] positions = this.positions; + this.positions = new int[positions.length]; + for (int i = 0; i < this.size; i++) { + this.positions[positions[i]] = i; + } + } + + int getSize() { + return this.size; + } + + private void sort(int left, int right) { + // Quick sort algorithm, uses hashCodes as the source but sorts all arrays + if (left < right) { + int pivot = this.hashCodes[left + (right - left) / 2]; + int i = left; + int j = right; + while (i <= j) { + while (this.hashCodes[i] < pivot) { + i++; + } + while (this.hashCodes[j] > pivot) { + j--; + } + if (i <= j) { + swap(i, j); + i++; + j--; + } + } + if (left < j) { + sort(left, j); + } + if (right > i) { + sort(i, right); + } + } + } + + private void swap(int i, int j) { + swap(this.hashCodes, i, j); + this.centralDirectoryOffsets.swap(i, j); + swap(this.positions, i, j); + } + + @Override + public Iterator iterator() { + return new EntryIterator(NO_VALIDATION); + } + + Iterator iterator(Runnable validator) { + return new EntryIterator(validator); + } + + boolean containsEntry(CharSequence name) { + return getEntry(name, FileHeader.class, true) != null; + } + + JarEntry getEntry(CharSequence name) { + return getEntry(name, JarEntry.class, true); + } + + InputStream getInputStream(String name) throws IOException { + FileHeader entry = getEntry(name, FileHeader.class, false); + return getInputStream(entry); + } + + InputStream getInputStream(FileHeader entry) throws IOException { + if (entry == null) { + return null; + } + InputStream inputStream = getEntryData(entry).getInputStream(); + if (entry.getMethod() == ZipEntry.DEFLATED) { + inputStream = new ZipInflaterInputStream(inputStream, (int) entry.getSize()); + } + return inputStream; + } + + RandomAccessData getEntryData(String name) throws IOException { + FileHeader entry = getEntry(name, FileHeader.class, false); + if (entry == null) { + return null; + } + return getEntryData(entry); + } + + private RandomAccessData getEntryData(FileHeader entry) throws IOException { + // aspectjrt-1.7.4.jar has a different ext bytes length in the + // local directory to the central directory. We need to re-read + // here to skip them + RandomAccessData data = this.jarFile.getData(); + byte[] localHeader = data.read(entry.getLocalHeaderOffset(), LOCAL_FILE_HEADER_SIZE); + long nameLength = Bytes.littleEndianValue(localHeader, 26, 2); + long extraLength = Bytes.littleEndianValue(localHeader, 28, 2); + return data.getSubsection(entry.getLocalHeaderOffset() + LOCAL_FILE_HEADER_SIZE + nameLength + extraLength, + entry.getCompressedSize()); + } + + private T getEntry(CharSequence name, Class type, boolean cacheEntry) { + T entry = doGetEntry(name, type, cacheEntry, null); + if (!isMetaInfEntry(name) && isMultiReleaseJar()) { + int version = RUNTIME_VERSION; + Bytes.AsciiBytes nameAlias = (entry instanceof JarEntry) ? ((JarEntry) entry).getAsciiBytesName() + : new Bytes.AsciiBytes(name.toString()); + while (version > BASE_VERSION) { + T versionedEntry = doGetEntry("META-INF/versions/" + version + "/" + name, type, cacheEntry, nameAlias); + if (versionedEntry != null) { + return versionedEntry; + } + version--; + } + } + return entry; + } + + private boolean isMetaInfEntry(CharSequence name) { + return name.toString().startsWith(META_INF_PREFIX); + } + + private boolean isMultiReleaseJar() { + Boolean multiRelease = this.multiReleaseJar; + if (multiRelease != null) { + return multiRelease; + } + try { + Manifest manifest = this.jarFile.getManifest(); + if (manifest == null) { + multiRelease = false; + } + else { + Attributes attributes = manifest.getMainAttributes(); + multiRelease = attributes.containsKey(MULTI_RELEASE); + } + } + catch (IOException ex) { + multiRelease = false; + } + this.multiReleaseJar = multiRelease; + return multiRelease; + } + + private T doGetEntry(CharSequence name, Class type, boolean cacheEntry, + Bytes.AsciiBytes nameAlias) { + int hashCode = Bytes.AsciiBytes.hashCode(name); + T entry = getEntry(hashCode, name, NO_SUFFIX, type, cacheEntry, nameAlias); + if (entry == null) { + hashCode = Bytes.AsciiBytes.hashCode(hashCode, SLASH); + entry = getEntry(hashCode, name, SLASH, type, cacheEntry, nameAlias); + } + return entry; + } + + private T getEntry(int hashCode, CharSequence name, char suffix, Class type, + boolean cacheEntry, Bytes.AsciiBytes nameAlias) { + int index = getFirstIndex(hashCode); + while (index >= 0 && index < this.size && this.hashCodes[index] == hashCode) { + T entry = getEntry(index, type, cacheEntry, nameAlias); + if (entry.hasName(name, suffix)) { + return entry; + } + index++; + } + return null; + } + + @SuppressWarnings("unchecked") + private T getEntry(int index, Class type, boolean cacheEntry, Bytes.AsciiBytes nameAlias) { + try { + long offset = this.centralDirectoryOffsets.get(index); + FileHeader cached = this.entriesCache.get(index); + FileHeader entry = (cached != null) ? cached + : CentralDirectoryFileHeader.fromRandomAccessData(this.centralDirectoryData, offset, this.filter); + if (CentralDirectoryFileHeader.class.equals(entry.getClass()) && type.equals(JarEntry.class)) { + entry = new JarEntry(this.jarFile, index, (CentralDirectoryFileHeader) entry, nameAlias); + } + if (cacheEntry && cached != entry) { + this.entriesCache.put(index, entry); + } + return (T) entry; + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private int getFirstIndex(int hashCode) { + int index = Arrays.binarySearch(this.hashCodes, 0, this.size, hashCode); + if (index < 0) { + return -1; + } + while (index > 0 && this.hashCodes[index - 1] == hashCode) { + index--; + } + return index; + } + + void clearCache() { + this.entriesCache.clear(); + } + + private Bytes.AsciiBytes applyFilter(Bytes.AsciiBytes name) { + return (this.filter != null) ? this.filter.apply(name) : name; + } + + JarEntryCertification getCertification(JarEntry entry) throws IOException { + JarEntryCertification[] certifications = this.certifications; + if (certifications == null) { + certifications = new JarEntryCertification[this.size]; + // We fall back to use JarInputStream to obtain the certs. This isn't that + // fast, but hopefully doesn't happen too often. + try (JarInputStream certifiedJarStream = new JarInputStream(this.jarFile.getData().getInputStream())) { + java.util.jar.JarEntry certifiedEntry = null; + while ((certifiedEntry = certifiedJarStream.getNextJarEntry()) != null) { + // Entry must be closed to trigger a read and set entry certificates + certifiedJarStream.closeEntry(); + int index = getEntryIndex(certifiedEntry.getName()); + if (index != -1) { + certifications[index] = JarEntryCertification.from(certifiedEntry); + } + } + } + this.certifications = certifications; + } + JarEntryCertification certification = certifications[entry.getIndex()]; + return (certification != null) ? certification : JarEntryCertification.NONE; + } + + private int getEntryIndex(CharSequence name) { + int hashCode = Bytes.AsciiBytes.hashCode(name); + int index = getFirstIndex(hashCode); + while (index >= 0 && index < this.size && this.hashCodes[index] == hashCode) { + FileHeader candidate = getEntry(index, FileHeader.class, false, null); + if (candidate.hasName(name, NO_SUFFIX)) { + return index; + } + index++; + } + return -1; + } + + private static void swap(int[] array, int i, int j) { + int temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + + private static void swap(long[] array, int i, int j) { + long temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + + private static int javaSpecificationVersion() { + float v = Float.parseFloat(System.getProperty("java.specification.version")); + if(v < 2) { + return ((int) (v * 10)) - 10; + } else { + return (int) v; + } + } + + /** + * Iterator for contained entries. + */ + private final class EntryIterator implements Iterator { + + private final Runnable validator; + + private int index = 0; + + private EntryIterator(Runnable validator) { + this.validator = validator; + validator.run(); + } + + @Override + public boolean hasNext() { + this.validator.run(); + return this.index < JarFileEntries.this.size; + } + + @Override + public JarEntry next() { + this.validator.run(); + if (!hasNext()) { + throw new NoSuchElementException(); + } + int entryIndex = JarFileEntries.this.positions[this.index]; + this.index++; + return getEntry(entryIndex, JarEntry.class, false, null); + } + + } + + /** + * Interface to manage offsets to central directory records. Regular zip files are + * backed by an {@code int[]} based implementation, Zip64 files are backed by a + * {@code long[]} and will consume more memory. + */ + private interface Offsets { + + void set(int index, long value); + + long get(int index); + + void swap(int i, int j); + + static Offsets from(CentralDirectoryEndRecord endRecord) { + int size = endRecord.getNumberOfRecords(); + return endRecord.isZip64() ? new Zip64Offsets(size) : new ZipOffsets(size); + } + + } + + /** + * {@link Offsets} implementation for regular zip files. + */ + private static final class ZipOffsets implements Offsets { + + private final int[] offsets; + + private ZipOffsets(int size) { + this.offsets = new int[size]; + } + + @Override + public void swap(int i, int j) { + JarFileEntries.swap(this.offsets, i, j); + } + + @Override + public void set(int index, long value) { + this.offsets[index] = (int) value; + } + + @Override + public long get(int index) { + return this.offsets[index]; + } + + } + + /** + * {@link Offsets} implementation for zip64 files. + */ + private static final class Zip64Offsets implements Offsets { + + private final long[] offsets; + + private Zip64Offsets(int size) { + this.offsets = new long[size]; + } + + @Override + public void swap(int i, int j) { + JarFileEntries.swap(this.offsets, i, j); + } + + @Override + public void set(int index, long value) { + this.offsets[index] = value; + } + + @Override + public long get(int index) { + return this.offsets[index]; + } + + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/JarFileWrapper.java b/loader/src/main/java/net/woggioni/envelope/loader/JarFileWrapper.java new file mode 100644 index 0000000..775a33d --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/JarFileWrapper.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.Permission; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; + +/** + * A wrapper used to create a copy of a {@link JarFile} so that it can be safely closed + * without closing the original. + * + * @author Phillip Webb + */ +class JarFileWrapper extends AbstractJarFile { + + private final JarFile parent; + + JarFileWrapper(JarFile parent) throws IOException { + super(parent.getRootJarFile().getFile()); + this.parent = parent; + } + + @Override + URL getUrl() throws MalformedURLException { + return this.parent.getUrl(); + } + + @Override + JarFileType getType() { + return this.parent.getType(); + } + + @Override + Permission getPermission() { + return this.parent.getPermission(); + } + + @Override + public Manifest getManifest() throws IOException { + return this.parent.getManifest(); + } + + @Override + public Enumeration entries() { + return this.parent.entries(); + } + + @Override + public Stream stream() { + return this.parent.stream(); + } + + @Override + public JarEntry getJarEntry(String name) { + return this.parent.getJarEntry(name); + } + + @Override + public ZipEntry getEntry(String name) { + return this.parent.getEntry(name); + } + + @Override + InputStream getInputStream() throws IOException { + return this.parent.getInputStream(); + } + + @Override + public synchronized InputStream getInputStream(ZipEntry ze) throws IOException { + return this.parent.getInputStream(ze); + } + + @Override + public String getComment() { + return this.parent.getComment(); + } + + @Override + public int size() { + return this.parent.size(); + } + + @Override + public String toString() { + return this.parent.toString(); + } + + @Override + public String getName() { + return this.parent.getName(); + } + + static JarFile unwrap(java.util.jar.JarFile jarFile) { + if (jarFile instanceof JarFile) { + return (JarFile) jarFile; + } + if (jarFile instanceof JarFileWrapper) { + return unwrap(((JarFileWrapper) jarFile).parent); + } + throw new IllegalStateException("Not a JarFile or Wrapper"); + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/JarURLConnection.java b/loader/src/main/java/net/woggioni/envelope/loader/JarURLConnection.java new file mode 100644 index 0000000..4a760bf --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/JarURLConnection.java @@ -0,0 +1,407 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.net.URLStreamHandler; +import java.security.Permission; + +/** + * {@link java.net.JarURLConnection} used to support {@link JarFile#getUrl()}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Rostyslav Dudka + */ +final class JarURLConnection extends java.net.JarURLConnection { + + private static ThreadLocal useFastExceptions = new ThreadLocal<>(); + + private static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException( + "Jar file or entry not found"); + + private static final IllegalStateException NOT_FOUND_CONNECTION_EXCEPTION = new IllegalStateException( + FILE_NOT_FOUND_EXCEPTION); + + private static final String SEPARATOR = "!/"; + + private static final URL EMPTY_JAR_URL; + + static { + try { + EMPTY_JAR_URL = new URL("jar:", null, 0, "file:!/", new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL u) throws IOException { + // Stub URLStreamHandler to prevent the wrong JAR Handler from being + // Instantiated and cached. + return null; + } + }); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private static final JarEntryName EMPTY_JAR_ENTRY_NAME = new JarEntryName(new Bytes.StringSequence("")); + + private static final JarURLConnection NOT_FOUND_CONNECTION = JarURLConnection.notFound(); + + private final AbstractJarFile jarFile; + + private Permission permission; + + private URL jarFileUrl; + + private final JarEntryName jarEntryName; + + private java.util.jar.JarEntry jarEntry; + + private JarURLConnection(URL url, AbstractJarFile jarFile, JarEntryName jarEntryName) throws IOException { + // What we pass to super is ultimately ignored + super(EMPTY_JAR_URL); + this.url = url; + this.jarFile = jarFile; + this.jarEntryName = jarEntryName; + } + + @Override + public void connect() throws IOException { + if (this.jarFile == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (!this.jarEntryName.isEmpty() && this.jarEntry == null) { + this.jarEntry = this.jarFile.getJarEntry(getEntryName()); + if (this.jarEntry == null) { + throwFileNotFound(this.jarEntryName, this.jarFile); + } + } + this.connected = true; + } + + @Override + public java.util.jar.JarFile getJarFile() throws IOException { + connect(); + return this.jarFile; + } + + @Override + public URL getJarFileURL() { + if (this.jarFile == null) { + throw NOT_FOUND_CONNECTION_EXCEPTION; + } + if (this.jarFileUrl == null) { + this.jarFileUrl = buildJarFileUrl(); + } + return this.jarFileUrl; + } + + private URL buildJarFileUrl() { + try { + String spec = this.jarFile.getUrl().getFile(); + if (spec.endsWith(SEPARATOR)) { + spec = spec.substring(0, spec.length() - SEPARATOR.length()); + } + if (!spec.contains(SEPARATOR)) { + return new URL(spec); + } + return new URL("jar:" + spec); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + public java.util.jar.JarEntry getJarEntry() throws IOException { + if (this.jarEntryName == null || this.jarEntryName.isEmpty()) { + return null; + } + connect(); + return this.jarEntry; + } + + @Override + public String getEntryName() { + if (this.jarFile == null) { + throw NOT_FOUND_CONNECTION_EXCEPTION; + } + return this.jarEntryName.toString(); + } + + @Override + public InputStream getInputStream() throws IOException { + if (this.jarFile == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (this.jarEntryName.isEmpty() && this.jarFile.getType() == JarFile.JarFileType.DIRECT) { + throw new IOException("no entry name specified"); + } + connect(); + InputStream inputStream = (this.jarEntryName.isEmpty() ? this.jarFile.getInputStream() + : this.jarFile.getInputStream(this.jarEntry)); + if (inputStream == null) { + throwFileNotFound(this.jarEntryName, this.jarFile); + } + return new ConnectionInputStream(inputStream); + } + + private void throwFileNotFound(Object entry, AbstractJarFile jarFile) throws FileNotFoundException { + if (Boolean.TRUE.equals(useFastExceptions.get())) { + throw FILE_NOT_FOUND_EXCEPTION; + } + throw new FileNotFoundException("JAR entry " + entry + " not found in " + jarFile.getName()); + } + + @Override + public int getContentLength() { + long length = getContentLengthLong(); + if (length > Integer.MAX_VALUE) { + return -1; + } + return (int) length; + } + + @Override + public long getContentLengthLong() { + if (this.jarFile == null) { + return -1; + } + try { + if (this.jarEntryName.isEmpty()) { + return this.jarFile.size(); + } + java.util.jar.JarEntry entry = getJarEntry(); + return (entry != null) ? (int) entry.getSize() : -1; + } + catch (IOException ex) { + return -1; + } + } + + @Override + public Object getContent() throws IOException { + connect(); + return this.jarEntryName.isEmpty() ? this.jarFile : super.getContent(); + } + + @Override + public String getContentType() { + return (this.jarEntryName != null) ? this.jarEntryName.getContentType() : null; + } + + @Override + public Permission getPermission() throws IOException { + if (this.jarFile == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (this.permission == null) { + this.permission = this.jarFile.getPermission(); + } + return this.permission; + } + + @Override + public long getLastModified() { + if (this.jarFile == null || this.jarEntryName.isEmpty()) { + return 0; + } + try { + java.util.jar.JarEntry entry = getJarEntry(); + return (entry != null) ? entry.getTime() : 0; + } + catch (IOException ex) { + return 0; + } + } + + static void setUseFastExceptions(boolean useFastExceptions) { + JarURLConnection.useFastExceptions.set(useFastExceptions); + } + + static JarURLConnection get(URL url, JarFile jarFile) throws IOException { + Bytes.StringSequence spec = new Bytes.StringSequence(url.getFile()); + int index = indexOfRootSpec(spec, jarFile.getPathFromRoot()); + if (index == -1) { + return (Boolean.TRUE.equals(useFastExceptions.get()) ? NOT_FOUND_CONNECTION + : new JarURLConnection(url, null, EMPTY_JAR_ENTRY_NAME)); + } + int separator; + while ((separator = spec.indexOf(SEPARATOR, index)) > 0) { + JarEntryName entryName = JarEntryName.get(spec.subSequence(index, separator)); + JarEntry jarEntry = jarFile.getJarEntry(entryName.toCharSequence()); + if (jarEntry == null) { + return JarURLConnection.notFound(jarFile, entryName); + } + jarFile = jarFile.getNestedJarFile(jarEntry); + index = separator + SEPARATOR.length(); + } + JarEntryName jarEntryName = JarEntryName.get(spec, index); + if (Boolean.TRUE.equals(useFastExceptions.get()) && !jarEntryName.isEmpty() + && !jarFile.containsEntry(jarEntryName.toString())) { + return NOT_FOUND_CONNECTION; + } + return new JarURLConnection(url, jarFile.getWrapper(), jarEntryName); + } + + private static int indexOfRootSpec(Bytes.StringSequence file, String pathFromRoot) { + int separatorIndex = file.indexOf(SEPARATOR); + if (separatorIndex < 0 || !file.startsWith(pathFromRoot, separatorIndex)) { + return -1; + } + return separatorIndex + SEPARATOR.length() + pathFromRoot.length(); + } + + private static JarURLConnection notFound() { + try { + return notFound(null, null); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private static JarURLConnection notFound(JarFile jarFile, JarEntryName jarEntryName) throws IOException { + if (Boolean.TRUE.equals(useFastExceptions.get())) { + return NOT_FOUND_CONNECTION; + } + return new JarURLConnection(null, jarFile, jarEntryName); + } + + private class ConnectionInputStream extends FilterInputStream { + + ConnectionInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + JarURLConnection.this.jarFile.close(); + } + + } + + /** + * A JarEntryName parsed from a URL String. + */ + static class JarEntryName { + + private final Bytes.StringSequence name; + + private String contentType; + + JarEntryName(Bytes.StringSequence spec) { + this.name = decode(spec); + } + + private Bytes.StringSequence decode(Bytes.StringSequence source) { + if (source.isEmpty() || (source.indexOf('%') < 0)) { + return source; + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length()); + write(source.toString(), bos); + // AsciiBytes is what is used to store the JarEntries so make it symmetric + return new Bytes.StringSequence(Bytes.AsciiBytes.toString(bos.toByteArray())); + } + + private void write(String source, ByteArrayOutputStream outputStream) { + int length = source.length(); + for (int i = 0; i < length; i++) { + int c = source.charAt(i); + if (c > 127) { + try { + String encoded = URLEncoder.encode(String.valueOf((char) c), "UTF-8"); + write(encoded, outputStream); + } + catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + } + else { + if (c == '%') { + if ((i + 2) >= length) { + throw new IllegalArgumentException( + "Invalid encoded sequence \"" + source.substring(i) + "\""); + } + c = decodeEscapeSequence(source, i); + i += 2; + } + outputStream.write(c); + } + } + } + + private char decodeEscapeSequence(String source, int i) { + int hi = Character.digit(source.charAt(i + 1), 16); + int lo = Character.digit(source.charAt(i + 2), 16); + if (hi == -1 || lo == -1) { + throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); + } + return ((char) ((hi << 4) + lo)); + } + + CharSequence toCharSequence() { + return this.name; + } + + @Override + public String toString() { + return this.name.toString(); + } + + boolean isEmpty() { + return this.name.isEmpty(); + } + + String getContentType() { + if (this.contentType == null) { + this.contentType = deduceContentType(); + } + return this.contentType; + } + + private String deduceContentType() { + // Guess the content type, don't bother with streams as mark is not supported + String type = isEmpty() ? "x-java/jar" : null; + type = (type != null) ? type : guessContentTypeFromName(toString()); + type = (type != null) ? type : "content/unknown"; + return type; + } + + static JarEntryName get(Bytes.StringSequence spec) { + return get(spec, 0); + } + + static JarEntryName get(Bytes.StringSequence spec, int beginIndex) { + if (spec.length() <= beginIndex) { + return EMPTY_JAR_ENTRY_NAME; + } + return new JarEntryName(spec.subSequence(beginIndex)); + } + + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/RandomAccessData.java b/loader/src/main/java/net/woggioni/envelope/loader/RandomAccessData.java new file mode 100644 index 0000000..09212be --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/RandomAccessData.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Interface that provides read-only random access to some underlying data. + * Implementations must allow concurrent reads in a thread-safe manner. + * + * @author Phillip Webb + * @since 1.0.0 + */ +public interface RandomAccessData { + + /** + * Returns an {@link InputStream} that can be used to read the underlying data. The + * caller is responsible close the underlying stream. + * @return a new input stream that can be used to read the underlying data. + * @throws IOException if the stream cannot be opened + */ + InputStream getInputStream() throws IOException; + + /** + * Returns a new {@link RandomAccessData} for a specific subsection of this data. + * @param offset the offset of the subsection + * @param length the length of the subsection + * @return the subsection data + */ + RandomAccessData getSubsection(long offset, long length); + + /** + * Reads all the data and returns it as a byte array. + * @return the data + * @throws IOException if the data cannot be read + */ + byte[] read() throws IOException; + + /** + * Reads the {@code length} bytes of data starting at the given {@code offset}. + * @param offset the offset from which data should be read + * @param length the number of bytes to be read + * @return the data + * @throws IOException if the data cannot be read + * @throws IndexOutOfBoundsException if offset is beyond the end of the file or + * subsection + * @throws EOFException if offset plus length is greater than the length of the file + * or subsection + */ + byte[] read(long offset, long length) throws IOException; + + /** + * Returns the size of the data. + * @return the size + */ + long getSize(); + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/RandomAccessDataFile.java b/loader/src/main/java/net/woggioni/envelope/loader/RandomAccessDataFile.java new file mode 100644 index 0000000..1dc195d --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/RandomAccessDataFile.java @@ -0,0 +1,260 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.EOFException; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; + +/** + * {@link RandomAccessData} implementation backed by a {@link RandomAccessFile}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class RandomAccessDataFile implements RandomAccessData { + + private final FileAccess fileAccess; + + private final long offset; + + private final long length; + + /** + * Create a new {@link RandomAccessDataFile} backed by the specified file. + * @param file the underlying file + * @throws IllegalArgumentException if the file is null or does not exist + */ + public RandomAccessDataFile(File file) { + if (file == null) { + throw new IllegalArgumentException("File must not be null"); + } + this.fileAccess = new FileAccess(file); + this.offset = 0L; + this.length = file.length(); + } + + /** + * Private constructor used to create a {@link #getSubsection(long, long) subsection}. + * @param fileAccess provides access to the underlying file + * @param offset the offset of the section + * @param length the length of the section + */ + private RandomAccessDataFile(FileAccess fileAccess, long offset, long length) { + this.fileAccess = fileAccess; + this.offset = offset; + this.length = length; + } + + /** + * Returns the underlying File. + * @return the underlying file + */ + public File getFile() { + return this.fileAccess.file; + } + + @Override + public InputStream getInputStream() throws IOException { + return new DataInputStream(); + } + + @Override + public RandomAccessData getSubsection(long offset, long length) { + if (offset < 0 || length < 0 || offset + length > this.length) { + throw new IndexOutOfBoundsException(); + } + return new RandomAccessDataFile(this.fileAccess, this.offset + offset, length); + } + + @Override + public byte[] read() throws IOException { + return read(0, this.length); + } + + @Override + public byte[] read(long offset, long length) throws IOException { + if (offset > this.length) { + throw new IndexOutOfBoundsException(); + } + if (offset + length > this.length) { + throw new EOFException(); + } + byte[] bytes = new byte[(int) length]; + read(bytes, offset, 0, bytes.length); + return bytes; + } + + private int readByte(long position) throws IOException { + if (position >= this.length) { + return -1; + } + return this.fileAccess.readByte(this.offset + position); + } + + private int read(byte[] bytes, long position, int offset, int length) throws IOException { + if (position > this.length) { + return -1; + } + return this.fileAccess.read(bytes, this.offset + position, offset, length); + } + + @Override + public long getSize() { + return this.length; + } + + public void close() throws IOException { + this.fileAccess.close(); + } + + /** + * {@link InputStream} implementation for the {@link RandomAccessDataFile}. + */ + private class DataInputStream extends InputStream { + + private int position; + + @Override + public int read() throws IOException { + int read = RandomAccessDataFile.this.readByte(this.position); + if (read > -1) { + moveOn(1); + } + return read; + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, (b != null) ? b.length : 0); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (b == null) { + throw new NullPointerException("Bytes must not be null"); + } + return doRead(b, off, len); + } + + /** + * Perform the actual read. + * @param b the bytes to read or {@code null} when reading a single byte + * @param off the offset of the byte array + * @param len the length of data to read + * @return the number of bytes read into {@code b} or the actual read byte if + * {@code b} is {@code null}. Returns -1 when the end of the stream is reached + * @throws IOException in case of I/O errors + */ + int doRead(byte[] b, int off, int len) throws IOException { + if (len == 0) { + return 0; + } + int cappedLen = cap(len); + if (cappedLen <= 0) { + return -1; + } + return (int) moveOn(RandomAccessDataFile.this.read(b, this.position, off, cappedLen)); + } + + @Override + public long skip(long n) throws IOException { + return (n <= 0) ? 0 : moveOn(cap(n)); + } + + @Override + public int available() throws IOException { + return (int) RandomAccessDataFile.this.length - this.position; + } + + /** + * Cap the specified value such that it cannot exceed the number of bytes + * remaining. + * @param n the value to cap + * @return the capped value + */ + private int cap(long n) { + return (int) Math.min(RandomAccessDataFile.this.length - this.position, n); + } + + /** + * Move the stream position forwards the specified amount. + * @param amount the amount to move + * @return the amount moved + */ + private long moveOn(int amount) { + this.position += amount; + return amount; + } + + } + + private static final class FileAccess { + + private final Object monitor = new Object(); + + private final File file; + + private RandomAccessFile randomAccessFile; + + private FileAccess(File file) { + this.file = file; + openIfNecessary(); + } + + private int read(byte[] bytes, long position, int offset, int length) throws IOException { + synchronized (this.monitor) { + openIfNecessary(); + this.randomAccessFile.seek(position); + return this.randomAccessFile.read(bytes, offset, length); + } + } + + private void openIfNecessary() { + if (this.randomAccessFile == null) { + try { + this.randomAccessFile = new RandomAccessFile(this.file, "r"); + } + catch (FileNotFoundException ex) { + throw new IllegalArgumentException( + String.format("File %s must exist", this.file.getAbsolutePath())); + } + } + } + + private void close() throws IOException { + synchronized (this.monitor) { + if (this.randomAccessFile != null) { + this.randomAccessFile.close(); + this.randomAccessFile = null; + } + } + } + + private int readByte(long position) throws IOException { + synchronized (this.monitor) { + openIfNecessary(); + this.randomAccessFile.seek(position); + return this.randomAccessFile.read(); + } + } + } +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/ZipInflaterInputStream.java b/loader/src/main/java/net/woggioni/envelope/loader/ZipInflaterInputStream.java new file mode 100644 index 0000000..f048224 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/ZipInflaterInputStream.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed 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 + * + * https://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 net.woggioni.envelope.loader; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +/** + * {@link InflaterInputStream} that supports the writing of an extra "dummy" byte (which + * is required with JDK 6) and returns accurate available() results. + * + * @author Phillip Webb + */ +class ZipInflaterInputStream extends InflaterInputStream { + + private int available; + + private boolean extraBytesWritten; + + ZipInflaterInputStream(InputStream inputStream, int size) { + super(inputStream, new Inflater(true), getInflaterBufferSize(size)); + this.available = size; + } + + @Override + public int available() throws IOException { + if (this.available < 0) { + return super.available(); + } + return this.available; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int result = super.read(b, off, len); + if (result != -1) { + this.available -= result; + } + return result; + } + + @Override + public void close() throws IOException { + super.close(); + this.inf.end(); + } + + @Override + protected void fill() throws IOException { + try { + super.fill(); + } + catch (EOFException ex) { + if (this.extraBytesWritten) { + throw ex; + } + this.len = 1; + this.buf[0] = 0x0; + this.extraBytesWritten = true; + this.inf.setInput(this.buf, 0, this.len); + } + } + + private static int getInflaterBufferSize(long size) { + size += 2; // inflater likes some space + size = (size > 65536) ? 8192 : size; + size = (size <= 0) ? 4096 : size; + return (int) size; + } + +} diff --git a/loader/src/main/java/net/woggioni/envelope/loader/package-info.java b/loader/src/main/java/net/woggioni/envelope/loader/package-info.java new file mode 100644 index 0000000..4573c40 --- /dev/null +++ b/loader/src/main/java/net/woggioni/envelope/loader/package-info.java @@ -0,0 +1,4 @@ +/** + * Support for loading and manipulating JAR/WAR files. + */ +package net.woggioni.envelope.loader; diff --git a/loader/src/main/java11/module-info.java b/loader/src/main/java11/module-info.java new file mode 100644 index 0000000..885e693 --- /dev/null +++ b/loader/src/main/java11/module-info.java @@ -0,0 +1,5 @@ +module net.woggioni.envelope.loader { + requires java.logging; + requires static lombok; + exports net.woggioni.envelope.loader; +} \ No newline at end of file diff --git a/loader/src/main/java11/net.woggioni.envelope.loader/JarFileModuleFinder.java b/loader/src/main/java11/net.woggioni.envelope.loader/JarFileModuleFinder.java new file mode 100644 index 0000000..5c4da81 --- /dev/null +++ b/loader/src/main/java11/net.woggioni.envelope.loader/JarFileModuleFinder.java @@ -0,0 +1,217 @@ +package net.woggioni.envelope.loader; + +import lombok.SneakyThrows; +import net.woggioni.envelope.loader.JarFile; +import net.woggioni.envelope.loader.Handler; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.module.ModuleDescriptor; +import java.lang.module.ModuleFinder; +import java.lang.module.ModuleReader; +import java.lang.module.ModuleReference; +import java.net.URI; +import java.net.URL; +import java.net.URLStreamHandler; +import java.nio.file.Paths; +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.jar.Attributes.Name; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class JarFileModuleFinder implements ModuleFinder { + + private static final String MODULE_DESCRIPTOR_ENTRY = "module-info.class"; + private static final Name AUTOMATIC_MODULE_NAME_MANIFEST_ENTRY = new Name("Automatic-Module-Name"); + + private static class Patterns { + static final Pattern DASH_VERSION = Pattern.compile("-(\\d+(\\.|$))"); + static final Pattern NON_ALPHANUM = Pattern.compile("[^A-Za-z0-9]"); + static final Pattern REPEATING_DOTS = Pattern.compile("(\\.)(\\1)+"); + static final Pattern LEADING_DOTS = Pattern.compile("^\\."); + static final Pattern TRAILING_DOTS = Pattern.compile("\\.$"); + } + + private final Map> modules; + + @SneakyThrows + private static final URI toURI(URL url) { + return url.toURI(); + } + + public final URLStreamHandler getStreamHandlerForModule(String moduleName) { + return modules.get(moduleName).getValue(); + } + + private static String cleanModuleName(String mn) { + // replace non-alphanumeric + mn = Patterns.NON_ALPHANUM.matcher(mn).replaceAll("."); + + // collapse repeating dots + mn = Patterns.REPEATING_DOTS.matcher(mn).replaceAll("."); + + // drop leading dots + if (!mn.isEmpty() && mn.charAt(0) == '.') + mn = Patterns.LEADING_DOTS.matcher(mn).replaceAll(""); + + // drop trailing dots + int len = mn.length(); + if (len > 0 && mn.charAt(len-1) == '.') + mn = Patterns.TRAILING_DOTS.matcher(mn).replaceAll(""); + + return mn; + } + + @SneakyThrows + private static String moduleNameFromURI(URI uri) { + String fn = null; + URI tmp = uri; + while(true) { + String schemeSpecificPart = tmp.getSchemeSpecificPart(); + if(tmp.getPath() != null) { + String path = tmp.getPath(); + int end = path.lastIndexOf("!"); + if(end == -1) end = path.length(); + int start = path.lastIndexOf("!", end - 1); + if(start == -1) start = 0; + fn = Paths.get(path.substring(start, end)).getFileName().toString(); + break; + } else { + tmp = new URI(schemeSpecificPart); + } + } + // Derive the version, and the module name if needed, from JAR file name + int i = fn.lastIndexOf(File.separator); + if (i != -1) + fn = fn.substring(i + 1); + + // drop ".jar" + String name = fn.substring(0, fn.length() - 4); + String vs = null; + + // find first occurrence of -${NUMBER}. or -${NUMBER}$ + Matcher matcher = Patterns.DASH_VERSION.matcher(name); + if (matcher.find()) { + int start = matcher.start(); + + // attempt to parse the tail as a version string + try { + String tail = name.substring(start + 1); + ModuleDescriptor.Version.parse(tail); + vs = tail; + } catch (IllegalArgumentException ignore) { } + + name = name.substring(0, start); + } + return cleanModuleName(name); + } + + public JarFileModuleFinder(JarFile ...jarFiles) { + this(Arrays.asList(jarFiles)); + } + + + private static final String META_INF_VERSION_PREFIX = "META-INF/versions/"; + private static Set collectPackageNames(JarFile jarFile) { + Set result = jarFile + .stream() + .filter(entry -> entry.getName().endsWith(".class")) + .map(entry -> { + String entryName = entry.getName(); + int lastSlash = entryName.lastIndexOf('/'); + if(lastSlash < 0) return null; + else return entryName.substring(0, lastSlash).replace('/', '.'); + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return Collections.unmodifiableSet(result); + } + @SneakyThrows + public JarFileModuleFinder(Iterable jarFiles) { + + TreeMap> modules = new TreeMap<>(); + + for(JarFile jarFile : jarFiles) { + URI uri = jarFile.getUrl().toURI(); + String moduleName = null; + ModuleDescriptor moduleDescriptor; + JarEntry moduleDescriptorEntry = jarFile.getJarEntry(MODULE_DESCRIPTOR_ENTRY); + if (moduleDescriptorEntry != null) { + try(InputStream is = jarFile.getInputStream(moduleDescriptorEntry)) { + moduleDescriptor = ModuleDescriptor.read(is, () -> collectPackageNames(jarFile)); + } + } else { + Manifest mf = jarFile.getManifest(); + moduleName = mf.getMainAttributes().getValue(AUTOMATIC_MODULE_NAME_MANIFEST_ENTRY); + if(moduleName == null) { + moduleName = moduleNameFromURI(uri); + } + ModuleDescriptor.Builder mdb = ModuleDescriptor.newAutomaticModule(moduleName); + mdb.packages(collectPackageNames(jarFile)); + + // Main-Class attribute if it exists + String mainClass = mf.getMainAttributes().getValue(Name.MAIN_CLASS); + if (mainClass != null) { + mdb.mainClass(mainClass); + } + moduleDescriptor = mdb.build(); + } + + modules.put(moduleDescriptor.name(), + new AbstractMap.SimpleEntry<>(new ModuleReference(moduleDescriptor, uri) { + @Override + public ModuleReader open() throws IOException { + return new ModuleReader() { + @Override + public Optional find(String name) throws IOException { + JarEntry jarEntry = jarFile.getJarEntry(name); + if(jarEntry == null) return Optional.empty(); + return Optional.of(uri.resolve('!' + name)); + } + + @Override + public Optional open(String name) throws IOException { + JarEntry jarEntry = jarFile.getJarEntry(name); + if(jarEntry == null) return Optional.empty(); + return Optional.of(jarFile.getInputStream(jarEntry)); + } + + @Override + public Stream list() throws IOException { + return jarFile.stream().map(JarEntry::getName); + } + + @Override + public void close() throws IOException {} + }; + } + }, new Handler(jarFile))); + } + this.modules = Collections.unmodifiableMap(modules); + } + + @Override + public Optional find(String name) { + return Optional.ofNullable(modules.get(name)).map(Map.Entry::getKey); + } + + @Override + public Set findAll() { + return Collections.unmodifiableSet(modules.values() + .stream().map(Map.Entry::getKey) + .collect(Collectors.toSet())); + } +} diff --git a/loader/src/main/java11/net.woggioni.envelope.loader/ModuleClassLoader.java b/loader/src/main/java11/net.woggioni.envelope.loader/ModuleClassLoader.java new file mode 100644 index 0000000..48d9671 --- /dev/null +++ b/loader/src/main/java11/net.woggioni.envelope.loader/ModuleClassLoader.java @@ -0,0 +1,158 @@ +package net.woggioni.envelope.loader; + +import java.lang.module.ModuleReader; +import java.lang.module.ModuleReference; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; +import java.util.function.Function; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; + +import java.net.URI; +import java.net.URL; +import java.net.URLStreamHandler; +import java.nio.ByteBuffer; +import java.lang.module.ResolvedModule; +import java.lang.module.Configuration; +import java.security.CodeSource; +import java.security.SecureClassLoader; +import java.security.CodeSigner; + +@RequiredArgsConstructor +public final class ModuleClassLoader extends SecureClassLoader { + + static { + registerAsParallelCapable(); + } + + private static String className2ResourceName(String className) { + return className.replace('.', '/') + ".class"; + } + + private static String packageName(String cn) { + int pos = cn.lastIndexOf('.'); + return (pos < 0) ? "" : cn.substring(0, pos); + } + + private final Map packageMap; + private final ModuleReference moduleReference; + + private final Function urlConverter; + + @Override + protected Class loadClass(String className, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(className)) { + Class result = findLoadedClass(className); + if (result == null) { + result = findClass(className); + if(result == null) { + ClassLoader classLoader = packageMap.get(packageName(className)); + if (classLoader != null && classLoader != this) { + result = classLoader.loadClass(className); + } + if(result == null) { + result = super.loadClass(className, resolve); + } + } else if(resolve) { + resolveClass(result); + } + } + return result; + } + } + + @Override + protected URL findResource(String moduleName, String name) throws IOException { + if (Objects.equals(moduleReference.descriptor().name(), moduleName)) { + return findResource(name); + } else { + return null; + } + } + + @Override + @SneakyThrows + protected URL findResource(String resource) { + try(ModuleReader reader = moduleReference.open()) { + Optional byteBufferOptional = reader.read(resource); + if (byteBufferOptional.isPresent()) { + ByteBuffer byteBuffer = byteBufferOptional.get(); + try { + return moduleReference.location() + .map(new Function() { + @SneakyThrows + public URI apply(URI uri) { + return new URI(uri.toString() + resource); + } + }).map(urlConverter).orElse(null); + } finally { + reader.release(byteBuffer); + } + } else { + return null; + } + } + } + + @Override + protected Enumeration findResources(final String resource) throws IOException { + return new Enumeration() { + private URL url = findResource(resource); + + public boolean hasMoreElements() { + return url != null; + } + + public URL nextElement() { + URL result = url; + url = null; + return result; + } + }; + } + + + @Override + @SneakyThrows + protected Class findClass(String className) throws ClassNotFoundException { + Class result = findClass(moduleReference.descriptor().name(), className); + return result; + } + + @Override + @SneakyThrows + protected Class findClass(String moduleName, String className) { + if (Objects.equals(moduleReference.descriptor().name(), moduleName)) { + String resource = className.replace('.', '/').concat(".class"); + Optional byteBufferOptional; + try(ModuleReader reader = moduleReference.open()) { + byteBufferOptional = reader.read(resource); + if (byteBufferOptional.isPresent()) { + ByteBuffer byteBuffer = byteBufferOptional.get(); + try { + URL location = moduleReference + .location() + .map(urlConverter) + .orElse(null); + CodeSource codeSource = new CodeSource(location, (CodeSigner[]) null); + return defineClass(className, byteBuffer, codeSource); + } finally { + reader.release(byteBuffer); + } + } else { + return null; + } + } + } else { + return null; + } + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 3daaa20..8c95110 100644 --- a/settings.gradle +++ b/settings.gradle @@ -24,3 +24,4 @@ rootProject.name = 'envelope' include 'common' include 'launcher' +include 'loader' diff --git a/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java b/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java index 731dd77..7368a9a 100644 --- a/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java +++ b/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java @@ -24,9 +24,12 @@ import javax.annotation.Nonnull; import javax.inject.Inject; import java.io.File; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Properties; @@ -119,11 +122,19 @@ public class EnvelopeJarTask extends AbstractArchiveTask { private final ZipEntryFactory zipEntryFactory; private final byte[] buffer; + private final List libraries; + + private static final String LIBRARY_PREFIX = Constants.LIBRARIES_FOLDER + '/'; + @Override @SneakyThrows public void processFile(FileCopyDetailsInternal fileCopyDetails) { String entryName = fileCopyDetails.getRelativePath().toString(); - if (!fileCopyDetails.isDirectory() && entryName.startsWith(Constants.LIBRARIES_FOLDER)) { + int start = LIBRARY_PREFIX.length() + 1; + if (!fileCopyDetails.isDirectory() && + entryName.startsWith(LIBRARY_PREFIX) && + entryName.indexOf('/', start) < 0) { + libraries.add(entryName.substring(LIBRARY_PREFIX.length())); Supplier streamSupplier = () -> Common.read(fileCopyDetails.getFile(), false); Attributes attr = manifest.getEntries().computeIfAbsent(entryName, it -> new Attributes()); md.reset(); @@ -231,6 +242,7 @@ public class EnvelopeJarTask extends AbstractArchiveTask { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] buffer = new byte[Constants.BUFFER_SIZE]; + List libraries = new ArrayList<>(); /** * The manifest has to be the first zip entry in a jar archive, as an example, @@ -247,7 +259,8 @@ public class EnvelopeJarTask extends AbstractArchiveTask { File temporaryJar = new File(getTemporaryDir(), "premature.zip"); try (ZipOutputStream zipOutputStream = new ZipOutputStream(Common.write(temporaryJar, true))) { zipOutputStream.setLevel(NO_COMPRESSION); - StreamAction streamAction = new StreamAction(zipOutputStream, manifest, md, zipEntryFactory, buffer); + StreamAction streamAction = new StreamAction( + zipOutputStream, manifest, md, zipEntryFactory, buffer, libraries); copyActionProcessingStream.process(streamAction); } @@ -272,6 +285,15 @@ public class EnvelopeJarTask extends AbstractArchiveTask { props.setProperty(entry.getKey(), entry.getValue()); } props.store(zipOutputStream, null); + zipEntry = zipEntryFactory.createZipEntry(Constants.LIBRARIES_TOC); + zipEntry.setMethod(ZipEntry.DEFLATED); + zipOutputStream.putNextEntry(zipEntry); + int i = 0; + while(i < libraries.size()) { + if(i > 0) zipOutputStream.write('/'); + zipOutputStream.write(libraries.get(i).getBytes(StandardCharsets.UTF_8)); + ++i; + } while (true) { zipEntry = zipInputStream.getNextEntry(); diff --git a/src/main/java/net/woggioni/gradle/envelope/EnvelopePlugin.java b/src/main/java/net/woggioni/gradle/envelope/EnvelopePlugin.java index b8f442b..6a936cb 100644 --- a/src/main/java/net/woggioni/gradle/envelope/EnvelopePlugin.java +++ b/src/main/java/net/woggioni/gradle/envelope/EnvelopePlugin.java @@ -9,9 +9,6 @@ import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.provider.Provider; import org.gradle.api.tasks.JavaExec; import org.gradle.api.tasks.bundling.Jar; -import org.gradle.process.CommandLineArgumentProvider; - -import java.util.Arrays; public class EnvelopePlugin implements Plugin { @Override