From a90d5c5ce721f0896fa28711134bd4d948b43272 Mon Sep 17 00:00:00 2001 From: aoshiguchen <1052045476@qq.com> Date: Thu, 23 Feb 2023 18:48:36 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E5=8A=A8=E6=80=81=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=B1=BB=E4=BE=9D=E8=B5=96jar=20in=20jar=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E6=8A=A5=E6=89=BE=E4=B8=8D=E5=88=B0=E7=B1=BB=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aop/compiler/CustomJavaFileObject.java | 4 + .../aop/compiler/SpringBootJarParser.java | 50 ++ .../boot/loader/ClassPathIndexFile.java | 119 +++ .../loader/ExecutableArchiveLauncher.java | 215 ++++++ .../boot/loader/JarLauncher.java | 68 ++ .../boot/loader/LaunchedURLClassLoader.java | 366 +++++++++ .../springframework/boot/loader/Launcher.java | 159 ++++ .../boot/loader/MainMethodRunner.java | 52 ++ .../boot/loader/MyJarLauncher.java | 97 +++ .../boot/loader/PropertiesLauncher.java | 716 ++++++++++++++++++ .../boot/loader/WarLauncher.java | 62 ++ .../boot/loader/archive/Archive.java | 115 +++ .../boot/loader/archive/ExplodedArchive.java | 334 ++++++++ .../boot/loader/archive/JarFileArchive.java | 306 ++++++++ .../boot/loader/archive/package-info.java | 23 + .../boot/loader/data/RandomAccessData.java | 74 ++ .../loader/data/RandomAccessDataFile.java | 257 +++++++ .../boot/loader/data/package-info.java | 22 + .../boot/loader/jar/AbstractJarFile.java | 78 ++ .../boot/loader/jar/AsciiBytes.java | 255 +++++++ .../boot/loader/jar/Bytes.java | 37 + .../loader/jar/CentralDirectoryEndRecord.java | 258 +++++++ .../jar/CentralDirectoryFileHeader.java | 222 ++++++ .../loader/jar/CentralDirectoryParser.java | 101 +++ .../loader/jar/CentralDirectoryVisitor.java | 34 + .../boot/loader/jar/FileHeader.java | 64 ++ .../boot/loader/jar/Handler.java | 464 ++++++++++++ .../boot/loader/jar/JarEntry.java | 120 +++ .../loader/jar/JarEntryCertification.java | 58 ++ .../boot/loader/jar/JarEntryFilter.java | 35 + .../boot/loader/jar/JarFile.java | 476 ++++++++++++ .../boot/loader/jar/JarFileEntries.java | 486 ++++++++++++ .../boot/loader/jar/JarFileWrapper.java | 126 +++ .../boot/loader/jar/JarURLConnection.java | 385 ++++++++++ .../boot/loader/jar/StringSequence.java | 158 ++++ .../loader/jar/ZipInflaterInputStream.java | 88 +++ .../boot/loader/jar/package-info.java | 20 + .../boot/loader/jarmode/JarMode.java | 42 + .../boot/loader/jarmode/JarModeLauncher.java | 51 ++ .../boot/loader/jarmode/TestJarMode.java | 38 + .../boot/loader/jarmode/package-info.java | 22 + .../boot/loader/package-info.java | 26 + .../boot/loader/util/SystemPropertyUtils.java | 232 ++++++ .../boot/loader/util/package-info.java | 20 + .../core/aop/compiler/AsgcCompilerTest.java | 40 +- 45 files changed, 6967 insertions(+), 8 deletions(-) create mode 100644 neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/SpringBootJarParser.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/JarLauncher.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/Launcher.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/MainMethodRunner.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/MyJarLauncher.java create mode 100755 neutrino-core/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/WarLauncher.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/archive/Archive.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java create mode 100755 neutrino-core/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/archive/package-info.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/data/package-info.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/Bytes.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/FileHeader.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/Handler.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntry.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFile.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/StringSequence.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jar/package-info.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/package-info.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/package-info.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java create mode 100644 neutrino-core/src/main/java/org/springframework/boot/loader/util/package-info.java diff --git a/neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/CustomJavaFileObject.java b/neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/CustomJavaFileObject.java index 2bb5ce9c..e34e89bc 100644 --- a/neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/CustomJavaFileObject.java +++ b/neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/CustomJavaFileObject.java @@ -50,6 +50,10 @@ public class CustomJavaFileObject implements JavaFileObject { @Override public InputStream openInputStream() throws IOException { + InputStream in = SpringBootJarParser.getInputStream(uri); + if (null != in) { + return in; + } return uri.toURL().openStream(); } diff --git a/neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/SpringBootJarParser.java b/neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/SpringBootJarParser.java new file mode 100644 index 00000000..59d96551 --- /dev/null +++ b/neutrino-core/src/main/java/fun/asgc/neutrino/core/aop/compiler/SpringBootJarParser.java @@ -0,0 +1,50 @@ +package fun.asgc.neutrino.core.aop.compiler; + +import org.springframework.boot.loader.jar.JarFile; +import org.springframework.boot.loader.jar.JarURLConnection; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; + +/** + * @author: aoshiguchen + * @date: 2023/2/23 + */ +public class SpringBootJarParser { + + /** + * 根据一个URI从SpringBoot jar包中解析出输入流 + * 1、先尝试直接获取流,若能获取则到此结束 + * 2、若不能获取,且URI标识定位的是一个jar包,则进行处理(只处理SpringBoot情况的jar) + * @param uri + * @return + */ + public static InputStream getInputStream(URI uri) { + try { + return uri.toURL().openStream(); + } catch (Exception e) { + try { + if (e instanceof FileNotFoundException && uri.getScheme().equals("jar")) { + URL url = uri.toURL(); + String path = url.getPath(); + int index = path.indexOf("!"); + if (index >= 0) { + path = path.substring(0, index); + } + if (path.startsWith("file:")) { + path = path.substring(5); + } + JarFile jarFile = new JarFile(new File(path)); + return JarURLConnection.get(url, jarFile).getInputStream(); + } + } catch (Exception e1) { + // ignore + System.out.println("1"); + } + } + return null; + } +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java b/neutrino-core/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java new file mode 100644 index 00000000..ce1334fd --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java @@ -0,0 +1,119 @@ +/* + * 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 org.springframework.boot.loader; + +import java.io.*; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * A class path index file that provides ordering information for JARs. + * + * @author Madhura Bhave + * @author Phillip Webb + */ +final class ClassPathIndexFile { + + private final File root; + + private final List lines; + + private ClassPathIndexFile(File root, List lines) { + this.root = root; + this.lines = lines.stream().map(this::extractName).collect(Collectors.toList()); + } + + private String extractName(String line) { + if (line.startsWith("- \"") && line.endsWith("\"")) { + return line.substring(3, line.length() - 1); + } + throw new IllegalStateException("Malformed classpath index line [" + line + "]"); + } + + int size() { + return this.lines.size(); + } + + boolean containsEntry(String name) { + if (name == null || name.isEmpty()) { + return false; + } + return this.lines.contains(name); + } + + List getUrls() { + return this.lines.stream().map(this::asUrl).collect(Collectors.toList()); + } + + private URL asUrl(String line) { + try { + return new File(this.root, line).toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + static ClassPathIndexFile loadIfPossible(URL root, String location) throws IOException { + return loadIfPossible(asFile(root), location); + } + + private static ClassPathIndexFile loadIfPossible(File root, String location) throws IOException { + return loadIfPossible(root, new File(root, location)); + } + + private static ClassPathIndexFile loadIfPossible(File root, File indexFile) throws IOException { + if (indexFile.exists() && indexFile.isFile()) { + try (InputStream inputStream = new FileInputStream(indexFile)) { + return new ClassPathIndexFile(root, loadLines(inputStream)); + } + } + return null; + } + + private static List loadLines(InputStream inputStream) throws IOException { + List lines = new ArrayList<>(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + String line = reader.readLine(); + while (line != null) { + if (!line.trim().isEmpty()) { + lines.add(line); + } + line = reader.readLine(); + } + return Collections.unmodifiableList(lines); + } + + private static File asFile(URL url) { + if (!"file".equals(url.getProtocol())) { + throw new IllegalArgumentException("URL does not reference a file"); + } + try { + return new File(url.toURI()); + } + catch (URISyntaxException ex) { + return new File(url.getPath()); + } + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java b/neutrino-core/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java new file mode 100644 index 00000000..2302de4f --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java @@ -0,0 +1,215 @@ +/* + * 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 org.springframework.boot.loader; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +/** + * Base class for executable archive {@link Launcher}s. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 1.0.0 + */ +public abstract class ExecutableArchiveLauncher extends Launcher { + + private static final String START_CLASS_ATTRIBUTE = "Start-Class"; + + protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index"; + + protected static final String DEFAULT_CLASSPATH_INDEX_FILE_NAME = "classpath.idx"; + + private Archive archive; + + private ClassPathIndexFile classPathIndex; + + public ExecutableArchiveLauncher() { + try { + this.archive = createArchive(); + this.classPathIndex = getClassPathIndex(this.archive); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + protected ExecutableArchiveLauncher(Archive archive) { + try { + this.archive = archive; + this.classPathIndex = getClassPathIndex(this.archive); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + protected ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException { + // Only needed for exploded archives, regular ones already have a defined order + if (archive instanceof ExplodedArchive) { + String location = getClassPathIndexFileLocation(archive); + return ClassPathIndexFile.loadIfPossible(archive.getUrl(), location); + } + return null; + } + + private String getClassPathIndexFileLocation(Archive archive) throws IOException { + Manifest manifest = archive.getManifest(); + Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null; + String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null; + return (location != null) ? location : getArchiveEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME; + } + + @Override + protected String getMainClass() throws Exception { + Manifest manifest = this.archive.getManifest(); + String mainClass = null; + if (manifest != null) { + mainClass = manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE); + } + if (mainClass == null) { + throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this); + } + return mainClass; + } + + @Override + protected ClassLoader createClassLoader(Iterator archives) throws Exception { + List urls = new ArrayList<>(guessClassPathSize()); + while (archives.hasNext()) { + urls.add(archives.next().getUrl()); + } + if (this.classPathIndex != null) { + urls.addAll(this.classPathIndex.getUrls()); + } + return createClassLoader(urls.toArray(new URL[0])); + } + + private int guessClassPathSize() { + if (this.classPathIndex != null) { + return this.classPathIndex.size() + 10; + } + return 50; + } + + @Override + protected Iterator getClassPathArchivesIterator() throws Exception { + Archive.EntryFilter searchFilter = this::isSearchCandidate; + Iterator archives = this.archive.getNestedArchives(searchFilter, + (entry) -> isNestedArchive(entry) && !isEntryIndexed(entry)); + if (isPostProcessingClassPathArchives()) { + archives = applyClassPathArchivePostProcessing(archives); + } + return archives; + } + + private boolean isEntryIndexed(Archive.Entry entry) { + if (this.classPathIndex != null) { + return this.classPathIndex.containsEntry(entry.getName()); + } + return false; + } + + private Iterator applyClassPathArchivePostProcessing(Iterator archives) throws Exception { + List list = new ArrayList<>(); + while (archives.hasNext()) { + list.add(archives.next()); + } + postProcessClassPathArchives(list); + return list.iterator(); + } + + /** + * Determine if the specified entry is a candidate for further searching. + * @param entry the entry to check + * @return {@code true} if the entry is a candidate for further searching + * @since 2.3.0 + */ + protected boolean isSearchCandidate(Archive.Entry entry) { + if (getArchiveEntryPathPrefix() == null) { + return true; + } + return entry.getName().startsWith(getArchiveEntryPathPrefix()); + } + + /** + * Determine if the specified entry is a nested item that should be added to the + * classpath. + * @param entry the entry to check + * @return {@code true} if the entry is a nested item (jar or directory) + */ + protected abstract boolean isNestedArchive(Archive.Entry entry); + + /** + * Return if post-processing needs to be applied to the archives. For back + * compatibility this method returns {@code true}, but subclasses that don't override + * {@link #postProcessClassPathArchives(List)} should provide an implementation that + * returns {@code false}. + * @return if the {@link #postProcessClassPathArchives(List)} method is implemented + * @since 2.3.0 + */ + protected boolean isPostProcessingClassPathArchives() { + return true; + } + + /** + * Called to post-process archive entries before they are used. Implementations can + * add and remove entries. + * @param archives the archives + * @throws Exception if the post-processing fails + * @see #isPostProcessingClassPathArchives() + */ + protected void postProcessClassPathArchives(List archives) throws Exception { + } + + /** + * Return the path prefix for entries in the archive. + * @return the path prefix + */ + protected String getArchiveEntryPathPrefix() { + return null; + } + + @Override + protected boolean isExploded() { + return this.archive.isExploded(); + } + + @Override + protected final Archive getArchive() { + return this.archive; + } + + + protected void setArchive(Archive archive) { + this.archive = archive; + } + + protected void setClassPathIndex(ClassPathIndexFile classPathIndex) { + this.classPathIndex = classPathIndex; + } +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/JarLauncher.java b/neutrino-core/src/main/java/org/springframework/boot/loader/JarLauncher.java new file mode 100644 index 00000000..2c86b3d4 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/JarLauncher.java @@ -0,0 +1,68 @@ +/* + * 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 org.springframework.boot.loader; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.Archive.EntryFilter; + +/** + * {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are + * included inside a {@code /BOOT-INF/lib} directory and that application classes are + * included inside a {@code /BOOT-INF/classes} directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 1.0.0 + */ +public class JarLauncher extends ExecutableArchiveLauncher { + + static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> { + if (entry.isDirectory()) { + return entry.getName().equals("BOOT-INF/classes/"); + } + return entry.getName().startsWith("BOOT-INF/lib/"); + }; + + public JarLauncher() { + } + + protected JarLauncher(Archive archive) { + super(archive); + } + + @Override + protected boolean isPostProcessingClassPathArchives() { + return false; + } + + @Override + protected boolean isNestedArchive(Archive.Entry entry) { + return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry); + } + + @Override + protected String getArchiveEntryPathPrefix() { + return "BOOT-INF/"; + } + + public static void main(String[] args) throws Exception { + new JarLauncher().launch(args); + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java b/neutrino-core/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java new file mode 100644 index 00000000..583ae498 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java @@ -0,0 +1,366 @@ +/* + * 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 org.springframework.boot.loader; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.jar.Handler; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLConnection; +import java.util.Enumeration; +import java.util.function.Supplier; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +/** + * {@link ClassLoader} used by the {@link Launcher}. + * + * @author Phillip Webb + * @author Dave Syer + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class LaunchedURLClassLoader extends URLClassLoader { + + private static final int BUFFER_SIZE = 4096; + + static { + ClassLoader.registerAsParallelCapable(); + } + + private final boolean exploded; + + private final Archive rootArchive; + + private final Object packageLock = new Object(); + + private volatile DefinePackageCallType definePackageCallType; + + /** + * Create a new {@link LaunchedURLClassLoader} instance. + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) { + this(false, urls, parent); + } + + /** + * Create a new {@link LaunchedURLClassLoader} instance. + * @param exploded if the underlying archive is exploded + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public LaunchedURLClassLoader(boolean exploded, URL[] urls, ClassLoader parent) { + this(exploded, null, urls, parent); + } + + /** + * Create a new {@link LaunchedURLClassLoader} instance. + * @param exploded if the underlying archive is exploded + * @param rootArchive the root archive or {@code null} + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + * @since 2.3.1 + */ + public LaunchedURLClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) { + super(urls, parent); + this.exploded = exploded; + this.rootArchive = rootArchive; + } + + @Override + public URL findResource(String name) { + if (this.exploded) { + return super.findResource(name); + } + Handler.setUseFastConnectionExceptions(true); + try { + return super.findResource(name); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + @Override + public Enumeration findResources(String name) throws IOException { + if (this.exploded) { + return super.findResources(name); + } + Handler.setUseFastConnectionExceptions(true); + try { + return new UseFastConnectionExceptionsEnumeration(super.findResources(name)); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("org.springframework.boot.loader.jarmode.")) { + try { + Class result = loadClassInLaunchedClassLoader(name); + if (resolve) { + resolveClass(result); + } + return result; + } + catch (ClassNotFoundException ex) { + } + } + if (this.exploded) { + return super.loadClass(name, resolve); + } + Handler.setUseFastConnectionExceptions(true); + try { + try { + definePackageIfNecessary(name); + } + catch (IllegalArgumentException ex) { + // Tolerate race condition due to being parallel capable +// if (getDefinedPackage(name) == null) { +// // This should never happen as the IllegalArgumentException indicates +// // that the package has already been defined and, therefore, +// // getDefinedPackage(name) should not return null. +// throw new AssertionError("Package " + name + " has already been defined but it could not be found"); +// } + } + return super.loadClass(name, resolve); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + private Class loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException { + String internalName = name.replace('.', '/') + ".class"; + InputStream inputStream = getParent().getResourceAsStream(internalName); + if (inputStream == null) { + throw new ClassNotFoundException(name); + } + try { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead = -1; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + inputStream.close(); + byte[] bytes = outputStream.toByteArray(); + Class definedClass = defineClass(name, bytes, 0, bytes.length); + definePackageIfNecessary(name); + return definedClass; + } + finally { + inputStream.close(); + } + } + catch (IOException ex) { + throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", ex); + } + } + + /** + * Define a package before a {@code findClass} call is made. This is necessary to + * ensure that the appropriate manifest for nested JARs is associated with the + * package. + * @param className the class name being found + */ + private void definePackageIfNecessary(String className) { + int lastDot = className.lastIndexOf('.'); + if (lastDot >= 0) { + String packageName = className.substring(0, lastDot); +// if (getDefinedPackage(packageName) == null) { + try { + definePackage(className, packageName); + } + catch (IllegalArgumentException ex) { + // Tolerate race condition due to being parallel capable +// if (getDefinedPackage(packageName) == null) { +// // This should never happen as the IllegalArgumentException +// // indicates that the package has already been defined and, +// // therefore, getDefinedPackage(name) should not have returned +// // null. +// throw new AssertionError( +// "Package " + packageName + " has already been defined but it could not be found"); +// } + } +// } + } + } + + private void definePackage(String className, String packageName) { + String packageEntryName = packageName.replace('.', '/') + "/"; + String classEntryName = className.replace('.', '/') + ".class"; + for (URL url : getURLs()) { + try { + URLConnection connection = url.openConnection(); + if (connection instanceof JarURLConnection) { + JarFile jarFile = ((JarURLConnection)connection).getJarFile(); + if (jarFile.getEntry(classEntryName) != null && jarFile.getEntry(packageEntryName) != null + && jarFile.getManifest() != null) { + definePackage(packageName, jarFile.getManifest(), url); + return; + } + } + } + catch (IOException ex) { + // Ignore + } + } + } + + @Override + protected Package definePackage(String name, Manifest man, URL url) throws IllegalArgumentException { + if (!this.exploded) { + return super.definePackage(name, man, url); + } + synchronized (this.packageLock) { + return doDefinePackage(DefinePackageCallType.MANIFEST, () -> super.definePackage(name, man, url)); + } + } + + @Override + protected Package definePackage(String name, String specTitle, String specVersion, String specVendor, + String implTitle, String implVersion, String implVendor, URL sealBase) throws IllegalArgumentException { + if (!this.exploded) { + return super.definePackage(name, specTitle, specVersion, specVendor, implTitle, implVersion, implVendor, + sealBase); + } + synchronized (this.packageLock) { + if (this.definePackageCallType == null) { + // We're not part of a call chain which means that the URLClassLoader + // is trying to define a package for our exploded JAR. We use the + // manifest version to ensure package attributes are set + Manifest manifest = getManifest(this.rootArchive); + if (manifest != null) { + return definePackage(name, manifest, sealBase); + } + } + return doDefinePackage(DefinePackageCallType.ATTRIBUTES, () -> super.definePackage(name, specTitle, + specVersion, specVendor, implTitle, implVersion, implVendor, sealBase)); + } + } + + private Manifest getManifest(Archive archive) { + try { + return (archive != null) ? archive.getManifest() : null; + } + catch (IOException ex) { + return null; + } + } + + private T doDefinePackage(DefinePackageCallType type, Supplier call) { + DefinePackageCallType existingType = this.definePackageCallType; + try { + this.definePackageCallType = type; + return call.get(); + } + finally { + this.definePackageCallType = existingType; + } + } + + /** + * Clear URL caches. + */ + public void clearCache() { + if (this.exploded) { + return; + } + for (URL url : getURLs()) { + try { + URLConnection connection = url.openConnection(); + if (connection instanceof JarURLConnection) { + clearCache(connection); + } + } + catch (IOException ex) { + // Ignore + } + } + + } + + private void clearCache(URLConnection connection) throws IOException { + Object jarFile = ((JarURLConnection) connection).getJarFile(); + if (jarFile instanceof org.springframework.boot.loader.jar.JarFile) { + ((org.springframework.boot.loader.jar.JarFile) jarFile).clearCache(); + } + } + + private static class UseFastConnectionExceptionsEnumeration implements Enumeration { + + private final Enumeration delegate; + + UseFastConnectionExceptionsEnumeration(Enumeration delegate) { + this.delegate = delegate; + } + + @Override + public boolean hasMoreElements() { + Handler.setUseFastConnectionExceptions(true); + try { + return this.delegate.hasMoreElements(); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + + } + + @Override + public URL nextElement() { + Handler.setUseFastConnectionExceptions(true); + try { + return this.delegate.nextElement(); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + } + + /** + * The different types of call made to define a package. We track these for exploded + * jars so that we can detect packages that should have manifest attributes applied. + */ + private enum DefinePackageCallType { + + /** + * A define package call from a resource that has a manifest. + */ + MANIFEST, + + /** + * A define package call with a direct set of attributes. + */ + ATTRIBUTES + + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/Launcher.java b/neutrino-core/src/main/java/org/springframework/boot/loader/Launcher.java new file mode 100644 index 00000000..10bdca69 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/Launcher.java @@ -0,0 +1,159 @@ +/* + * 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 org.springframework.boot.loader; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; +import org.springframework.boot.loader.jar.JarFile; + +import java.io.File; +import java.net.URI; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Base class for launchers that can start an application with a fully configured + * classpath backed by one or more {@link Archive}s. + * + * @author Phillip Webb + * @author Dave Syer + * @since 1.0.0 + */ +public abstract class Launcher { + + private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher"; + + /** + * Launch the application. This method is the initial entry point that should be + * called by a subclass {@code public static void main(String[] args)} method. + * @param args the incoming arguments + * @throws Exception if the application fails to launch + */ + protected void launch(String[] args) throws Exception { + if (!isExploded()) { + JarFile.registerUrlProtocolHandler(); + } + ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator()); + String jarMode = System.getProperty("jarmode"); + String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass(); + launch(args, launchClass, classLoader); + } + + /** + * Create a classloader for the specified archives. + * @param archives the archives + * @return the classloader + * @throws Exception if the classloader cannot be created + * @since 2.3.0 + */ + protected ClassLoader createClassLoader(Iterator archives) throws Exception { + List urls = new ArrayList<>(50); + while (archives.hasNext()) { + urls.add(archives.next().getUrl()); + } + return createClassLoader(urls.toArray(new URL[0])); + } + + /** + * Create a classloader for the specified URLs. + * @param urls the URLs + * @return the classloader + * @throws Exception if the classloader cannot be created + */ + protected ClassLoader createClassLoader(URL[] urls) throws Exception { + return new LaunchedURLClassLoader(isExploded(), getArchive(), urls, getClass().getClassLoader()); + } + + /** + * Launch the application given the archive file and a fully configured classloader. + * @param args the incoming arguments + * @param launchClass the launch class to run + * @param classLoader the classloader + * @throws Exception if the launch fails + */ + protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception { + Thread.currentThread().setContextClassLoader(classLoader); + createMainMethodRunner(launchClass, args, classLoader).run(); + } + + /** + * Create the {@code MainMethodRunner} used to launch the application. + * @param mainClass the main class + * @param args the incoming arguments + * @param classLoader the classloader + * @return the main method runner + */ + protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) { + return new MainMethodRunner(mainClass, args); + } + + /** + * Returns the main class that should be launched. + * @return the name of the main class + * @throws Exception if the main class cannot be obtained + */ + protected abstract String getMainClass() throws Exception; + + /** + * Returns the archives that will be used to construct the class path. + * @return the class path archives + * @throws Exception if the class path archives cannot be obtained + * @since 2.3.0 + */ + protected abstract Iterator getClassPathArchivesIterator() throws Exception; + + protected final Archive createArchive() throws Exception { + ProtectionDomain protectionDomain = getClass().getProtectionDomain(); + CodeSource codeSource = protectionDomain.getCodeSource(); + URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null; + String path = (location != null) ? location.getSchemeSpecificPart() : null; + if (path == null) { + throw new IllegalStateException("Unable to determine code source archive"); + } + File root = new File(path); + if (!root.exists()) { + throw new IllegalStateException("Unable to determine code source archive from " + root); + } + return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); + } + + /** + * Returns if the launcher is running in an exploded mode. If this method returns + * {@code true} then only regular JARs are supported and the additional URL and + * ClassLoader support infrastructure can be optimized. + * @return if the jar is exploded. + * @since 2.3.0 + */ + protected boolean isExploded() { + return false; + } + + /** + * Return the root archive. + * @return the root archive + * @since 2.3.1 + */ + protected Archive getArchive() { + return null; + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/MainMethodRunner.java b/neutrino-core/src/main/java/org/springframework/boot/loader/MainMethodRunner.java new file mode 100644 index 00000000..9b7a551a --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/MainMethodRunner.java @@ -0,0 +1,52 @@ +/* + * 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 org.springframework.boot.loader; + +import java.lang.reflect.Method; + +/** + * Utility class that is used by {@link Launcher}s to call a main method. The class + * containing the main method is loaded using the thread context class loader. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class MainMethodRunner { + + private final String mainClassName; + + private final String[] args; + + /** + * Create a new {@link MainMethodRunner} instance. + * @param mainClass the main class + * @param args incoming arguments + */ + public MainMethodRunner(String mainClass, String[] args) { + this.mainClassName = mainClass; + this.args = (args != null) ? args.clone() : null; + } + + public void run() throws Exception { + Class mainClass = Class.forName(this.mainClassName, false, Thread.currentThread().getContextClassLoader()); + Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + mainMethod.setAccessible(true); + mainMethod.invoke(null, new Object[] { this.args }); + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/MyJarLauncher.java b/neutrino-core/src/main/java/org/springframework/boot/loader/MyJarLauncher.java new file mode 100644 index 00000000..a955869b --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/MyJarLauncher.java @@ -0,0 +1,97 @@ +/* + * 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 org.springframework.boot.loader; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.Archive.EntryFilter; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; + +import java.io.File; + +/** + * {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are + * included inside a {@code /BOOT-INF/lib} directory and that application classes are + * included inside a {@code /BOOT-INF/classes} directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 1.0.0 + */ +public class MyJarLauncher extends ExecutableArchiveLauncher { + + private String path; + + static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> { + if (entry.isDirectory()) { + return entry.getName().equals("BOOT-INF/classes/"); + } + return entry.getName().startsWith("BOOT-INF/lib/"); + }; + + public MyJarLauncher(String path) { + this.path = path; + try { + this.setArchive(createArchive(this.path)); + this.setClassPathIndex(getClassPathIndex(this.getArchive())); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + public ClassLoader createClassLoader() throws Exception { + return createClassLoader(getClassPathArchivesIterator()); + } + + protected final Archive createArchive(String path) throws Exception { + if (path == null) { + throw new IllegalStateException("Unable to determine code source archive"); + } + File root = new File(path); + if (!root.exists()) { + throw new IllegalStateException("Unable to determine code source archive from " + root); + } + return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); + } + + protected MyJarLauncher(Archive archive) { + super(archive); + } + + @Override + protected boolean isPostProcessingClassPathArchives() { + return false; + } + + @Override + protected boolean isNestedArchive(Archive.Entry entry) { + return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry); + } + + @Override + protected String getArchiveEntryPathPrefix() { + return "BOOT-INF/"; + } + +// public static void main(String[] args) throws Exception { +// new MyJarLauncher().launch(args); +// } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java b/neutrino-core/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java new file mode 100755 index 00000000..15b89120 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java @@ -0,0 +1,716 @@ +/* + * Copyright 2012-2023 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 org.springframework.boot.loader; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.Archive.Entry; +import org.springframework.boot.loader.archive.Archive.EntryFilter; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; +import org.springframework.boot.loader.util.SystemPropertyUtils; + +import java.io.*; +import java.lang.reflect.Constructor; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLDecoder; +import java.util.*; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * {@link Launcher} for archives with user-configured classpath and main class through a + * properties file. This model is often more flexible and more amenable to creating + * well-behaved OS-level services than a model based on executable jars. + *

+ * Looks in various places for a properties file to extract loader settings, defaulting to + * {@code loader.properties} either on the current classpath or in the current working + * directory. The name of the properties file can be changed by setting a System property + * {@code loader.config.name} (e.g. {@code -Dloader.config.name=foo} will look for + * {@code foo.properties}. If that file doesn't exist then tries + * {@code loader.config.location} (with allowed prefixes {@code classpath:} and + * {@code file:} or any valid URL). Once that file is located turns it into Properties and + * extracts optional values (which can also be provided overridden as System properties in + * case the file doesn't exist): + *

    + *
  • {@code loader.path}: a comma-separated list of directories (containing file + * resources and/or nested archives in *.jar or *.zip or archives) or archives to append + * to the classpath. {@code BOOT-INF/classes,BOOT-INF/lib} in the application archive are + * always used
  • + *
  • {@code loader.main}: the main method to delegate execution to once the class loader + * is set up. No default, but will fall back to looking for a {@code Start-Class} in a + * {@code MANIFEST.MF}, if there is one in ${loader.home}/META-INF.
  • + *
+ * + * @author Dave Syer + * @author Janne Valkealahti + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class PropertiesLauncher extends Launcher { + + private static final Class[] PARENT_ONLY_PARAMS = new Class[] { ClassLoader.class }; + + private static final Class[] URLS_AND_PARENT_PARAMS = new Class[] { URL[].class, ClassLoader.class }; + + private static final Class[] NO_PARAMS = new Class[] {}; + + private static final URL[] NO_URLS = new URL[0]; + + private static final String DEBUG = "loader.debug"; + + /** + * Properties key for main class. As a manifest entry can also be specified as + * {@code Start-Class}. + */ + public static final String MAIN = "loader.main"; + + /** + * Properties key for classpath entries (directories possibly containing jars or + * jars). Multiple entries can be specified using a comma-separated list. {@code + * BOOT-INF/classes,BOOT-INF/lib} in the application archive are always used. + */ + public static final String PATH = "loader.path"; + + /** + * Properties key for home directory. This is the location of external configuration + * if not on classpath, and also the base path for any relative paths in the + * {@link #PATH loader path}. Defaults to current working directory ( + * ${user.dir}). + */ + public static final String HOME = "loader.home"; + + /** + * Properties key for default command line arguments. These arguments (if present) are + * prepended to the main method arguments before launching. + */ + public static final String ARGS = "loader.args"; + + /** + * Properties key for name of external configuration file (excluding suffix). Defaults + * to "application". Ignored if {@link #CONFIG_LOCATION loader config location} is + * provided instead. + */ + public static final String CONFIG_NAME = "loader.config.name"; + + /** + * Properties key for config file location (including optional classpath:, file: or + * URL prefix). + */ + public static final String CONFIG_LOCATION = "loader.config.location"; + + /** + * Properties key for boolean flag (default false) which, if set, will cause the + * external configuration properties to be copied to System properties (assuming that + * is allowed by Java security). + */ + public static final String SET_SYSTEM_PROPERTIES = "loader.system"; + + private static final Pattern WORD_SEPARATOR = Pattern.compile("\\W+"); + + private static final String NESTED_ARCHIVE_SEPARATOR = "!" + File.separator; + + private final File home; + + private List paths = new ArrayList<>(); + + private final Properties properties = new Properties(); + + private final Archive parent; + + private volatile ClassPathArchives classPathArchives; + + public PropertiesLauncher() { + try { + this.home = getHomeDirectory(); + initializeProperties(); + initializePaths(); + this.parent = createArchive(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + protected File getHomeDirectory() { + try { + return new File(getPropertyWithDefault(HOME, "${user.dir}")); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private void initializeProperties() throws Exception { + List configs = new ArrayList<>(); + if (getProperty(CONFIG_LOCATION) != null) { + configs.add(getProperty(CONFIG_LOCATION)); + } + else { + String[] names = getPropertyWithDefault(CONFIG_NAME, "loader").split(","); + for (String name : names) { + configs.add("file:" + getHomeDirectory() + "/" + name + ".properties"); + configs.add("classpath:" + name + ".properties"); + configs.add("classpath:BOOT-INF/classes/" + name + ".properties"); + } + } + for (String config : configs) { + try (InputStream resource = getResource(config)) { + if (resource != null) { + debug("Found: " + config); + loadResource(resource); + // Load the first one we find + return; + } + else { + debug("Not found: " + config); + } + } + } + } + + private void loadResource(InputStream resource) throws Exception { + this.properties.load(resource); + for (Object key : Collections.list(this.properties.propertyNames())) { + String text = this.properties.getProperty((String) key); + String value = SystemPropertyUtils.resolvePlaceholders(this.properties, text); + if (value != null) { + this.properties.put(key, value); + } + } + if ("true".equals(getProperty(SET_SYSTEM_PROPERTIES))) { + debug("Adding resolved properties to System properties"); + for (Object key : Collections.list(this.properties.propertyNames())) { + String value = this.properties.getProperty((String) key); + System.setProperty((String) key, value); + } + } + } + + private InputStream getResource(String config) throws Exception { + if (config.startsWith("classpath:")) { + return getClasspathResource(config.substring("classpath:".length())); + } + config = handleUrl(config); + if (isUrl(config)) { + return getURLResource(config); + } + return getFileResource(config); + } + + private String handleUrl(String path) throws UnsupportedEncodingException { + if (path.startsWith("jar:file:") || path.startsWith("file:")) { + path = URLDecoder.decode(path, "UTF-8"); + if (path.startsWith("file:")) { + path = path.substring("file:".length()); + if (path.startsWith("//")) { + path = path.substring(2); + } + } + } + return path; + } + + private boolean isUrl(String config) { + return config.contains("://"); + } + + private InputStream getClasspathResource(String config) { + while (config.startsWith("/")) { + config = config.substring(1); + } + config = "/" + config; + debug("Trying classpath: " + config); + return getClass().getResourceAsStream(config); + } + + private InputStream getFileResource(String config) throws Exception { + File file = new File(config); + debug("Trying file: " + config); + if (file.canRead()) { + return new FileInputStream(file); + } + return null; + } + + private InputStream getURLResource(String config) throws Exception { + URL url = new URL(config); + if (exists(url)) { + URLConnection con = url.openConnection(); + try { + return con.getInputStream(); + } + catch (IOException ex) { + // Close the HTTP connection (if applicable). + if (con instanceof HttpURLConnection) { + ((HttpURLConnection)con).disconnect(); + } + throw ex; + } + } + return null; + } + + private boolean exists(URL url) throws IOException { + // Try a URL connection content-length header... + URLConnection connection = url.openConnection(); + try { + connection.setUseCaches(connection.getClass().getSimpleName().startsWith("JNLP")); + if (connection instanceof HttpURLConnection) { + HttpURLConnection httpConnection = (HttpURLConnection)connection; + httpConnection.setRequestMethod("HEAD"); + int responseCode = httpConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + return true; + } + else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + } + return (connection.getContentLength() >= 0); + } + finally { + if (connection instanceof HttpURLConnection) { + ((HttpURLConnection)connection).disconnect(); + } + } + } + + private void initializePaths() throws Exception { + String path = getProperty(PATH); + if (path != null) { + this.paths = parsePathsProperty(path); + } + debug("Nested archive paths: " + this.paths); + } + + private List parsePathsProperty(String commaSeparatedPaths) { + List paths = new ArrayList<>(); + for (String path : commaSeparatedPaths.split(",")) { + path = cleanupPath(path); + // "" means the user wants root of archive but not current directory + path = (path == null || path.isEmpty()) ? "/" : path; + paths.add(path); + } + if (paths.isEmpty()) { + paths.add("lib"); + } + return paths; + } + + protected String[] getArgs(String... args) throws Exception { + String loaderArgs = getProperty(ARGS); + if (loaderArgs != null) { + String[] defaultArgs = loaderArgs.split("\\s+"); + String[] additionalArgs = args; + args = new String[defaultArgs.length + additionalArgs.length]; + System.arraycopy(defaultArgs, 0, args, 0, defaultArgs.length); + System.arraycopy(additionalArgs, 0, args, defaultArgs.length, additionalArgs.length); + } + return args; + } + + @Override + protected String getMainClass() throws Exception { + String mainClass = getProperty(MAIN, "Start-Class"); + if (mainClass == null) { + throw new IllegalStateException("No '" + MAIN + "' or 'Start-Class' specified"); + } + return mainClass; + } + + @Override + protected ClassLoader createClassLoader(Iterator archives) throws Exception { + String customLoaderClassName = getProperty("loader.classLoader"); + if (customLoaderClassName == null) { + return super.createClassLoader(archives); + } + Set urls = new LinkedHashSet<>(); + while (archives.hasNext()) { + urls.add(archives.next().getUrl()); + } + ClassLoader loader = new LaunchedURLClassLoader(urls.toArray(NO_URLS), getClass().getClassLoader()); + debug("Classpath for custom loader: " + urls); + loader = wrapWithCustomClassLoader(loader, customLoaderClassName); + debug("Using custom class loader: " + customLoaderClassName); + return loader; + } + + @SuppressWarnings("unchecked") + private ClassLoader wrapWithCustomClassLoader(ClassLoader parent, String className) throws Exception { + Class type = (Class) Class.forName(className, true, parent); + ClassLoader classLoader = newClassLoader(type, PARENT_ONLY_PARAMS, parent); + if (classLoader == null) { + classLoader = newClassLoader(type, URLS_AND_PARENT_PARAMS, NO_URLS, parent); + } + if (classLoader == null) { + classLoader = newClassLoader(type, NO_PARAMS); + } + if (classLoader == null) { + throw new IllegalArgumentException("Unable to create class loader for " + className); + } + return classLoader; + } + + private ClassLoader newClassLoader(Class loaderClass, Class[] parameterTypes, Object... initargs) + throws Exception { + try { + Constructor constructor = loaderClass.getDeclaredConstructor(parameterTypes); + constructor.setAccessible(true); + return constructor.newInstance(initargs); + } + catch (NoSuchMethodException ex) { + return null; + } + } + + private String getProperty(String propertyKey) throws Exception { + return getProperty(propertyKey, null, null); + } + + private String getProperty(String propertyKey, String manifestKey) throws Exception { + return getProperty(propertyKey, manifestKey, null); + } + + private String getPropertyWithDefault(String propertyKey, String defaultValue) throws Exception { + return getProperty(propertyKey, null, defaultValue); + } + + private String getProperty(String propertyKey, String manifestKey, String defaultValue) throws Exception { + if (manifestKey == null) { + manifestKey = propertyKey.replace('.', '-'); + manifestKey = toCamelCase(manifestKey); + } + String property = SystemPropertyUtils.getProperty(propertyKey); + if (property != null) { + String value = SystemPropertyUtils.resolvePlaceholders(this.properties, property); + debug("Property '" + propertyKey + "' from environment: " + value); + return value; + } + if (this.properties.containsKey(propertyKey)) { + String value = SystemPropertyUtils.resolvePlaceholders(this.properties, + this.properties.getProperty(propertyKey)); + debug("Property '" + propertyKey + "' from properties: " + value); + return value; + } + try { + if (this.home != null) { + // Prefer home dir for MANIFEST if there is one + try (ExplodedArchive archive = new ExplodedArchive(this.home, false)) { + Manifest manifest = archive.getManifest(); + if (manifest != null) { + String value = manifest.getMainAttributes().getValue(manifestKey); + if (value != null) { + debug("Property '" + manifestKey + "' from home directory manifest: " + value); + return SystemPropertyUtils.resolvePlaceholders(this.properties, value); + } + } + } + } + } + catch (IllegalStateException ex) { + // Ignore + } + // Otherwise try the parent archive + Manifest manifest = createArchive().getManifest(); + if (manifest != null) { + String value = manifest.getMainAttributes().getValue(manifestKey); + if (value != null) { + debug("Property '" + manifestKey + "' from archive manifest: " + value); + return SystemPropertyUtils.resolvePlaceholders(this.properties, value); + } + } + return (defaultValue != null) ? SystemPropertyUtils.resolvePlaceholders(this.properties, defaultValue) + : defaultValue; + } + + @Override + protected Iterator getClassPathArchivesIterator() throws Exception { + ClassPathArchives classPathArchives = this.classPathArchives; + if (classPathArchives == null) { + classPathArchives = new ClassPathArchives(); + this.classPathArchives = classPathArchives; + } + return classPathArchives.iterator(); + } + + public static void main(String[] args) throws Exception { + PropertiesLauncher launcher = new PropertiesLauncher(); + args = launcher.getArgs(args); + launcher.launch(args); + } + + public static String toCamelCase(CharSequence string) { + if (string == null) { + return null; + } + StringBuilder builder = new StringBuilder(); + Matcher matcher = WORD_SEPARATOR.matcher(string); + int pos = 0; + while (matcher.find()) { + builder.append(capitalize(string.subSequence(pos, matcher.end()).toString())); + pos = matcher.end(); + } + builder.append(capitalize(string.subSequence(pos, string.length()).toString())); + return builder.toString(); + } + + private static String capitalize(String str) { + return Character.toUpperCase(str.charAt(0)) + str.substring(1); + } + + private void debug(String message) { + if (Boolean.getBoolean(DEBUG)) { + System.out.println(message); + } + } + + private String cleanupPath(String path) { + path = path.trim(); + // No need for current dir path + if (path.startsWith("./")) { + path = path.substring(2); + } + String lowerCasePath = path.toLowerCase(Locale.ENGLISH); + if (lowerCasePath.endsWith(".jar") || lowerCasePath.endsWith(".zip")) { + return path; + } + if (path.endsWith("/*")) { + path = path.substring(0, path.length() - 1); + } + else { + // It's a directory + if (!path.endsWith("/") && !path.equals(".")) { + path = path + "/"; + } + } + return path; + } + + void close() throws Exception { + if (this.classPathArchives != null) { + this.classPathArchives.close(); + } + if (this.parent != null) { + this.parent.close(); + } + } + + /** + * An iterable collection of the classpath archives. + */ + private class ClassPathArchives implements Iterable { + + private final List classPathArchives; + + private final List jarFileArchives = new ArrayList<>(); + + ClassPathArchives() throws Exception { + this.classPathArchives = new ArrayList<>(); + for (String path : PropertiesLauncher.this.paths) { + for (Archive archive : getClassPathArchives(path)) { + addClassPathArchive(archive); + } + } + addNestedEntries(); + } + + private void addClassPathArchive(Archive archive) throws IOException { + if (!(archive instanceof ExplodedArchive)) { + this.classPathArchives.add(archive); + return; + } + this.classPathArchives.add(archive); + this.classPathArchives.addAll(asList(archive.getNestedArchives(null, new ArchiveEntryFilter()))); + } + + private List getClassPathArchives(String path) throws Exception { + String root = cleanupPath(handleUrl(path)); + List lib = new ArrayList<>(); + File file = new File(root); + if (!"/".equals(root)) { + if (!isAbsolutePath(root)) { + file = new File(PropertiesLauncher.this.home, root); + } + if (file.isDirectory()) { + debug("Adding classpath entries from " + file); + Archive archive = new ExplodedArchive(file, false); + lib.add(archive); + } + } + Archive archive = getArchive(file); + if (archive != null) { + debug("Adding classpath entries from archive " + archive.getUrl() + root); + lib.add(archive); + } + List nestedArchives = getNestedArchives(root); + if (nestedArchives != null) { + debug("Adding classpath entries from nested " + root); + lib.addAll(nestedArchives); + } + return lib; + } + + private boolean isAbsolutePath(String root) { + // Windows contains ":" others start with "/" + return root.contains(":") || root.startsWith("/"); + } + + private Archive getArchive(File file) throws IOException { + if (isNestedArchivePath(file)) { + return null; + } + String name = file.getName().toLowerCase(Locale.ENGLISH); + if (name.endsWith(".jar") || name.endsWith(".zip")) { + return getJarFileArchive(file); + } + return null; + } + + private boolean isNestedArchivePath(File file) { + return file.getPath().contains(NESTED_ARCHIVE_SEPARATOR); + } + + private List getNestedArchives(String path) throws Exception { + Archive parent = PropertiesLauncher.this.parent; + String root = path; + if (!root.equals("/") && root.startsWith("/") + || parent.getUrl().toURI().equals(PropertiesLauncher.this.home.toURI())) { + // If home dir is same as parent archive, no need to add it twice. + return null; + } + int index = root.indexOf('!'); + if (index != -1) { + File file = new File(PropertiesLauncher.this.home, root.substring(0, index)); + if (root.startsWith("jar:file:")) { + file = new File(root.substring("jar:file:".length(), index)); + } + parent = getJarFileArchive(file); + root = root.substring(index + 1); + while (root.startsWith("/")) { + root = root.substring(1); + } + } + if (root.endsWith(".jar")) { + File file = new File(PropertiesLauncher.this.home, root); + if (file.exists()) { + parent = getJarFileArchive(file); + root = ""; + } + } + if (root.equals("/") || root.equals("./") || root.equals(".")) { + // The prefix for nested jars is actually empty if it's at the root + root = ""; + } + EntryFilter filter = new PrefixMatchingArchiveFilter(root); + List archives = asList(parent.getNestedArchives(null, filter)); + if ((root == null || root.isEmpty() || ".".equals(root)) && !path.endsWith(".jar") + && parent != PropertiesLauncher.this.parent) { + // You can't find the root with an entry filter so it has to be added + // explicitly. But don't add the root of the parent archive. + archives.add(parent); + } + return archives; + } + + private void addNestedEntries() { + // The parent archive might have "BOOT-INF/lib/" and "BOOT-INF/classes/" + // directories, meaning we are running from an executable JAR. We add nested + // entries from there with low priority (i.e. at end). + try { + Iterator archives = PropertiesLauncher.this.parent.getNestedArchives(null, + JarLauncher.NESTED_ARCHIVE_ENTRY_FILTER); + while (archives.hasNext()) { + this.classPathArchives.add(archives.next()); + } + } + catch (IOException ex) { + // Ignore + } + } + + private List asList(Iterator iterator) { + List list = new ArrayList<>(); + while (iterator.hasNext()) { + list.add(iterator.next()); + } + return list; + } + + private JarFileArchive getJarFileArchive(File file) throws IOException { + JarFileArchive archive = new JarFileArchive(file); + this.jarFileArchives.add(archive); + return archive; + } + + @Override + public Iterator iterator() { + return this.classPathArchives.iterator(); + } + + void close() throws IOException { + for (JarFileArchive archive : this.jarFileArchives) { + archive.close(); + } + } + + } + + /** + * Convenience class for finding nested archives that have a prefix in their file path + * (e.g. "lib/"). + */ + private static final class PrefixMatchingArchiveFilter implements EntryFilter { + + private final String prefix; + + private final ArchiveEntryFilter filter = new ArchiveEntryFilter(); + + private PrefixMatchingArchiveFilter(String prefix) { + this.prefix = prefix; + } + + @Override + public boolean matches(Entry entry) { + if (entry.isDirectory()) { + return entry.getName().equals(this.prefix); + } + return entry.getName().startsWith(this.prefix) && this.filter.matches(entry); + } + + } + + /** + * Convenience class for finding nested archives (archive entries that can be + * classpath entries). + */ + private static final class ArchiveEntryFilter implements EntryFilter { + + private static final String DOT_JAR = ".jar"; + + private static final String DOT_ZIP = ".zip"; + + @Override + public boolean matches(Entry entry) { + return entry.getName().endsWith(DOT_JAR) || entry.getName().endsWith(DOT_ZIP); + } + + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/WarLauncher.java b/neutrino-core/src/main/java/org/springframework/boot/loader/WarLauncher.java new file mode 100644 index 00000000..81e0a744 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/WarLauncher.java @@ -0,0 +1,62 @@ +/* + * 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 org.springframework.boot.loader; + +import org.springframework.boot.loader.archive.Archive; + +/** + * {@link Launcher} for WAR based archives. This launcher for standard WAR archives. + * Supports dependencies in {@code WEB-INF/lib} as well as {@code WEB-INF/lib-provided}, + * classes are loaded from {@code WEB-INF/classes}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 1.0.0 + */ +public class WarLauncher extends ExecutableArchiveLauncher { + + public WarLauncher() { + } + + protected WarLauncher(Archive archive) { + super(archive); + } + + @Override + protected boolean isPostProcessingClassPathArchives() { + return false; + } + + @Override + public boolean isNestedArchive(Archive.Entry entry) { + if (entry.isDirectory()) { + return entry.getName().equals("WEB-INF/classes/"); + } + return entry.getName().startsWith("WEB-INF/lib/") || entry.getName().startsWith("WEB-INF/lib-provided/"); + } + + @Override + protected String getArchiveEntryPathPrefix() { + return "WEB-INF/"; + } + + public static void main(String[] args) throws Exception { + new WarLauncher().launch(args); + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/archive/Archive.java b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/Archive.java new file mode 100644 index 00000000..919d30c0 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/Archive.java @@ -0,0 +1,115 @@ +/* + * 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 org.springframework.boot.loader.archive; + +import org.springframework.boot.loader.Launcher; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Iterator; +import java.util.jar.Manifest; + +/** + * An archive that can be launched by the {@link Launcher}. + * + * @author Phillip Webb + * @since 1.0.0 + * @see JarFileArchive + */ +public interface Archive extends Iterable, AutoCloseable { + + /** + * Returns a URL that can be used to load the archive. + * @return the archive URL + * @throws MalformedURLException if the URL is malformed + */ + URL getUrl() throws MalformedURLException; + + /** + * Returns the manifest of the archive. + * @return the manifest + * @throws IOException if the manifest cannot be read + */ + Manifest getManifest() throws IOException; + + /** + * Returns nested {@link Archive}s for entries that match the specified filters. + * @param searchFilter filter used to limit when additional sub-entry searching is + * required or {@code null} if all entries should be considered. + * @param includeFilter filter used to determine which entries should be included in + * the result or {@code null} if all entries should be included + * @return the nested archives + * @throws IOException on IO error + * @since 2.3.0 + */ + Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException; + + /** + * Return if the archive is exploded (already unpacked). + * @return if the archive is exploded + * @since 2.3.0 + */ + default boolean isExploded() { + return false; + } + + /** + * Closes the {@code Archive}, releasing any open resources. + * @throws Exception if an error occurs during close processing + * @since 2.2.0 + */ + @Override + default void close() throws Exception { + + } + + /** + * Represents a single entry in the archive. + */ + interface Entry { + + /** + * Returns {@code true} if the entry represents a directory. + * @return if the entry is a directory + */ + boolean isDirectory(); + + /** + * Returns the name of the entry. + * @return the name of the entry + */ + String getName(); + + } + + /** + * Strategy interface to filter {@link Entry Entries}. + */ + @FunctionalInterface + interface EntryFilter { + + /** + * Apply the jar entry filter. + * @param entry the entry to filter + * @return {@code true} if the filter matches + */ + boolean matches(Entry entry); + + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java new file mode 100644 index 00000000..205601d0 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java @@ -0,0 +1,334 @@ +/* + * 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 org.springframework.boot.loader.archive; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.*; +import java.util.jar.Manifest; + +/** + * {@link Archive} implementation backed by an exploded archive directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @since 1.0.0 + */ +public class ExplodedArchive implements Archive { + + private static final Set SKIPPED_NAMES = new HashSet<>(Arrays.asList(".", "..")); + + private final File root; + + private final boolean recursive; + + private final File manifestFile; + + private Manifest manifest; + + /** + * Create a new {@link ExplodedArchive} instance. + * @param root the root directory + */ + public ExplodedArchive(File root) { + this(root, true); + } + + /** + * Create a new {@link ExplodedArchive} instance. + * @param root the root directory + * @param recursive if recursive searching should be used to locate the manifest. + * Defaults to {@code true}, directories with a large tree might want to set this to + * {@code false}. + */ + public ExplodedArchive(File root, boolean recursive) { + if (!root.exists() || !root.isDirectory()) { + throw new IllegalArgumentException("Invalid source directory " + root); + } + this.root = root; + this.recursive = recursive; + this.manifestFile = getManifestFile(root); + } + + private File getManifestFile(File root) { + File metaInf = new File(root, "META-INF"); + return new File(metaInf, "MANIFEST.MF"); + } + + @Override + public URL getUrl() throws MalformedURLException { + return this.root.toURI().toURL(); + } + + @Override + public Manifest getManifest() throws IOException { + if (this.manifest == null && this.manifestFile.exists()) { + try (FileInputStream inputStream = new FileInputStream(this.manifestFile)) { + this.manifest = new Manifest(inputStream); + } + } + return this.manifest; + } + + @Override + public Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException { + return new ArchiveIterator(this.root, this.recursive, searchFilter, includeFilter); + } + + @Override +// @Deprecated(since = "2.3.10", forRemoval = false) + public Iterator iterator() { + return new EntryIterator(this.root, this.recursive, null, null); + } + + protected Archive getNestedArchive(Entry entry) { + File file = ((FileEntry) entry).getFile(); + return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive((FileEntry) entry)); + } + + @Override + public boolean isExploded() { + return true; + } + + @Override + public String toString() { + try { + return getUrl().toString(); + } + catch (Exception ex) { + return "exploded archive"; + } + } + + /** + * File based {@link Entry} {@link Iterator}. + */ + private abstract static class AbstractIterator implements Iterator { + + private static final Comparator entryComparator = Comparator.comparing(File::getAbsolutePath); + + private final File root; + + private final boolean recursive; + + private final EntryFilter searchFilter; + + private final EntryFilter includeFilter; + + private final Deque> stack = new LinkedList<>(); + + private FileEntry current; + + private final String rootUrl; + + AbstractIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) { + this.root = root; + this.rootUrl = this.root.toURI().getPath(); + this.recursive = recursive; + this.searchFilter = searchFilter; + this.includeFilter = includeFilter; + this.stack.add(listFiles(root)); + this.current = poll(); + } + + @Override + public boolean hasNext() { + return this.current != null; + } + + @Override + public T next() { + FileEntry entry = this.current; + if (entry == null) { + throw new NoSuchElementException(); + } + this.current = poll(); + return adapt(entry); + } + + private FileEntry poll() { + while (!this.stack.isEmpty()) { + while (this.stack.peek().hasNext()) { + File file = this.stack.peek().next(); + if (SKIPPED_NAMES.contains(file.getName())) { + continue; + } + FileEntry entry = getFileEntry(file); + if (isListable(entry)) { + this.stack.addFirst(listFiles(file)); + } + if (this.includeFilter == null || this.includeFilter.matches(entry)) { + return entry; + } + } + this.stack.poll(); + } + return null; + } + + private FileEntry getFileEntry(File file) { + URI uri = file.toURI(); + String name = uri.getPath().substring(this.rootUrl.length()); + try { + return new FileEntry(name, file, uri.toURL()); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private boolean isListable(FileEntry entry) { + return entry.isDirectory() && (this.recursive || entry.getFile().getParentFile().equals(this.root)) + && (this.searchFilter == null || this.searchFilter.matches(entry)) + && (this.includeFilter == null || !this.includeFilter.matches(entry)); + } + + private Iterator listFiles(File file) { + File[] files = file.listFiles(); + if (files == null) { + return Collections.emptyIterator(); + } + Arrays.sort(files, entryComparator); + return Arrays.asList(files).iterator(); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove"); + } + + protected abstract T adapt(FileEntry entry); + + } + + private static class EntryIterator extends AbstractIterator { + + EntryIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) { + super(root, recursive, searchFilter, includeFilter); + } + + @Override + protected Entry adapt(FileEntry entry) { + return entry; + } + + } + + private static class ArchiveIterator extends AbstractIterator { + + ArchiveIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) { + super(root, recursive, searchFilter, includeFilter); + } + + @Override + protected Archive adapt(FileEntry entry) { + File file = entry.getFile(); + return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive(entry)); + } + + } + + /** + * {@link Entry} backed by a File. + */ + private static class FileEntry implements Entry { + + private final String name; + + private final File file; + + private final URL url; + + FileEntry(String name, File file, URL url) { + this.name = name; + this.file = file; + this.url = url; + } + + File getFile() { + return this.file; + } + + @Override + public boolean isDirectory() { + return this.file.isDirectory(); + } + + @Override + public String getName() { + return this.name; + } + + URL getUrl() { + return this.url; + } + + } + + /** + * {@link Archive} implementation backed by a simple JAR file that doesn't itself + * contain nested archives. + */ + private static class SimpleJarFileArchive implements Archive { + + private final URL url; + + SimpleJarFileArchive(FileEntry file) { + this.url = file.getUrl(); + } + + @Override + public URL getUrl() throws MalformedURLException { + return this.url; + } + + @Override + public Manifest getManifest() throws IOException { + return null; + } + + @Override + public Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) + throws IOException { + return Collections.emptyIterator(); + } + + @Override +// @Deprecated(since = "2.3.10", forRemoval = false) + public Iterator iterator() { + return Collections.emptyIterator(); + } + + @Override + public String toString() { + try { + return getUrl().toString(); + } + catch (Exception ex) { + return "jar archive"; + } + } + + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java new file mode 100755 index 00000000..9516cb3c --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java @@ -0,0 +1,306 @@ +/* + * 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 org.springframework.boot.loader.archive; + +import org.springframework.boot.loader.jar.JarFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.*; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; + +/** + * {@link Archive} implementation backed by a {@link JarFile}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class JarFileArchive implements Archive { + + private static final String UNPACK_MARKER = "UNPACK:"; + + private static final int BUFFER_SIZE = 32 * 1024; + + private static final FileAttribute[] NO_FILE_ATTRIBUTES = {}; + + private static final EnumSet DIRECTORY_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE); + + private static final EnumSet FILE_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE); + + private final JarFile jarFile; + + private URL url; + + private Path tempUnpackDirectory; + + public JarFileArchive(File file) throws IOException { + this(file, file.toURI().toURL()); + } + + public JarFileArchive(File file, URL url) throws IOException { + this(new JarFile(file)); + this.url = url; + } + + public JarFileArchive(JarFile jarFile) { + this.jarFile = jarFile; + } + + @Override + public URL getUrl() throws MalformedURLException { + if (this.url != null) { + return this.url; + } + return this.jarFile.getUrl(); + } + + @Override + public Manifest getManifest() throws IOException { + return this.jarFile.getManifest(); + } + + @Override + public Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException { + return new NestedArchiveIterator(this.jarFile.iterator(), searchFilter, includeFilter); + } + + @Override +// @Deprecated(since = "2.3.10", forRemoval = false) + public Iterator iterator() { + return new EntryIterator(this.jarFile.iterator(), null, null); + } + + @Override + public void close() throws IOException { + this.jarFile.close(); + } + + protected Archive getNestedArchive(Entry entry) throws IOException { + JarEntry jarEntry = ((JarFileEntry) entry).getJarEntry(); + if (jarEntry.getComment().startsWith(UNPACK_MARKER)) { + return getUnpackedNestedArchive(jarEntry); + } + try { + JarFile jarFile = this.jarFile.getNestedJarFile(jarEntry); + return new JarFileArchive(jarFile); + } + catch (Exception ex) { + throw new IllegalStateException("Failed to get nested archive for entry " + entry.getName(), ex); + } + } + + private Archive getUnpackedNestedArchive(JarEntry jarEntry) throws IOException { + String name = jarEntry.getName(); + if (name.lastIndexOf('/') != -1) { + name = name.substring(name.lastIndexOf('/') + 1); + } + Path path = getTempUnpackDirectory().resolve(name); + if (!Files.exists(path) || Files.size(path) != jarEntry.getSize()) { + unpack(jarEntry, path); + } + return new JarFileArchive(path.toFile(), path.toUri().toURL()); + } + + private Path getTempUnpackDirectory() { + if (this.tempUnpackDirectory == null) { + Path tempDirectory = Paths.get(System.getProperty("java.io.tmpdir")); + this.tempUnpackDirectory = createUnpackDirectory(tempDirectory); + } + return this.tempUnpackDirectory; + } + + private Path createUnpackDirectory(Path parent) { + int attempts = 0; + while (attempts++ < 1000) { + String fileName = Paths.get(this.jarFile.getName()).getFileName().toString(); + Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID()); + try { + createDirectory(unpackDirectory); + return unpackDirectory; + } + catch (IOException ex) { + } + } + throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'"); + } + + private void unpack(JarEntry entry, Path path) throws IOException { + createFile(path); + path.toFile().deleteOnExit(); + try (InputStream inputStream = this.jarFile.getInputStream(entry); + OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } + } + + private void createDirectory(Path path) throws IOException { + Files.createDirectory(path, getFileAttributes(path.getFileSystem(), DIRECTORY_PERMISSIONS)); + } + + private void createFile(Path path) throws IOException { + Files.createFile(path, getFileAttributes(path.getFileSystem(), FILE_PERMISSIONS)); + } + + private FileAttribute[] getFileAttributes(FileSystem fileSystem, EnumSet ownerReadWrite) { + if (!fileSystem.supportedFileAttributeViews().contains("posix")) { + return NO_FILE_ATTRIBUTES; + } + return new FileAttribute[] { PosixFilePermissions.asFileAttribute(ownerReadWrite) }; + } + + @Override + public String toString() { + try { + return getUrl().toString(); + } + catch (Exception ex) { + return "jar archive"; + } + } + + /** + * Abstract base class for iterator implementations. + */ + private abstract static class AbstractIterator implements Iterator { + + private final Iterator iterator; + + private final EntryFilter searchFilter; + + private final EntryFilter includeFilter; + + private Entry current; + + AbstractIterator(Iterator iterator, EntryFilter searchFilter, EntryFilter includeFilter) { + this.iterator = iterator; + this.searchFilter = searchFilter; + this.includeFilter = includeFilter; + this.current = poll(); + } + + @Override + public boolean hasNext() { + return this.current != null; + } + + @Override + public T next() { + T result = adapt(this.current); + this.current = poll(); + return result; + } + + private Entry poll() { + while (this.iterator.hasNext()) { + JarFileEntry candidate = new JarFileEntry(this.iterator.next()); + if ((this.searchFilter == null || this.searchFilter.matches(candidate)) + && (this.includeFilter == null || this.includeFilter.matches(candidate))) { + return candidate; + } + } + return null; + } + + protected abstract T adapt(Entry entry); + + } + + /** + * {@link Entry} iterator implementation backed by {@link JarEntry}. + */ + private static class EntryIterator extends AbstractIterator { + + EntryIterator(Iterator iterator, EntryFilter searchFilter, EntryFilter includeFilter) { + super(iterator, searchFilter, includeFilter); + } + + @Override + protected Entry adapt(Entry entry) { + return entry; + } + + } + + /** + * Nested {@link Archive} iterator implementation backed by {@link JarEntry}. + */ + private class NestedArchiveIterator extends AbstractIterator { + + NestedArchiveIterator(Iterator iterator, EntryFilter searchFilter, EntryFilter includeFilter) { + super(iterator, searchFilter, includeFilter); + } + + @Override + protected Archive adapt(Entry entry) { + try { + return getNestedArchive(entry); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + } + + /** + * {@link Entry} implementation backed by a {@link JarEntry}. + */ + private static class JarFileEntry implements Entry { + + private final JarEntry jarEntry; + + JarFileEntry(JarEntry jarEntry) { + this.jarEntry = jarEntry; + } + + JarEntry getJarEntry() { + return this.jarEntry; + } + + @Override + public boolean isDirectory() { + return this.jarEntry.isDirectory(); + } + + @Override + public String getName() { + return this.jarEntry.getName(); + } + + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/archive/package-info.java b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/package-info.java new file mode 100644 index 00000000..ebaca84b --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/archive/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Abstraction over logical Archives be they backed by a JAR file or unpacked into a + * directory. + * + * @see org.springframework.boot.loader.archive.Archive + */ +package org.springframework.boot.loader.archive; diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java b/neutrino-core/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java new file mode 100644 index 00000000..ec1aa5e4 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/data/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 org.springframework.boot.loader.data; + +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/neutrino-core/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java b/neutrino-core/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java new file mode 100644 index 00000000..73877288 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java @@ -0,0 +1,257 @@ +/* + * 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 org.springframework.boot.loader.data; + +import java.io.*; + +/** + * {@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/neutrino-core/src/main/java/org/springframework/boot/loader/data/package-info.java b/neutrino-core/src/main/java/org/springframework/boot/loader/data/package-info.java new file mode 100644 index 00000000..8f456bd6 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/data/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Classes and interfaces to allow random access to a block of data. + * + * @see org.springframework.boot.loader.data.RandomAccessData + */ +package org.springframework.boot.loader.data; diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java new file mode 100644 index 00000000..88726e37 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/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 org.springframework.boot.loader.jar; + +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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java new file mode 100644 index 00000000..4b6e2678 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java @@ -0,0 +1,255 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import java.nio.charset.StandardCharsets; + +/** + * 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 + */ +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; + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jar/Bytes.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/Bytes.java new file mode 100644 index 00000000..7f53bac6 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/Bytes.java @@ -0,0 +1,37 @@ +/* + * 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 org.springframework.boot.loader.jar; + +/** + * Utilities for dealing with bytes from ZIP files. + * + * @author Phillip Webb + */ +final class Bytes { + + private 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; + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java new file mode 100644 index 00000000..b1d24d65 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java @@ -0,0 +1,258 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import org.springframework.boot.loader.data.RandomAccessData; + +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); + AsciiBytes comment = new 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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java new file mode 100644 index 00000000..b532b611 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java @@ -0,0 +1,222 @@ +/* + * Copyright 2012-2023 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 org.springframework.boot.loader.jar; + +import org.springframework.boot.loader.data.RandomAccessData; + +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 AsciiBytes SLASH = new AsciiBytes("/"); + + private static final byte[] NO_EXTRA = {}; + + private static final AsciiBytes NO_COMMENT = new AsciiBytes(""); + + private byte[] header; + + private int headerOffset; + + private AsciiBytes name; + + private byte[] extra; + + private AsciiBytes comment; + + private long localHeaderOffset; + + CentralDirectoryFileHeader() { + } + + CentralDirectoryFileHeader(byte[] header, int headerOffset, AsciiBytes name, byte[] extra, 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 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 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"); + } + + 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; + } + + 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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java new file mode 100644 index 00000000..6ac0ca1b --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java @@ -0,0 +1,101 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import org.springframework.boot.loader.data.RandomAccessData; + +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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java new file mode 100644 index 00000000..d160cbf8 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java @@ -0,0 +1,34 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import org.springframework.boot.loader.data.RandomAccessData; + +/** + * 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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/FileHeader.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/FileHeader.java new file mode 100644 index 00000000..4b8de500 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/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 org.springframework.boot.loader.jar; + +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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/Handler.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/Handler.java new file mode 100644 index 00000000..d5cc8e74 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/Handler.java @@ -0,0 +1,464 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.net.*; +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); + IOException ioException = (IOException)reason; + throw ioException; + } + log(true, "Unable to open fallback handler", ex); + if (reason instanceof RuntimeException) { + RuntimeException runtimeException = (RuntimeException)reason; + throw runtimeException; + } + 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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntry.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntry.java new file mode 100644 index 00000000..5b8f3bed --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntry.java @@ -0,0 +1,120 @@ +/* + * 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 org.springframework.boot.loader.jar; + +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 AsciiBytes name; + + private final AsciiBytes headerName; + + private final JarFile jarFile; + + private final long localHeaderOffset; + + private volatile JarEntryCertification certification; + + JarEntry(JarFile jarFile, int index, CentralDirectoryFileHeader header, 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; + } + + 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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java new file mode 100644 index 00000000..cbf66412 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/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 org.springframework.boot.loader.jar; + +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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java new file mode 100644 index 00000000..98ed4b90 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/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 org.springframework.boot.loader.jar; + +/** + * 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. + */ + AsciiBytes apply(AsciiBytes name); + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFile.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFile.java new file mode 100644 index 00000000..2e9bfb8a --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFile.java @@ -0,0 +1,476 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import org.springframework.boot.loader.data.RandomAccessData; +import org.springframework.boot.loader.data.RandomAccessDataFile; + +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.Enumeration; +import java.util.Iterator; +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 = "org.springframework.boot.loader"; + + private static final AsciiBytes META_INF = new AsciiBytes("META-INF/"); + + private static final AsciiBytes SIGNATURE_FILE_EXTENSION = new 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 final JarFileEntries entries; + + private final Supplier manifestSupplier; + + private SoftReference manifest; + + private boolean signed; + + private String comment; + + private volatile boolean closed; + + private volatile JarFileWrapper wrapper; + + /** + * 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()); + super.close(); + 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 { + this.rootFile.close(); + super.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) { + 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) { + 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) { + JarEntry jarEntry = (JarEntry) entry; + return this.entries.getInputStream(jarEntry); + } + 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 { + 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()); + return new JarFile(this.rootFile, this.pathFromRoot + "!/" + entry.getName(), entryData, + JarFileType.NESTED_JAR); + } + + @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; + } + super.close(); + if (this.type == JarFileType.DIRECT) { + this.rootFile.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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java new file mode 100644 index 00000000..3da8c5c9 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java @@ -0,0 +1,486 @@ +/* + * Copyright 2012-2023 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 org.springframework.boot.loader.jar; + +import org.springframework.boot.loader.data.RandomAccessData; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; +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 = 1;// TODO Runtime.version().feature(); + + 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) { + AsciiBytes name = applyFilter(fileHeader.getName()); + if (name != null) { + add(name, dataOffset); + } + } + + private void add(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; + AsciiBytes nameAlias = (entry instanceof JarEntry) ? ((JarEntry)entry).getAsciiBytesName() + : new 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, + AsciiBytes nameAlias) { + int hashCode = AsciiBytes.hashCode(name); + T entry = getEntry(hashCode, name, NO_SUFFIX, type, cacheEntry, nameAlias); + if (entry == null) { + hashCode = 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, 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, 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 AsciiBytes applyFilter(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; + 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 = 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; + } + + /** + * 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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java new file mode 100644 index 00000000..61343ac8 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java @@ -0,0 +1,126 @@ +/* + * 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 org.springframework.boot.loader.jar; + +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; + super.close(); + } + + @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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java new file mode 100644 index 00000000..26ced367 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java @@ -0,0 +1,385 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import java.io.*; +import java.net.*; +import java.security.Permission; + +/** + * {@link java.net.JarURLConnection} used to support {@link JarFile#getUrl()}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Rostyslav Dudka + */ +public final class JarURLConnection extends java.net.JarURLConnection { + + private static final 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 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 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); + } + + public static JarURLConnection get(URL url, JarFile jarFile) throws IOException { + StringSequence spec = new 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(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); + } + + /** + * A JarEntryName parsed from a URL String. + */ + static class JarEntryName { + + private final StringSequence name; + + private String contentType; + + JarEntryName(StringSequence spec) { + this.name = decode(spec); + } + + private StringSequence decode(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 StringSequence(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(StringSequence spec) { + return get(spec, 0); + } + + static JarEntryName get(StringSequence spec, int beginIndex) { + if (spec.length() <= beginIndex) { + return EMPTY_JAR_ENTRY_NAME; + } + return new JarEntryName(spec.subSequence(beginIndex)); + } + + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jar/StringSequence.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/StringSequence.java new file mode 100644 index 00000000..ee1feb0a --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/StringSequence.java @@ -0,0 +1,158 @@ +/* + * 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 org.springframework.boot.loader.jar; + +import java.util.Objects; + +/** + * 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 + */ +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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java new file mode 100644 index 00000000..87587bed --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/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 org.springframework.boot.loader.jar; + +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/neutrino-core/src/main/java/org/springframework/boot/loader/jar/package-info.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/package-info.java new file mode 100644 index 00000000..e232261f --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jar/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Support for loading and manipulating JAR/WAR files. + */ +package org.springframework.boot.loader.jar; diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java new file mode 100644 index 00000000..c711e206 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java @@ -0,0 +1,42 @@ +/* + * 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 org.springframework.boot.loader.jarmode; + +/** + * Interface registered in {@code spring.factories} to provides extended 'jarmode' + * support. + * + * @author Phillip Webb + * @since 2.3.0 + */ +public interface JarMode { + + /** + * Returns if this accepts and can run the given mode. + * @param mode the mode to check + * @return if this instance accepts the mode + */ + boolean accepts(String mode); + + /** + * Run the jar in the given mode. + * @param mode the mode to use + * @param args any program arguments + */ + void run(String mode, String[] args); + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java new file mode 100644 index 00000000..b8504c49 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java @@ -0,0 +1,51 @@ +/* + * 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 org.springframework.boot.loader.jarmode; + +//import org.springframework.core.io.support.SpringFactoriesLoader; +//import org.springframework.util.ClassUtils; + +/** + * Delegate class used to launch the fat jar in a specific mode. + * + * @author Phillip Webb + * @since 2.3.0 + */ +public final class JarModeLauncher { + + static final String DISABLE_SYSTEM_EXIT = JarModeLauncher.class.getName() + ".DISABLE_SYSTEM_EXIT"; + + private JarModeLauncher() { + } + +// public static void main(String[] args) { +// String mode = System.getProperty("jarmode"); +// List candidates = SpringFactoriesLoader.loadFactories(JarMode.class, +// ClassUtils.getDefaultClassLoader()); +// for (JarMode candidate : candidates) { +// if (candidate.accepts(mode)) { +// candidate.run(mode, args); +// return; +// } +// } +// System.err.println("Unsupported jarmode '" + mode + "'"); +// if (!Boolean.getBoolean(DISABLE_SYSTEM_EXIT)) { +// System.exit(1); +// } +// } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java new file mode 100644 index 00000000..6a6e83ff --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java @@ -0,0 +1,38 @@ +/* + * 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 org.springframework.boot.loader.jarmode; + +import java.util.Arrays; + +/** + * {@link JarMode} for testing. + * + * @author Phillip Webb + */ +class TestJarMode implements JarMode { + + @Override + public boolean accepts(String mode) { + return "test".equals(mode); + } + + @Override + public void run(String mode, String[] args) { + System.out.println("running in " + mode + " jar mode " + Arrays.asList(args)); + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/package-info.java b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/package-info.java new file mode 100644 index 00000000..315cb569 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/jarmode/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Support for launching the JAR using jarmode. + * + * @see org.springframework.boot.loader.jarmode.JarModeLauncher + */ +package org.springframework.boot.loader.jarmode; diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/package-info.java b/neutrino-core/src/main/java/org/springframework/boot/loader/package-info.java new file mode 100644 index 00000000..c2114c2d --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/package-info.java @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** + * System that allows self-contained JAR/WAR archives to be launched using + * {@code java -jar}. Archives can include nested packaged dependency JARs (there is no + * need to create shade style jars) and are executed without unpacking. The only + * constraint is that nested JARs must be stored in the archive uncompressed. + * + * @see org.springframework.boot.loader.JarLauncher + * @see org.springframework.boot.loader.WarLauncher + */ +package org.springframework.boot.loader; diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java b/neutrino-core/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java new file mode 100644 index 00000000..b6f0e3a3 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java @@ -0,0 +1,232 @@ +/* + * 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 org.springframework.boot.loader.util; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; + +/** + * Helper class for resolving placeholders in texts. Usually applied to file paths. + *

+ * A text may contain {@code $ ...} placeholders, to be resolved as system properties: + * e.g. {@code $ user.dir}. Default values can be supplied using the ":" separator between + * key and value. + *

+ * Adapted from Spring. + * + * @author Juergen Hoeller + * @author Rob Harrop + * @author Dave Syer + * @since 1.0.0 + * @see System#getProperty(String) + */ +public abstract class SystemPropertyUtils { + + /** + * Prefix for system property placeholders: "${". + */ + public static final String PLACEHOLDER_PREFIX = "${"; + + /** + * Suffix for system property placeholders: "}". + */ + public static final String PLACEHOLDER_SUFFIX = "}"; + + /** + * Value separator for system property placeholders: ":". + */ + public static final String VALUE_SEPARATOR = ":"; + + private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1); + + /** + * Resolve ${...} placeholders in the given text, replacing them with corresponding + * system property values. + * @param text the String to resolve + * @return the resolved String + * @throws IllegalArgumentException if there is an unresolvable placeholder + * @see #PLACEHOLDER_PREFIX + * @see #PLACEHOLDER_SUFFIX + */ + public static String resolvePlaceholders(String text) { + if (text == null) { + return text; + } + return parseStringValue(null, text, text, new HashSet<>()); + } + + /** + * Resolve ${...} placeholders in the given text, replacing them with corresponding + * system property values. + * @param properties a properties instance to use in addition to System + * @param text the String to resolve + * @return the resolved String + * @throws IllegalArgumentException if there is an unresolvable placeholder + * @see #PLACEHOLDER_PREFIX + * @see #PLACEHOLDER_SUFFIX + */ + public static String resolvePlaceholders(Properties properties, String text) { + if (text == null) { + return text; + } + return parseStringValue(properties, text, text, new HashSet<>()); + } + + private static String parseStringValue(Properties properties, String value, String current, + Set visitedPlaceholders) { + + StringBuilder buf = new StringBuilder(current); + + int startIndex = current.indexOf(PLACEHOLDER_PREFIX); + while (startIndex != -1) { + int endIndex = findPlaceholderEndIndex(buf, startIndex); + if (endIndex != -1) { + String placeholder = buf.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex); + String originalPlaceholder = placeholder; + if (!visitedPlaceholders.add(originalPlaceholder)) { + throw new IllegalArgumentException( + "Circular placeholder reference '" + originalPlaceholder + "' in property definitions"); + } + // Recursive invocation, parsing placeholders contained in the + // placeholder + // key. + placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders); + // Now obtain the value for the fully resolved key... + String propVal = resolvePlaceholder(properties, value, placeholder); + if (propVal == null) { + int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR); + if (separatorIndex != -1) { + String actualPlaceholder = placeholder.substring(0, separatorIndex); + String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length()); + propVal = resolvePlaceholder(properties, value, actualPlaceholder); + if (propVal == null) { + propVal = defaultValue; + } + } + } + if (propVal != null) { + // Recursive invocation, parsing placeholders contained in the + // previously resolved placeholder value. + propVal = parseStringValue(properties, value, propVal, visitedPlaceholders); + buf.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal); + startIndex = buf.indexOf(PLACEHOLDER_PREFIX, startIndex + propVal.length()); + } + else { + // Proceed with unprocessed value. + startIndex = buf.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length()); + } + visitedPlaceholders.remove(originalPlaceholder); + } + else { + startIndex = -1; + } + } + + return buf.toString(); + } + + private static String resolvePlaceholder(Properties properties, String text, String placeholderName) { + String propVal = getProperty(placeholderName, null, text); + if (propVal != null) { + return propVal; + } + return (properties != null) ? properties.getProperty(placeholderName) : null; + } + + public static String getProperty(String key) { + return getProperty(key, null, ""); + } + + public static String getProperty(String key, String defaultValue) { + return getProperty(key, defaultValue, ""); + } + + /** + * Search the System properties and environment variables for a value with the + * provided key. Environment variables in {@code UPPER_CASE} style are allowed where + * System properties would normally be {@code lower.case}. + * @param key the key to resolve + * @param defaultValue the default value + * @param text optional extra context for an error message if the key resolution fails + * (e.g. if System properties are not accessible) + * @return a static property value or null of not found + */ + public static String getProperty(String key, String defaultValue, String text) { + try { + String propVal = System.getProperty(key); + if (propVal == null) { + // Fall back to searching the system environment. + propVal = System.getenv(key); + } + if (propVal == null) { + // Try with underscores. + String name = key.replace('.', '_'); + propVal = System.getenv(name); + } + if (propVal == null) { + // Try uppercase with underscores as well. + String name = key.toUpperCase(Locale.ENGLISH).replace('.', '_'); + propVal = System.getenv(name); + } + if (propVal != null) { + return propVal; + } + } + catch (Throwable ex) { + System.err.println("Could not resolve key '" + key + "' in '" + text + + "' as system property or in environment: " + ex); + } + return defaultValue; + } + + private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) { + int index = startIndex + PLACEHOLDER_PREFIX.length(); + int withinNestedPlaceholder = 0; + while (index < buf.length()) { + if (substringMatch(buf, index, PLACEHOLDER_SUFFIX)) { + if (withinNestedPlaceholder > 0) { + withinNestedPlaceholder--; + index = index + PLACEHOLDER_SUFFIX.length(); + } + else { + return index; + } + } + else if (substringMatch(buf, index, SIMPLE_PREFIX)) { + withinNestedPlaceholder++; + index = index + SIMPLE_PREFIX.length(); + } + else { + index++; + } + } + return -1; + } + + private static boolean substringMatch(CharSequence str, int index, CharSequence substring) { + for (int j = 0; j < substring.length(); j++) { + int i = index + j; + if (i >= str.length() || str.charAt(i) != substring.charAt(j)) { + return false; + } + } + return true; + } + +} diff --git a/neutrino-core/src/main/java/org/springframework/boot/loader/util/package-info.java b/neutrino-core/src/main/java/org/springframework/boot/loader/util/package-info.java new file mode 100644 index 00000000..af0aa2d1 --- /dev/null +++ b/neutrino-core/src/main/java/org/springframework/boot/loader/util/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Utilities used by Spring Boot's JAR loading. + */ +package org.springframework.boot.loader.util; diff --git a/neutrino-core/src/test/java/fun/asgc/neutrino/core/aop/compiler/AsgcCompilerTest.java b/neutrino-core/src/test/java/fun/asgc/neutrino/core/aop/compiler/AsgcCompilerTest.java index 4e8d5bcf..46405fb8 100644 --- a/neutrino-core/src/test/java/fun/asgc/neutrino/core/aop/compiler/AsgcCompilerTest.java +++ b/neutrino-core/src/test/java/fun/asgc/neutrino/core/aop/compiler/AsgcCompilerTest.java @@ -23,9 +23,11 @@ package fun.asgc.neutrino.core.aop.compiler; import fun.asgc.neutrino.core.util.ReflectUtil; import org.junit.Test; +import org.springframework.boot.loader.MyJarLauncher; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.stream.Stream; /** * @@ -106,24 +108,46 @@ public class AsgcCompilerTest { /** * jar in jar测试 + * + * 使用jdk1.8跑,tools.jar放在classpath下 + * 测试jar in jar + * jar结构: + * asgc-package-lab2.jar + * - BOOT-INF/ + * - classes/fun/asgc/lab/pkg/lab2/ + * - Console.class + * - lib/asgc-package-lab1-1.0-SNAPSHOT.jar + * - BOOT-INF/classes/fun/asgc/lab/pkg/lab1/ + * - Dog.class */ @Test - public void compileAndLoadClass5() throws ClassNotFoundException, IllegalAccessException, InstantiationException, InvocationTargetException { - AsgcCompiler compiler = new AsgcCompiler(); -// compiler.addClasspath("/Users/yangwen/my/tmp/java"); - compiler.addClasspath("/Users/yangwen/my/tmp/java/asgc-package-lab2.jar"); + public void compileAndLoadClass5() throws Exception { + MyJarLauncher jarLauncher = new MyJarLauncher("/Users/yangwen/my/tmp/java/asgc-package-lab2.jar"); + AsgcCompiler compiler = new AsgcCompiler(jarLauncher.createClassLoader()); String code = "package a.b;\n" + - "import fun.asgc.lab.pkg.lab2;\n" + + "import fun.asgc.lab.pkg.lab2.Console;\n" + + "import fun.asgc.lab.pkg.lab1.Dog;\n" + "public class Test {\n" + "\tpublic void execute() {\n" + "\t\tSystem.out.println(\"1122\");\n" + "\t}\n" + + "\tpublic void echo(String str){\n" + + "\t\tConsole.println(str);\n" + + "\t}\n" + + "\tpublic String dogEat(String name, String food){\n" + + "\t\treturn new Dog(name).eat(food);\n" + + "\t}\n" + "}\n"; -// GlobalConfig.setIsSaveGeneratorCode(true); + System.out.println(code); Class clazz = compiler.compile("a.b","Test", code); - Method method = ReflectUtil.getMethods(clazz).stream().filter(m -> m.getName().equals("execute")).findFirst().get(); + Method executeMethod = ReflectUtil.getMethods(clazz).stream().filter(m -> m.getName().equals("execute")).findFirst().get(); + Method echoMethod = Stream.of(clazz.getDeclaredMethods()).filter(m -> m.getName().equals("echo")).findFirst().get(); + Method dogEatMethod = Stream.of(clazz.getDeclaredMethods()).filter(m -> m.getName().equals("dogEat")).findFirst().get(); + Object instance = clazz.newInstance(); - method.invoke(instance); + executeMethod.invoke(instance); + echoMethod.invoke(instance, "哈哈哈哈"); + System.out.println(dogEatMethod.invoke(instance, "大黄", "骨头")); } private Object eval(String expression) {