package com.github.atdi.gboot.loader.jar;
import com.github.atdi.gboot.loader.data.RandomAccessData;
import com.github.atdi.gboot.loader.data.RandomAccessDataFile;
import com.github.atdi.gboot.loader.util.AsciiBytes;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.SoftReference;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.Manifest;
import java.util.logging.Level;
import java.util.logging.Logger;
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.
* <ul>
* <li>New filtered files can be {@link #getFilteredJarFile(GBootJarEntryFilter...) created}
* from existing files.</li>
* <li>A nested {@link com.github.atdi.gboot.loader.jar.GBootJarFile} can be {@link #getNestedJarFile(java.util.zip.ZipEntry) obtained} based
* on any directory entry.</li>
* <li>A nested {@link com.github.atdi.gboot.loader.jar.GBootJarFile} can be {@link #getNestedJarFile(java.util.zip.ZipEntry) obtained} for
* embedded JAR files (as long as their entry is not compressed).</li>
* <li>Entry data can be accessed as {@link RandomAccessData}.</li>
* </ul>
*
*/
public class GBootJarFile extends java.util.jar.JarFile implements Iterable<GBootJarEntryData> {
private static final AsciiBytes META_INF = new AsciiBytes("META-INF/");
private static final AsciiBytes MANIFEST_MF = new AsciiBytes("META-INF/MANIFEST.MF");
private static final AsciiBytes SIGNATURE_FILE_EXTENSION = new AsciiBytes(".SF");
private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs";
private static final String HANDLERS_PACKAGE = "org.springframework.boot.loader";
private static final AsciiBytes SLASH = new AsciiBytes("/");
private final RandomAccessDataFile rootFile;
private final String pathFromRoot;
private final RandomAccessData data;
private final List<GBootJarEntryData> entries;
private SoftReference<Map<AsciiBytes, GBootJarEntryData>> entriesByName;
private boolean signed;
private GBootJarEntryData manifestEntry;
private SoftReference<Manifest> manifest;
private URL url;
private static final Logger logger = Logger.getLogger(GBootJarFile.class.getName());
/**
* Create a new {@link com.github.atdi.gboot.loader.jar.GBootJarFile} backed by the specified file.
* @param file the root jar file
* @throws java.io.IOException
*/
public GBootJarFile(File file) throws IOException {
this(new RandomAccessDataFile(file));
}
/**
* Create a new {@link com.github.atdi.gboot.loader.jar.GBootJarFile} backed by the specified file.
* @param file the root jar file
* @throws java.io.IOException
*/
GBootJarFile(RandomAccessDataFile file) throws IOException {
this(file, "", file);
}
/**
* Private constructor used to create a new {@link com.github.atdi.gboot.loader.jar.GBootJarFile} 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
* @throws java.io.IOException
*/
private GBootJarFile(RandomAccessDataFile rootFile, String pathFromRoot,
RandomAccessData data) throws IOException {
super(rootFile.getFile());
CentralDirectoryEndRecord endRecord = new CentralDirectoryEndRecord(data);
this.rootFile = rootFile;
this.pathFromRoot = pathFromRoot;
this.data = getArchiveData(endRecord, data);
this.entries = loadJarEntries(endRecord);
}
private GBootJarFile(RandomAccessDataFile rootFile, String pathFromRoot,
RandomAccessData data, List<GBootJarEntryData> entries, GBootJarEntryFilter... filters)
throws IOException {
super(rootFile.getFile());
this.rootFile = rootFile;
this.pathFromRoot = pathFromRoot;
this.data = data;
this.entries = filterEntries(entries, filters);
}
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 List<GBootJarEntryData> loadJarEntries(CentralDirectoryEndRecord endRecord)
throws IOException {
RandomAccessData centralDirectory = endRecord.getCentralDirectory(this.data);
int numberOfRecords = endRecord.getNumberOfRecords();
List<GBootJarEntryData> entries = new ArrayList<GBootJarEntryData>(numberOfRecords);
InputStream inputStream = centralDirectory.getInputStream(RandomAccessData.ResourceAccess.ONCE);
try {
GBootJarEntryData entry = GBootJarEntryData.fromInputStream(this, inputStream);
while (entry != null) {
entries.add(entry);
processEntry(entry);
entry = GBootJarEntryData.fromInputStream(this, inputStream);
}
}
finally {
inputStream.close();
}
return entries;
}
private List<GBootJarEntryData> filterEntries(List<GBootJarEntryData> entries,
GBootJarEntryFilter[] filters) {
List<GBootJarEntryData> filteredEntries = new ArrayList<GBootJarEntryData>(entries.size());
for (GBootJarEntryData entry : entries) {
AsciiBytes name = entry.getName();
for (GBootJarEntryFilter filter : filters) {
name = (filter == null || name == null ? name : filter.apply(name, entry));
}
if (name != null) {
GBootJarEntryData filteredCopy = entry.createFilteredCopy(this, name);
filteredEntries.add(filteredCopy);
processEntry(filteredCopy);
}
}
return filteredEntries;
}
private void processEntry(GBootJarEntryData entry) {
AsciiBytes name = entry.getName();
if (name.startsWith(META_INF)) {
processMetaInfEntry(name, entry);
}
}
private void processMetaInfEntry(AsciiBytes name, GBootJarEntryData entry) {
if (name.equals(MANIFEST_MF)) {
this.manifestEntry = entry;
}
if (name.endsWith(SIGNATURE_FILE_EXTENSION)) {
this.signed = true;
}
}
protected final RandomAccessDataFile getRootJarFile() {
return this.rootFile;
}
RandomAccessData getData() {
return this.data;
}
@Override
public Manifest getManifest() throws IOException {
if (this.manifestEntry == null) {
return null;
}
Manifest manifest = (this.manifest == null ? null : this.manifest.get());
if (manifest == null) {
InputStream inputStream = this.manifestEntry.getInputStream();
try {
manifest = new Manifest(inputStream);
}
finally {
inputStream.close();
}
this.manifest = new SoftReference<Manifest>(manifest);
}
return manifest;
}
@Override
public Enumeration<JarEntry> entries() {
final Iterator<GBootJarEntryData> iterator = iterator();
return new Enumeration<JarEntry>() {
@Override
public boolean hasMoreElements() {
return iterator.hasNext();
}
@Override
public JarEntry nextElement() {
return iterator.next().asJarEntry();
}
};
}
@Override
public Iterator<GBootJarEntryData> iterator() {
return this.entries.iterator();
}
@Override
public GBootJarEntry getJarEntry(String name) {
return (GBootJarEntry) getEntry(name);
}
@Override
public ZipEntry getEntry(String name) {
GBootJarEntryData jarEntryData = getJarEntryData(name);
return (jarEntryData == null ? null : jarEntryData.asJarEntry());
}
public GBootJarEntryData getJarEntryData(String name) {
if (name == null) {
return null;
}
return getJarEntryData(new AsciiBytes(name));
}
public GBootJarEntryData getJarEntryData(AsciiBytes name) {
if (name == null) {
return null;
}
Map<AsciiBytes, GBootJarEntryData> entriesByName = (this.entriesByName == null ? null
: this.entriesByName.get());
if (entriesByName == null) {
entriesByName = new HashMap<AsciiBytes, GBootJarEntryData>();
for (GBootJarEntryData entry : this.entries) {
entriesByName.put(entry.getName(), entry);
}
this.entriesByName = new SoftReference<Map<AsciiBytes, GBootJarEntryData>>(
entriesByName);
}
GBootJarEntryData entryData = entriesByName.get(name);
if (entryData == null && !name.endsWith(SLASH)) {
entryData = entriesByName.get(name.append(SLASH));
}
return entryData;
}
boolean isSigned() {
return this.signed;
}
void setupEntryCertificates() {
// Fallback to JarInputStream to obtain certificates, not fast but hopefully not
// happening that often.
try {
JarInputStream inputStream = new JarInputStream(getData().getInputStream(
RandomAccessData.ResourceAccess.ONCE));
try {
JarEntry entry = inputStream.getNextJarEntry();
while (entry != null) {
inputStream.closeEntry();
GBootJarEntry jarEntry = getJarEntry(entry.getName());
if (jarEntry != null) {
jarEntry.setupCertificates(entry);
}
entry = inputStream.getNextJarEntry();
}
}
finally {
inputStream.close();
}
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
@Override
public synchronized InputStream getInputStream(ZipEntry ze) throws IOException {
return getContainedEntry(ze).getSource().getInputStream();
}
/**
* Return a nested {@link com.github.atdi.gboot.loader.jar.GBootJarFile} loaded from the specified entry.
* @param ze the zip entry
* @return a {@link com.github.atdi.gboot.loader.jar.GBootJarFile} for the entry
* @throws java.io.IOException
*/
public synchronized GBootJarFile getNestedJarFile(final ZipEntry ze) throws IOException {
return getNestedJarFile(getContainedEntry(ze).getSource());
}
/**
* Return a nested {@link com.github.atdi.gboot.loader.jar.GBootJarFile} loaded from the specified entry.
* @param sourceEntry the zip entry
* @return a {@link com.github.atdi.gboot.loader.jar.GBootJarFile} for the entry
* @throws java.io.IOException
*/
public synchronized GBootJarFile getNestedJarFile(GBootJarEntryData sourceEntry)
throws IOException {
try {
if (sourceEntry.nestedJar == null) {
sourceEntry.nestedJar = createJarFileFromEntry(sourceEntry);
}
return sourceEntry.nestedJar;
}
catch (IOException ex) {
throw new IOException("Unable to open nested jar file '"
+ sourceEntry.getName() + "'", ex);
}
}
private GBootJarFile createJarFileFromEntry(GBootJarEntryData sourceEntry) throws IOException {
if (sourceEntry.isDirectory()) {
return createJarFileFromDirectoryEntry(sourceEntry);
}
return createJarFileFromFileEntry(sourceEntry);
}
private GBootJarFile createJarFileFromDirectoryEntry(GBootJarEntryData sourceEntry)
throws IOException {
final AsciiBytes sourceName = sourceEntry.getName();
GBootJarEntryFilter filter = new GBootJarEntryFilter() {
@Override
public AsciiBytes apply(AsciiBytes name, GBootJarEntryData entryData) {
if (name.startsWith(sourceName) && !name.equals(sourceName)) {
return name.substring(sourceName.length());
}
return null;
}
};
return new GBootJarFile(this.rootFile, this.pathFromRoot + "!/"
+ sourceEntry.getName().substring(0, sourceName.length() - 1), this.data,
this.entries, filter);
}
private GBootJarFile createJarFileFromFileEntry(GBootJarEntryData sourceEntry)
throws IOException {
if (sourceEntry.getMethod() != ZipEntry.STORED) {
throw new IllegalStateException("Unable to open nested entry '"
+ sourceEntry.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");
}
return new GBootJarFile(this.rootFile, this.pathFromRoot + "!/"
+ sourceEntry.getName(), sourceEntry.getData());
}
/**
* Return a new jar based on the filtered contents of this file.
* @param filters the set of jar entry filters to be applied
* @return a filtered {@link com.github.atdi.gboot.loader.jar.GBootJarFile}
* @throws java.io.IOException
*/
public synchronized GBootJarFile getFilteredJarFile(GBootJarEntryFilter... filters)
throws IOException {
return new GBootJarFile(this.rootFile, this.pathFromRoot, this.data, this.entries,
filters);
}
private GBootJarEntry getContainedEntry(ZipEntry zipEntry) throws IOException {
if (zipEntry instanceof JarEntry
&& ((GBootJarEntry) zipEntry).getSource().getSource() == this) {
return (GBootJarEntry) zipEntry;
}
throw new IllegalArgumentException("ZipEntry must be contained in this file");
}
@Override
public int size() {
return (int) this.data.getSize();
}
@Override
public void close() throws IOException {
this.rootFile.close();
}
/**
* 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 java.net.MalformedURLException
*/
public URL getUrl() throws MalformedURLException {
if (this.url == null) {
Handler handler = new Handler(this);
String file = this.rootFile.getFile().toURI() + this.pathFromRoot + "!/";
file = file.replace("file:////", "file://"); // Fix UNC paths
this.url = new URL("jar", "", -1, file, handler);
}
return this.url;
}
@Override
public String toString() {
return getName();
}
@Override
public String getName() {
String path = this.pathFromRoot;
return this.rootFile.getFile() + path;
}
/**
* Register a {@literal 'java.protocol.handler.pkgs'} property so that a
* {@link java.net.URLStreamHandler} will be located to deal with jar URLs.
*/
public static void registerUrlProtocolHandler() {
String handlers = System.getProperty(PROTOCOL_HANDLER);
System.setProperty(PROTOCOL_HANDLER, ("".equals(handlers) ? HANDLERS_PACKAGE
: handlers + "|" + HANDLERS_PACKAGE));
resetCachedUrlHandlers();
}
/**
* Reset any cached handers just in case a jar protocol has already been used. We
* reset the handler by trying to set a null {@link java.net.URLStreamHandlerFactory} which
* should have no effect other than clearing the handlers cache.
*/
private static void resetCachedUrlHandlers() {
try {
URL.setURLStreamHandlerFactory(null);
}
catch (Error ex) {
logger.log(Level.SEVERE, "Error during cache reset", ex);
}
}
}