/*
* Copyright (c) MuleSoft, Inc. All rights reserved. http://www.mulesoft.com
* The software in this package is published under the terms of the CPAL v1.0
* license, a copy of which has been included with this distribution in the
* LICENSE.txt file.
*/
package org.mule.runtime.module.artifact.classloader.net;
import static java.lang.String.format;
import static java.lang.String.join;
import static java.util.Arrays.asList;
import static org.apache.commons.lang3.StringUtils.endsWithIgnoreCase;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import sun.net.www.ParseUtil;
/**
* A URL Connection to a Mule Artifact file or an entry in a Mule Artifact file.
*
* <p>The syntax of a Mule Artifact URL is (current support for protocols under URL are described in {@link #SUPPORTED_PROTOCOLS}):
* <pre>
* muleartifact:<url>!/{entry}
* </pre>
*
* <p>Where "url" is targeting a ZIP type of file to be decompressed, and the subsequent N-1 elements in "{entry}" are zips
* as well and the N element is the file to look for. That means the {@link #getInputStream()} will open as many zips as
* needed to look for the file.
*
* <p>valid samples:
* <p>{@code muleartifact:file:/folder/mule-plugin.zip!/classes!/org/foo/echo/aResource.txt}
* <p>{@code muleartifact:file:/folder/mule-plugin.zip!/lib/test-jar-with-resources.jar!/test-resource-2.txt}
*
* <p> invalid samples:
* <p>{@code muleartifact:file:/folder/mule-plugin.zip}
* <p>{@code muleartifact:file:/folder/mule-plugin.zip!/}
* <p>{@code muleartifact:http:/folder/mule-plugin.zip!/classes/org/foo/echo/aResource.txt} (protocol is 'http')
*
* <p>Notice that after the URL targeting the ZIP file, there must be several separators '!/' elements, due to the fact
* that this class is meant to open every ZIP until it finds out the expected file.
*
* TODO(fernandezlautaro): MULE-10892 at some moment this class should be strong enough to support any type of artifact.
* @since 4.0
*/
public class MuleArtifactUrlConnection extends URLConnection {
public static final String SEPARATOR = "!/";
public static final String CLASSES_FOLDER = "classes";
private static final List<String> SUPPORTED_PROTOCOLS = asList("file");
/**
* initialized from the first element of the split URL, which must be a zip form of file
*/
private ZipFile artifactZip;
private List<String> files;
/**
* Takes an {@link URL} to validate its format in the {@link #connect()} ()} method, if there aren't any problem, it
* will store the ZIP file in {@code artifactZip} and all the files that are accessible from that starting point.
*/
public MuleArtifactUrlConnection(URL url) {
super(url);
}
/**
* Given the {@link URL} that was feed in the {@link #MuleArtifactUrlConnection(URL)} constructor, it will validate
* its format through the {@link #parseSpecs()} method.
* <p>
* If there aren't any problem during validation, it will store a ZIP file in {@code artifactZip} and all the
* subsequent files that are accessible from that starting point.
*
* @throws IOException if the first element is not a ZIP file, or if the protocol is not supported, or if it's
* impossible to create a {@link ZipFile} from the parsed {@code url}, or if there's not at least a {@link #SEPARATOR}
* in the {@code url}.
*/
@Override
public void connect() throws IOException {
if (!connected) {
parseSpecs();
this.connected = true;
}
}
/**
* Returns an input stream that represents the element in the {@code url} from the farthest {@link #SEPARATOR} mark.
* For the following {@link URL} samples:
* <p>{@code muleartifact:file:/folder/mule-plugin.zip!/classes!/org/foo/echo/aResource.txt}
* <p>{@code muleartifact:file:/folder/mule-plugin.zip!/lib/test-jar-with-resources.jar!/test-resource-2.txt}
*
* The expected input streams will be the content of "org/foo/echo/aResource.txt" and "test-resource-2.txt"
* respectively.
*
* @return an input stream that represents the element in the {@code url} from the farthest {@link #SEPARATOR} mark.
* @throws IOException
*/
@Override
public InputStream getInputStream() throws IOException {
connect();
Deque<String> queue = new ArrayDeque<>(files);
String filename = queue.pop();
ZipEntry entry = artifactZip.getEntry(filename);
if (entry == null) {
throw new MalformedURLException(format("File '%s' is missing in '%s' artifact", filename, artifactZip.getName()));
}
InputStream is = artifactZip.getInputStream(entry);
if (!queue.isEmpty()) {
//there are more files to look for, will work them recursively assuming each entry is either a ZIP until we get up to the file
is = getInputStream(is, queue);
}
return is;
}
private void parseSpecs() throws MalformedURLException {
String spec = url.getFile();
int separator = seekFirstSeparator(spec);
URL muleArtifactLocation = new URL(spec.substring(0, separator++));
if (!SUPPORTED_PROTOCOLS.contains((muleArtifactLocation.getProtocol()))) {
throw new MalformedURLException(format("Supported protocols for '%s' are '%s', but received '%s' (full URL received '%s')",
MuleArtifactUrlStreamHandler.PROTOCOL,
join(",", SUPPORTED_PROTOCOLS),
muleArtifactLocation.getProtocol(),
url.toString()));
}
try {
artifactZip = new ZipFile(URLDecoder.decode(muleArtifactLocation.getFile(), StandardCharsets.UTF_8.name()));
} catch (IOException e) {
throw new MalformedURLException(format("There was a problem opening a zip for '%s'", muleArtifactLocation));
}
files = getFiles(spec.substring(++separator));
}
private int seekFirstSeparator(String spec) throws MalformedURLException {
int separator = spec.indexOf(SEPARATOR);
if (separator == -1) {
throw new MalformedURLException(format("No separator '%s' found in url spec '%s'", SEPARATOR, spec));
}
return separator;
}
/**
* Recursively iterates the {@code files} queue to lookup for the element, ends successfully when it gets to the
* bottom of it.
*
* @param currentStream position to the current element of the zip file (it moves in the recursion targeting the
* contents of a ZIP file)
* @param files the queue with the files that has to be introspected
* @return the input stream of the file that has been looked for
* @throws IOException if the file is not present
*/
private InputStream getInputStream(InputStream currentStream, Deque<String> files) throws IOException {
if (files.isEmpty()) {
return currentStream;
}
String expectedFile = files.pop();
ZipInputStream zipInputStream = new ZipInputStream(currentStream);
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
if (entry.getName().equals(expectedFile)) {
return getInputStream(zipInputStream, files);
}
}
throw new MalformedURLException(format("Can't find the %s file in %s", expectedFile, artifactZip.getName()));
}
/**
* As this class is meant to help a {@link ClassLoader}, it will look for resources within zips, the "classes"
* is a particular scenario
* where it will collapse with the subsequent element of the split file, as /classes in a mule module is never
* compressed, different scenario for /lib folder, where every element there is a compressed jar.
*
* @param files to split by {@link #SEPARATOR}
* @return the elements to work with when opening the stream
*/
private List<String> getFiles(final String files) {
if (files.isEmpty()) {
throw new IllegalArgumentException(format("There's no file to process after the first '%s' (full URL received '%s')",
SEPARATOR, url.toString()));
}
String[] resources = files.split(SEPARATOR);
if (resources.length == 2 && !isCompressed(resources[0])) {
// this scenario handles the /classes!/org/foo/echo/MyClass.class
return asList(resources[0].concat("/").concat(resources[1]));
}
return Arrays.stream(resources)
.map(ParseUtil::decode).collect(Collectors.toList());
}
private boolean isCompressed(String resource) {
return endsWithIgnoreCase(resource, ".zip") || endsWithIgnoreCase(resource, ".jar");
}
}