diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntriesStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntriesStream.java new file mode 100644 index 000000000000..35d9421874b4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarEntriesStream.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-2024 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.Closeable; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.zip.Inflater; +import java.util.zip.ZipEntry; + +/** + * Helper class to iterate entries in a jar file and check that content matches a related + * entry. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarEntriesStream implements Closeable { + + private static final int BUFFER_SIZE = 4 * 1024; + + private final JarInputStream in; + + private final byte[] inBuffer = new byte[BUFFER_SIZE]; + + private final byte[] compareBuffer = new byte[BUFFER_SIZE]; + + private final Inflater inflater = new Inflater(true); + + private JarEntry entry; + + JarEntriesStream(InputStream in) throws IOException { + this.in = new JarInputStream(in); + } + + JarEntry getNextEntry() throws IOException { + this.entry = this.in.getNextJarEntry(); + if (this.entry != null) { + this.entry.getSize(); + } + this.inflater.reset(); + return this.entry; + } + + boolean matches(boolean directory, int size, int compressionMethod, InputStreamSupplier streamSupplier) + throws IOException { + if (this.entry.isDirectory() != directory) { + fail("directory"); + } + if (this.entry.getMethod() != compressionMethod) { + fail("compression method"); + } + if (this.entry.isDirectory()) { + this.in.closeEntry(); + return true; + } + try (DataInputStream expected = new DataInputStream(getInputStream(size, streamSupplier))) { + assertSameContent(expected); + } + return true; + } + + private InputStream getInputStream(int size, InputStreamSupplier streamSupplier) throws IOException { + InputStream inputStream = streamSupplier.get(); + return (this.entry.getMethod() != ZipEntry.DEFLATED) ? inputStream + : new ZipInflaterInputStream(inputStream, this.inflater, size); + } + + private void assertSameContent(DataInputStream expected) throws IOException { + int len; + while ((len = this.in.read(this.inBuffer)) > 0) { + try { + expected.readFully(this.compareBuffer, 0, len); + if (Arrays.equals(this.inBuffer, 0, len, this.compareBuffer, 0, len)) { + continue; + } + } + catch (EOFException ex) { + // Continue and throw exception due to mismatched content length. + } + fail("content"); + } + if (expected.read() != -1) { + fail("content"); + } + } + + private void fail(String check) { + throw new IllegalStateException("Content mismatch when reading security info for entry '%s' (%s check)" + .formatted(this.entry.getName(), check)); + } + + @Override + public void close() throws IOException { + this.inflater.end(); + this.in.close(); + } + + @FunctionalInterface + interface InputStreamSupplier { + + InputStream get() throws IOException; + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java index d151c8d80a85..bf4e3bcbbd0e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -26,7 +26,6 @@ import java.util.NoSuchElementException; import java.util.jar.Attributes; import java.util.jar.Attributes.Name; -import java.util.jar.JarInputStream; import java.util.jar.Manifest; import java.util.zip.ZipEntry; @@ -334,37 +333,30 @@ private AsciiBytes applyFilter(AsciiBytes 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); - } - } - } + certifications = getCertifications(); 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; + private JarEntryCertification[] getCertifications() throws IOException { + JarEntryCertification[] certifications = new JarEntryCertification[this.size]; + try (JarEntriesStream entries = new JarEntriesStream(this.jarFile.getData().getInputStream())) { + java.util.jar.JarEntry entry = entries.getNextEntry(); + while (entry != null) { + JarEntry relatedEntry = this.doGetEntry(entry.getName(), JarEntry.class, false, null); + if (relatedEntry != null && entries.matches(relatedEntry.isDirectory(), (int) relatedEntry.getSize(), + relatedEntry.getMethod(), () -> getEntryData(relatedEntry).getInputStream())) { + int index = relatedEntry.getIndex(); + if (index != -1) { + certifications[index] = JarEntryCertification.from(entry); + } + } + entry = entries.getNextEntry(); } - index++; } - return -1; + return certifications; } private static void swap(int[] array, int i, int j) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java index 67624460ccd7..71750d1ab432 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -30,12 +30,23 @@ */ class ZipInflaterInputStream extends InflaterInputStream { + private final boolean ownsInflator; + private int available; private boolean extraBytesWritten; ZipInflaterInputStream(InputStream inputStream, int size) { - super(inputStream, new Inflater(true), getInflaterBufferSize(size)); + this(inputStream, new Inflater(true), size, true); + } + + ZipInflaterInputStream(InputStream inputStream, Inflater inflater, int size) { + this(inputStream, inflater, size, false); + } + + private ZipInflaterInputStream(InputStream inputStream, Inflater inflater, int size, boolean ownsInflator) { + super(inputStream, inflater, getInflaterBufferSize(size)); + this.ownsInflator = ownsInflator; this.available = size; } @@ -59,7 +70,9 @@ public int read(byte[] b, int off, int len) throws IOException { @Override public void close() throws IOException { super.close(); - this.inf.end(); + if (this.ownsInflator) { + this.inf.end(); + } } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java index 0e11c4858226..1b4d02d9a25e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java @@ -666,6 +666,26 @@ void jarFileEntryWithEpochTimeOfZeroShouldNotFail() throws Exception { } } + @Test + void mismatchedStreamEntriesThrowsException() throws IOException { + File mismatchJar = new File("src/test/resources/jars/mismatch.jar"); + IllegalStateException failure = null; + try (JarFile jarFile = new JarFile(mismatchJar)) { + JarFile nestedJarFile = jarFile.getNestedJarFile(jarFile.getJarEntry("inner.jar")); + Enumeration entries = nestedJarFile.entries(); + while (entries.hasMoreElements()) { + try { + entries.nextElement().getCodeSigners(); + } + catch (IllegalStateException ex) { + failure = (failure != null) ? failure : ex; + } + } + } + assertThat(failure) + .hasMessage("Content mismatch when reading security info for entry 'content' (content check)"); + } + private File createJarFileWithEpochTimeOfZero() throws Exception { File jarFile = new File(this.tempDir, "temp.jar"); FileOutputStream fileOutputStream = new FileOutputStream(jarFile); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/mismatch.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/mismatch.jar new file mode 100644 index 000000000000..1f096171614a Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/resources/jars/mismatch.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntriesStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntriesStream.java new file mode 100644 index 000000000000..35d9421874b4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntriesStream.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-2024 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.Closeable; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.zip.Inflater; +import java.util.zip.ZipEntry; + +/** + * Helper class to iterate entries in a jar file and check that content matches a related + * entry. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarEntriesStream implements Closeable { + + private static final int BUFFER_SIZE = 4 * 1024; + + private final JarInputStream in; + + private final byte[] inBuffer = new byte[BUFFER_SIZE]; + + private final byte[] compareBuffer = new byte[BUFFER_SIZE]; + + private final Inflater inflater = new Inflater(true); + + private JarEntry entry; + + JarEntriesStream(InputStream in) throws IOException { + this.in = new JarInputStream(in); + } + + JarEntry getNextEntry() throws IOException { + this.entry = this.in.getNextJarEntry(); + if (this.entry != null) { + this.entry.getSize(); + } + this.inflater.reset(); + return this.entry; + } + + boolean matches(boolean directory, int size, int compressionMethod, InputStreamSupplier streamSupplier) + throws IOException { + if (this.entry.isDirectory() != directory) { + fail("directory"); + } + if (this.entry.getMethod() != compressionMethod) { + fail("compression method"); + } + if (this.entry.isDirectory()) { + this.in.closeEntry(); + return true; + } + try (DataInputStream expected = new DataInputStream(getInputStream(size, streamSupplier))) { + assertSameContent(expected); + } + return true; + } + + private InputStream getInputStream(int size, InputStreamSupplier streamSupplier) throws IOException { + InputStream inputStream = streamSupplier.get(); + return (this.entry.getMethod() != ZipEntry.DEFLATED) ? inputStream + : new ZipInflaterInputStream(inputStream, this.inflater, size); + } + + private void assertSameContent(DataInputStream expected) throws IOException { + int len; + while ((len = this.in.read(this.inBuffer)) > 0) { + try { + expected.readFully(this.compareBuffer, 0, len); + if (Arrays.equals(this.inBuffer, 0, len, this.compareBuffer, 0, len)) { + continue; + } + } + catch (EOFException ex) { + // Continue and throw exception due to mismatched content length. + } + fail("content"); + } + if (expected.read() != -1) { + fail("content"); + } + } + + private void fail(String check) { + throw new IllegalStateException("Content mismatch when reading security info for entry '%s' (%s check)" + .formatted(this.entry.getName(), check)); + } + + @Override + public void close() throws IOException { + this.inflater.end(); + this.in.close(); + } + + @FunctionalInterface + interface InputStreamSupplier { + + InputStream get() throws IOException; + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java index 3b20bebdbe4d..a6a0a08b1ec2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -81,30 +81,31 @@ static SecurityInfo get(ZipContent content) { * @return the security info * @throws IOException on I/O error */ + @SuppressWarnings("resource") private static SecurityInfo load(ZipContent content) throws IOException { int size = content.size(); boolean hasSecurityInfo = false; Certificate[][] entryCertificates = new Certificate[size][]; CodeSigner[][] entryCodeSigners = new CodeSigner[size][]; - try (JarInputStream in = new JarInputStream(content.openRawZipData().asInputStream())) { - JarEntry jarEntry = in.getNextJarEntry(); - while (jarEntry != null) { - in.closeEntry(); // Close to trigger a read and set certs/signers - Certificate[] certificates = jarEntry.getCertificates(); - CodeSigner[] codeSigners = jarEntry.getCodeSigners(); - if (certificates != null || codeSigners != null) { - ZipContent.Entry contentEntry = content.getEntry(jarEntry.getName()); - if (contentEntry != null) { + try (JarEntriesStream entries = new JarEntriesStream(content.openRawZipData().asInputStream())) { + JarEntry entry = entries.getNextEntry(); + while (entry != null) { + ZipContent.Entry relatedEntry = content.getEntry(entry.getName()); + if (relatedEntry != null && entries.matches(relatedEntry.isDirectory(), + relatedEntry.getUncompressedSize(), relatedEntry.getCompressionMethod(), + () -> relatedEntry.openContent().asInputStream())) { + Certificate[] certificates = entry.getCertificates(); + CodeSigner[] codeSigners = entry.getCodeSigners(); + if (certificates != null || codeSigners != null) { hasSecurityInfo = true; - entryCertificates[contentEntry.getLookupIndex()] = certificates; - entryCodeSigners[contentEntry.getLookupIndex()] = codeSigners; + entryCertificates[relatedEntry.getLookupIndex()] = certificates; + entryCodeSigners[relatedEntry.getLookupIndex()] = codeSigners; } } - jarEntry = in.getNextJarEntry(); + entry = entries.getNextEntry(); } - return (!hasSecurityInfo) ? NONE : new SecurityInfo(entryCertificates, entryCodeSigners); } - + return (!hasSecurityInfo) ? NONE : new SecurityInfo(entryCertificates, entryCodeSigners); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java index 1528f0b9c507..095d24874916 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -29,7 +29,7 @@ * * @author Phillip Webb */ -abstract class ZipInflaterInputStream extends InflaterInputStream { +class ZipInflaterInputStream extends InflaterInputStream { private int available; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java index ad7882a4e6cd..f1d381cf308c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java @@ -412,6 +412,25 @@ void getCommentAlignsWithJdkJar() throws Exception { assertThat(nested).isEqualTo(jdk); } + @Test + void mismatchedStreamEntriesThrowsException() throws IOException { + File mismatchJar = new File("src/test/resources/jars/mismatch.jar"); + IllegalStateException failure = null; + try (NestedJarFile innerJar = new NestedJarFile(mismatchJar, "inner.jar")) { + Enumeration entries = innerJar.entries(); + while (entries.hasMoreElements()) { + try { + entries.nextElement().getCodeSigners(); + } + catch (IllegalStateException ex) { + failure = (failure != null) ? failure : ex; + } + } + } + assertThat(failure) + .hasMessage("Content mismatch when reading security info for entry 'content' (content check)"); + } + private List collectComments(JarFile jarFile) throws IOException { try { List comments = new ArrayList<>(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/jars/mismatch.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/jars/mismatch.jar new file mode 100644 index 000000000000..1f096171614a Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/jars/mismatch.jar differ