From 299367fb7464bb2e7f8e0b5b36dde065cae8fafa Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Thu, 1 Jul 2021 21:36:38 +0100 Subject: [PATCH] added PathClassLoader --- build.gradle | 36 ++++- gradle.properties | 9 +- src/main/java/net/woggioni/jwo/hash/Hash.java | 86 +++++++++++ .../java/net/woggioni/jwo/hash/Hasher.java | 39 ----- .../jwo/io/JarExtractorInputStream.java | 83 +++++++++++ .../jwo/io/UncloseableInputStream.java | 20 +++ .../jwo/io/UncloseableOutputStream.java | 19 +++ .../jwo/io/ZipExtractorInputStream.java | 59 ++++++++ .../woggioni/jwo/loader/PathClassLoader.java | 93 ++++++++++++ .../jwo/loader/PathURLConnection.java | 57 ++++++++ .../jwo/loader/PathURLStreamHandler.java | 26 ++++ .../jwo/signing/SignatureCollector.java | 71 +++++++++ .../jwo/io/ExtractorInputStreamTest.java | 138 ++++++++++++++++++ 13 files changed, 686 insertions(+), 50 deletions(-) create mode 100644 src/main/java/net/woggioni/jwo/hash/Hash.java delete mode 100644 src/main/java/net/woggioni/jwo/hash/Hasher.java create mode 100644 src/main/java/net/woggioni/jwo/io/JarExtractorInputStream.java create mode 100644 src/main/java/net/woggioni/jwo/io/UncloseableInputStream.java create mode 100644 src/main/java/net/woggioni/jwo/io/UncloseableOutputStream.java create mode 100644 src/main/java/net/woggioni/jwo/io/ZipExtractorInputStream.java create mode 100644 src/main/java/net/woggioni/jwo/loader/PathClassLoader.java create mode 100644 src/main/java/net/woggioni/jwo/loader/PathURLConnection.java create mode 100644 src/main/java/net/woggioni/jwo/loader/PathURLStreamHandler.java create mode 100644 src/main/java/net/woggioni/jwo/signing/SignatureCollector.java create mode 100644 src/test/java/net/woggioni/jwo/io/ExtractorInputStreamTest.java diff --git a/build.gradle b/build.gradle index 568cb33..e7ec176 100644 --- a/build.gradle +++ b/build.gradle @@ -6,8 +6,9 @@ plugins { allprojects { apply plugin: 'java-library' apply plugin: 'net.woggioni.gradle.lombok' + group = "net.woggioni" - version = jwoVersion + version = getProperty('jwo.version') repositories { maven { @@ -16,17 +17,17 @@ allprojects { mavenCentral() } dependencies { - testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junitJupiterVersion - testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-params', version: junitJupiterVersion - testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junitJupiterVersion + testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: getProperty('junitJupiter.version') + testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-params', version: getProperty('junitJupiter.version') + testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: getProperty('junitJupiter.version') } lombok { - version = lombokVersion + version = getProperty('lombok.version') } } dependencies { - implementation group: "org.slf4j", name: "slf4j-api", version: slf4jVersion + implementation group: "org.slf4j", name: "slf4j-api", version: getProperty('slf4j.version') } compileJava { @@ -41,9 +42,30 @@ jar { } } +test { + useJUnitPlatform() + Dependency junitJupiterEngineDependency = + dependencies.create( + group: 'org.junit.jupiter', + name: 'junit-jupiter-engine', + version: project.getProperty('junitJupiter.version') + ) + + File junitJupiterEngineJar = configurations.getByName(JavaPlugin.TEST_RUNTIME_CLASSPATH_CONFIGURATION_NAME) + .resolvedConfiguration.resolvedArtifacts.grep { ResolvedArtifact resolvedArtifact -> + ModuleVersionIdentifier id = resolvedArtifact.moduleVersion.id + id.group == 'org.junit.jupiter' && id.name == 'junit-jupiter-engine' + }.collect { + ResolvedArtifact resolvedArtifact -> resolvedArtifact.file + }.first() + systemProperties([ + 'junit.jupiter.engine.jar' : junitJupiterEngineJar.toString() + ]) +} + wrapper { distributionType = Wrapper.DistributionType.BIN - gradleVersion = "7.0.2" + gradleVersion = getProperty('gradle.version') } publishing { diff --git a/gradle.properties b/gradle.properties index b5535b0..bad4451 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ -jwoVersion=1.0 -junitJupiterVersion=5.7.0 -lombokVersion=1.18.16 -slf4jVersion=1.7.30 +gradle.version = 7.1 +jwo.version=1.0 +junitJupiter.version=5.7.0 +lombok.version=1.18.16 +slf4j.version=1.7.30 diff --git a/src/main/java/net/woggioni/jwo/hash/Hash.java b/src/main/java/net/woggioni/jwo/hash/Hash.java new file mode 100644 index 0000000..e6627a6 --- /dev/null +++ b/src/main/java/net/woggioni/jwo/hash/Hash.java @@ -0,0 +1,86 @@ +package net.woggioni.jwo.hash; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; + +import java.io.InputStream; +import java.security.MessageDigest; +import java.util.Arrays; + +@RequiredArgsConstructor +public class Hash { + + @RequiredArgsConstructor + enum Algorithm { + MD2("MD2"), + MD5("MD5"), + SHA1("SHA-1"), + SHA256("SHA-256"), + SHA384("SHA-384"), + SHA512("SHA-512"); + + private final String key; + } + + final Algorithm algorithm; + final byte[] bytes; + + @Override + public boolean equals(Object other) { + if(other == null) return false; + else if(getClass() != other.getClass()) return false; + Hash otherHash = (Hash) other; + if(algorithm != otherHash.algorithm) return false; + return Arrays.equals(bytes, otherHash.bytes); + } + + @Override + public int hashCode() { + int result = algorithm.hashCode(); + for(byte b : bytes) { + result ^= b; + } + return result; + } + + @SneakyThrows + public static Hash hash(Algorithm algo, InputStream is, byte[] buffer) { + MessageDigest md = MessageDigest.getInstance(algo.key); + int read; + while((read = is.read(buffer, 0, buffer.length)) >= 0) { + md.update(buffer, 0, read); + } + return new Hash(algo, md.digest()); + } + + @SneakyThrows + public static Hash hash(Algorithm algo, InputStream is) { + return hash(algo, is, new byte[0x1000]); + } + + @SneakyThrows + public static Hash md5(InputStream is) { + return md5(is, new byte[0x1000]); + } + + @SneakyThrows + public static Hash md5(InputStream is, byte[] buffer) { + return hash(Algorithm.MD5, is, buffer); + } + + public static String md5String(InputStream is) { + return bytesToHex(md5(is).bytes); + } + + final private static char[] hexArray = "0123456789ABCDEF".toCharArray(); + + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for(int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } +} \ No newline at end of file diff --git a/src/main/java/net/woggioni/jwo/hash/Hasher.java b/src/main/java/net/woggioni/jwo/hash/Hasher.java deleted file mode 100644 index 087cb47..0000000 --- a/src/main/java/net/woggioni/jwo/hash/Hasher.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.woggioni.jwo.hash; - -import lombok.SneakyThrows; - -import java.io.InputStream; -import java.security.MessageDigest; - -public class Hasher { - - private Hasher() {} - - @SneakyThrows - public static byte[] md5(InputStream is) { - MessageDigest md = MessageDigest.getInstance("MD5"); - byte[] buffer = new byte[1024]; - int read; - while((read = is.read(buffer, 0, buffer.length)) >= 0) { - md.update(buffer, 0, read); - } - return md.digest(); - } - - public static String md5String(InputStream is) { - return bytesToHex(md5(is)); - } - - - final private static char[] hexArray = "0123456789ABCDEF".toCharArray(); - - public static String bytesToHex(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for(int j = 0; j < bytes.length; j++) { - int v = bytes[j] & 0xFF; - hexChars[j * 2] = hexArray[v >>> 4]; - hexChars[j * 2 + 1] = hexArray[v & 0x0F]; - } - return new String(hexChars); - } -} \ No newline at end of file diff --git a/src/main/java/net/woggioni/jwo/io/JarExtractorInputStream.java b/src/main/java/net/woggioni/jwo/io/JarExtractorInputStream.java new file mode 100644 index 0000000..b11779d --- /dev/null +++ b/src/main/java/net/woggioni/jwo/io/JarExtractorInputStream.java @@ -0,0 +1,83 @@ +package net.woggioni.jwo.io; + +import net.woggioni.jwo.JWO; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarFile; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +/** + * Input stream that extract a jar archive in the provided {@param destination} while reading it + */ +class JarExtractorInputStream extends JarInputStream { + + private final String sourceLocation; + private final Path destination; + private OutputStream currentFile = null; + + public JarExtractorInputStream(InputStream source, + Path destination, + boolean verify, + String sourceLocation) throws IOException { + super(source, verify); + this.sourceLocation = sourceLocation; + this.destination = destination; + Path newFileSystemLocation = destination.resolve(JarFile.MANIFEST_NAME); + Files.createDirectories(newFileSystemLocation.getParent()); + try(OutputStream outputStream = Files.newOutputStream(newFileSystemLocation)) { + Manifest manifest = getManifest(); + if(manifest == null) { + String location; + if(sourceLocation == null) { + location = ""; + } else { + location = String.format("from '%s'", sourceLocation); + } + throw JWO.newThrowable(IOException.class, + "The source stream %s doesn't represent a valid jar file", location); + } + manifest.write(outputStream); + } + } + + @Override + public ZipEntry getNextEntry() throws IOException { + ZipEntry entry = super.getNextEntry(); + if(entry != null) { + Path newFileSystemLocation = destination.resolve(entry.getName()); + if(entry.isDirectory()) { + Files.createDirectories(newFileSystemLocation); + } else { + Files.createDirectories(newFileSystemLocation.getParent()); + currentFile = Files.newOutputStream(newFileSystemLocation); + } + } + return entry; + } + + @Override + public int read() throws IOException { + int result = super.read(); + if(result != -1 && currentFile != null) currentFile.write(result); + return result; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int read = super.read(b, off, len); + if(read != -1 && currentFile != null) currentFile.write(b, off, read); + return read; + } + + @Override + public void closeEntry() throws IOException{ + super.closeEntry(); + if(currentFile != null) currentFile.close(); + } +} diff --git a/src/main/java/net/woggioni/jwo/io/UncloseableInputStream.java b/src/main/java/net/woggioni/jwo/io/UncloseableInputStream.java new file mode 100644 index 0000000..75cf3b2 --- /dev/null +++ b/src/main/java/net/woggioni/jwo/io/UncloseableInputStream.java @@ -0,0 +1,20 @@ +package net.woggioni.jwo.io; + +import java.io.FilterInputStream; +import java.io.InputStream; + +/** + * {@link InputStream} wrapper that prevents it from being closed, useful to pass an {@link InputStream} instance + * to a method that closes the stream before it has been fully consumed + * (and whose remaining content is still needed by the caller) + */ +public class UncloseableInputStream extends FilterInputStream { + + public UncloseableInputStream(InputStream source) { + super(source); + } + + @Override + public void close() { } +} + diff --git a/src/main/java/net/woggioni/jwo/io/UncloseableOutputStream.java b/src/main/java/net/woggioni/jwo/io/UncloseableOutputStream.java new file mode 100644 index 0000000..65b2a6b --- /dev/null +++ b/src/main/java/net/woggioni/jwo/io/UncloseableOutputStream.java @@ -0,0 +1,19 @@ +package net.woggioni.jwo.io; + +import java.io.FilterOutputStream; +import java.io.OutputStream; + +/** + * {@link OutputStream} wrapper that prevents it from being closed, useful to pass an {@link OutputStream} instance + * to a method that closes the stream before it has been finalized by the caller + */ +public class UncloseableOutputStream extends FilterOutputStream { + + public UncloseableOutputStream(OutputStream source) { + super(source); + } + + @Override + public void close() { + } +} diff --git a/src/main/java/net/woggioni/jwo/io/ZipExtractorInputStream.java b/src/main/java/net/woggioni/jwo/io/ZipExtractorInputStream.java new file mode 100644 index 0000000..7452782 --- /dev/null +++ b/src/main/java/net/woggioni/jwo/io/ZipExtractorInputStream.java @@ -0,0 +1,59 @@ +package net.woggioni.jwo.io; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Input stream that extract a zip archive in the provided {@param destination} while reading it + */ +class ZipExtractorInputStream extends ZipInputStream { + + public ZipExtractorInputStream(InputStream source, Path destination) { + super(source); + this.destination = destination; + } + + private final Path destination; + + private OutputStream currentFile = null; + + @Override + public ZipEntry getNextEntry() throws IOException { + ZipEntry entry = super.getNextEntry(); + if(entry != null) { + Path newFileSystemLocation = destination.resolve(entry.getName()); + if(entry.isDirectory()) { + Files.createDirectories(newFileSystemLocation); + } else { + Files.createDirectories(newFileSystemLocation.getParent()); + currentFile = Files.newOutputStream(newFileSystemLocation); + } + } + return entry; + } + + @Override + public int read() throws IOException { + int result = super.read(); + if(result != -1 && currentFile != null) currentFile.write(result); + return result; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int read = super.read(b, off, len); + if(read != -1 && currentFile != null) currentFile.write(b, off, read); + return read; + } + + @Override + public void closeEntry() throws IOException{ + super.closeEntry(); + if(currentFile != null) currentFile.close(); + } +} diff --git a/src/main/java/net/woggioni/jwo/loader/PathClassLoader.java b/src/main/java/net/woggioni/jwo/loader/PathClassLoader.java new file mode 100644 index 0000000..d5d3416 --- /dev/null +++ b/src/main/java/net/woggioni/jwo/loader/PathClassLoader.java @@ -0,0 +1,93 @@ +package net.woggioni.jwo.loader; + +import lombok.SneakyThrows; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; + +/** + * A classloader that loads classes from a {@link Path} instance + */ +public final class PathClassLoader extends ClassLoader { + + private final Path path; + + static { + registerAsParallelCapable(); + } + + public PathClassLoader(Path path) { + this(path, null); + } + + public PathClassLoader(Path path, ClassLoader parent) { + super(parent); + this.path = path; + } + + @Override + @SneakyThrows + protected Class findClass(String name) { + Path classPath = path.resolve(name.replace('.', '/').concat(".class")); + if (Files.exists(classPath)) { + byte[] byteCode = Files.readAllBytes(classPath); + return defineClass(name, byteCode, 0, byteCode.length); + } else { + throw new ClassNotFoundException(name); + } + } + + @Override + @SneakyThrows + protected URL findResource(String name) { + Path resolved = path.resolve(name); + if (Files.exists(resolved)) { + return toURL(resolved); + } else { + return null; + } + } + + @Override + protected Enumeration findResources(final String name) throws IOException { + final List resources = new ArrayList<>(1); + + Files.walkFileTree(path, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if (!name.isEmpty()) { + this.addIfMatches(resources, file); + } + return super.visitFile(file, attrs); + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + if (!name.isEmpty() || path.equals(dir)) { + this.addIfMatches(resources, dir); + } + return super.preVisitDirectory(dir, attrs); + } + + void addIfMatches(List resources, Path file) throws IOException { + if (path.relativize(file).toString().equals(name)) { + resources.add(toURL(file)); + } + } + }); + return Collections.enumeration(resources); + } + + private static URL toURL(Path path) throws IOException { + return new URL(null, path.toUri().toString(), PathURLStreamHandler.INSTANCE); + } +} \ No newline at end of file diff --git a/src/main/java/net/woggioni/jwo/loader/PathURLConnection.java b/src/main/java/net/woggioni/jwo/loader/PathURLConnection.java new file mode 100644 index 0000000..9b809b4 --- /dev/null +++ b/src/main/java/net/woggioni/jwo/loader/PathURLConnection.java @@ -0,0 +1,57 @@ +package net.woggioni.jwo.loader; + +import lombok.SneakyThrows; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; + +final class PathURLConnection extends URLConnection { + + private final Path path; + + PathURLConnection(URL url, Path path) { + super(url); + this.path = path; + } + + @Override + public void connect() {} + + @Override + public long getContentLengthLong() { + try { + return Files.size(this.path); + } catch (IOException e) { + throw new RuntimeException("could not get size of: " + this.path, e); + } + } + + @Override + public InputStream getInputStream() throws IOException { + return Files.newInputStream(this.path); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return Files.newOutputStream(this.path); + } + + @Override + @SneakyThrows + public String getContentType() { + return Files.probeContentType(this.path); + } + + @Override + @SneakyThrows + public long getLastModified() { + BasicFileAttributes attributes = Files.readAttributes(this.path, BasicFileAttributes.class); + return attributes.lastModifiedTime().toMillis(); + } +} \ No newline at end of file diff --git a/src/main/java/net/woggioni/jwo/loader/PathURLStreamHandler.java b/src/main/java/net/woggioni/jwo/loader/PathURLStreamHandler.java new file mode 100644 index 0000000..353ac26 --- /dev/null +++ b/src/main/java/net/woggioni/jwo/loader/PathURLStreamHandler.java @@ -0,0 +1,26 @@ +package net.woggioni.jwo.loader; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.SneakyThrows; + +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.nio.file.Path; +import java.nio.file.Paths; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class PathURLStreamHandler extends URLStreamHandler { + + static final URLStreamHandler INSTANCE = new PathURLStreamHandler(); + + @Override + @SneakyThrows + protected URLConnection openConnection(URL url) { + URI uri = url.toURI(); + Path path = Paths.get(uri); + return new PathURLConnection(url, path); + } +} diff --git a/src/main/java/net/woggioni/jwo/signing/SignatureCollector.java b/src/main/java/net/woggioni/jwo/signing/SignatureCollector.java new file mode 100644 index 0000000..800a18e --- /dev/null +++ b/src/main/java/net/woggioni/jwo/signing/SignatureCollector.java @@ -0,0 +1,71 @@ +package net.woggioni.jwo.signing; + +import java.security.CodeSigner; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.regex.Pattern; + +/** + * Helper class to extract signatures from a jar file, it has to be used calling {@link #addEntry} on all of the jar's {@link JarEntry} + * after having consumed their entry content from the source (@link java.util.jar.JarInputStream}, then {@link #getCertificates()} + * will return the public keys of the jar's signers. + */ +class SignatureCollector { + + /** + * @see + * Additionally accepting *.EC as its valid for [java.util.jar.JarVerifier] and jarsigner @see https://docs.oracle.com/javase/8/docs/technotes/tools/windows/jarsigner.html, + * temporally treating META-INF/INDEX.LIST as unsignable entry because [java.util.jar.JarVerifier] doesn't load its signers. + */ + private static final Pattern unsignableEntryName = Pattern.compile("META-INF/(?:(?:.*[.](?:SF|DSA|RSA|EC)|SIG-.*)|INDEX\\.LIST)"); + + /** + * @return if the [entry] [JarEntry] can be signed. + */ + static boolean isSignable(JarEntry entry) { + return !entry.isDirectory() && !unsignableEntryName.matcher(entry.getName()).matches(); + } + + private static Set signers2OrderedPublicKeys(CodeSigner[] signers) { + Set result = new LinkedHashSet<>(); + for (CodeSigner signer : signers) { + result.add((signer.getSignerCertPath().getCertificates().get(0)).getPublicKey()); + } + return Collections.unmodifiableSet(result); + } + + private String firstSignedEntry = null; + private CodeSigner[] codeSigners = null; + private Set _certificates; + + public final Set getCertificates() { + return Collections.unmodifiableSet(_certificates); + } + + public void addEntry(JarEntry jarEntry) { + if (isSignable(jarEntry)) { + CodeSigner[] entrySigners = jarEntry.getCodeSigners() != null ? jarEntry.getCodeSigners() : new CodeSigner[0]; + if (codeSigners == null) { + codeSigners = entrySigners; + firstSignedEntry = jarEntry.getName(); + for (CodeSigner signer : entrySigners) { + _certificates.add(signer.getSignerCertPath().getCertificates().get(0)); + } + } + if (!Arrays.equals(codeSigners, entrySigners)) { + throw new IllegalArgumentException(String.format( + "Mismatch between signers %s for file %s and signers %s for file %s", + signers2OrderedPublicKeys(codeSigners), + firstSignedEntry, + signers2OrderedPublicKeys(entrySigners), + jarEntry.getName())); + } + } + } +} + diff --git a/src/test/java/net/woggioni/jwo/io/ExtractorInputStreamTest.java b/src/test/java/net/woggioni/jwo/io/ExtractorInputStreamTest.java new file mode 100644 index 0000000..b979ea0 --- /dev/null +++ b/src/test/java/net/woggioni/jwo/io/ExtractorInputStreamTest.java @@ -0,0 +1,138 @@ +package net.woggioni.jwo.io; + +import lombok.SneakyThrows; +import net.woggioni.jwo.JWO; +import net.woggioni.jwo.hash.Hash; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +public class ExtractorInputStreamTest { + private Path testDir; + private Path testJar; + private Path referenceExtractionDestination; + private Path testExtractionDestination; + + @BeforeEach + void setup(@TempDir Path testDir) { + this.testDir = testDir; + testJar = Path.of(System.getProperty("junit.jupiter.engine.jar")); + referenceExtractionDestination = testDir.resolve("referenceExtraction"); + testExtractionDestination = testDir.resolve("testExtraction"); + } + + + @SneakyThrows + private static void referenceUnzipMethod(Path source, Path destination) { + try(FileSystem fs = FileSystems.newFileSystem(source, null)) { + for(Path root : fs.getRootDirectories()) { + Files.walk(root) + .filter(Predicate.not(Files::isDirectory)).forEach(new Consumer() { + @Override + @SneakyThrows + public void accept(Path path) { + Path newDir = destination.resolve(root.relativize(path).toString()); + Files.createDirectories(newDir.getParent()); + Files.copy(path, newDir); + } + }); + } + } + } + + @SneakyThrows + private static NavigableMap hashFileTree(Path tree) { + NavigableMap result = new TreeMap<>(); + byte[] buffer = new byte[0x1000]; + FileVisitor visitor = new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + String key = tree.relativize(file).toString(); + if(!Objects.equals(JarFile.MANIFEST_NAME, key)) { + try (InputStream is = Files.newInputStream(file)) { + result.put(key, Hash.md5(is, buffer)); + } + } else { + Manifest manifest = new Manifest(); + try (InputStream is = Files.newInputStream(file)) { + manifest.read(is); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + manifest.write(baos); + } finally { + baos.close(); + } + result.put(key, Hash.md5(new ByteArrayInputStream(baos.toByteArray()), buffer)); + } + return FileVisitResult.CONTINUE; + } + }; + Files.walkFileTree(tree, visitor); + return result; + } + + private static boolean compareFileTree(Path tree1, Path tree2) { + NavigableMap hash1 = hashFileTree(tree1); + NavigableMap hash2 = hashFileTree(tree2); + return Objects.equals(hash1, hash2); + } + + @SneakyThrows + public void run(Supplier zipInputStreamSupplier) { + referenceUnzipMethod(testJar, referenceExtractionDestination); + try(ZipInputStream zipInputStream = zipInputStreamSupplier.get()) { + while(true) { + ZipEntry zipEntry = zipInputStream.getNextEntry(); + if(zipEntry == null) break; + JWO.write2Stream(new NullOutputStream(), zipInputStream); + zipInputStream.closeEntry(); + } + } + Assertions.assertTrue(compareFileTree(referenceExtractionDestination, testExtractionDestination)); + } + + @Test + @SneakyThrows + public void zipExtractorInputStreamTest() { + Supplier supplier = new Supplier() { + @Override + @SneakyThrows + public ZipInputStream get() { + return new ZipExtractorInputStream(Files.newInputStream(testJar), testExtractionDestination); + } + }; + run(supplier); + } + + @Test + @SneakyThrows + public void jarExtractorInputStreamTest() { + Supplier supplier = new Supplier() { + @Override + @SneakyThrows + public ZipInputStream get() { + return new JarExtractorInputStream(Files.newInputStream(testJar), testExtractionDestination, true, null); + } + }; + run(supplier); + } +}