Skip to content

Commit c6a9696

Browse files
committed
Polish 'Fix signed jar performance issues'
Update the performance improvements to push certificate loading and storage into the `JarFileEntries` class. This allows us to keep certificates without needing to cache all entry data. We now also keep certificates and code signers in a dedicated class which is set whenever the full jar stream as been read, even if the contained values are `null`. The logic that assumes META-INF entries are not signed has been removed in favor of delegating to the streamed entry results. See gh-19041
1 parent 4d053e1 commit c6a9696

File tree

5 files changed

+148
-75
lines changed

5 files changed

+148
-75
lines changed

spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2019 the original author or authors.
2+
* Copyright 2012-2020 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -32,20 +32,21 @@
3232
*/
3333
class JarEntry extends java.util.jar.JarEntry implements FileHeader {
3434

35+
private final int index;
36+
3537
private final AsciiBytes name;
3638

3739
private final AsciiBytes headerName;
3840

39-
private Certificate[] certificates;
40-
41-
private CodeSigner[] codeSigners;
42-
4341
private final JarFile jarFile;
4442

4543
private long localHeaderOffset;
4644

47-
JarEntry(JarFile jarFile, CentralDirectoryFileHeader header, AsciiBytes nameAlias) {
45+
private volatile JarEntryCertification certification;
46+
47+
JarEntry(JarFile jarFile, int index, CentralDirectoryFileHeader header, AsciiBytes nameAlias) {
4848
super((nameAlias != null) ? nameAlias.toString() : header.getName().toString());
49+
this.index = index;
4950
this.name = (nameAlias != null) ? nameAlias : header.getName();
5051
this.headerName = header.getName();
5152
this.jarFile = jarFile;
@@ -59,6 +60,10 @@ class JarEntry extends java.util.jar.JarEntry implements FileHeader {
5960
setExtra(header.getExtra());
6061
}
6162

63+
int getIndex() {
64+
return this.index;
65+
}
66+
6267
AsciiBytes getAsciiBytesName() {
6368
return this.name;
6469
}
@@ -85,32 +90,29 @@ public Attributes getAttributes() throws IOException {
8590

8691
@Override
8792
public Certificate[] getCertificates() {
88-
if (this.jarFile.isSigned() && this.certificates == null && isSignable()) {
89-
this.jarFile.setupEntryCertificates();
90-
}
91-
return this.certificates;
93+
return getCertification().getCertificates();
9294
}
9395

9496
@Override
9597
public CodeSigner[] getCodeSigners() {
96-
if (this.jarFile.isSigned() && this.codeSigners == null && isSignable()) {
97-
this.jarFile.setupEntryCertificates();
98-
}
99-
return this.codeSigners;
98+
return getCertification().getCodeSigners();
10099
}
101100

102-
void setCertificates(java.util.jar.JarEntry entry) {
103-
this.certificates = entry.getCertificates();
104-
this.codeSigners = entry.getCodeSigners();
101+
private JarEntryCertification getCertification() {
102+
if (!this.jarFile.isSigned()) {
103+
return JarEntryCertification.NONE;
104+
}
105+
JarEntryCertification certification = this.certification;
106+
if (certification == null) {
107+
certification = this.jarFile.getCertification(this);
108+
this.certification = certification;
109+
}
110+
return certification;
105111
}
106112

107113
@Override
108114
public long getLocalHeaderOffset() {
109115
return this.localHeaderOffset;
110116
}
111117

112-
private boolean isSignable() {
113-
return !isDirectory() && !getName().startsWith("META-INF");
114-
}
115-
116118
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2012-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.loader.jar;
18+
19+
import java.security.CodeSigner;
20+
import java.security.cert.Certificate;
21+
22+
/**
23+
* {@link Certificate} and {@link CodeSigner} details for a {@link JarEntry} from a signed
24+
* {@link JarFile}.
25+
*
26+
* @author Phillip Webb
27+
*/
28+
class JarEntryCertification {
29+
30+
static final JarEntryCertification NONE = new JarEntryCertification(null, null);
31+
32+
private final Certificate[] certificates;
33+
34+
private final CodeSigner[] codeSigners;
35+
36+
JarEntryCertification(Certificate[] certificates, CodeSigner[] codeSigners) {
37+
this.certificates = certificates;
38+
this.codeSigners = codeSigners;
39+
}
40+
41+
Certificate[] getCertificates() {
42+
return (this.certificates != null) ? this.certificates.clone() : null;
43+
}
44+
45+
CodeSigner[] getCodeSigners() {
46+
return (this.codeSigners != null) ? this.codeSigners.clone() : null;
47+
}
48+
49+
public static JarEntryCertification from(java.util.jar.JarEntry certifiedEntry) {
50+
Certificate[] certificates = (certifiedEntry != null) ? certifiedEntry.getCertificates() : null;
51+
CodeSigner[] codeSigners = (certifiedEntry != null) ? certifiedEntry.getCodeSigners() : null;
52+
if (certificates == null && codeSigners == null) {
53+
return NONE;
54+
}
55+
return new JarEntryCertification(certificates, codeSigners);
56+
}
57+
58+
}

spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import java.util.Enumeration;
2828
import java.util.Iterator;
2929
import java.util.function.Supplier;
30-
import java.util.jar.JarInputStream;
3130
import java.util.jar.Manifest;
3231
import java.util.zip.ZipEntry;
3332

@@ -387,29 +386,15 @@ boolean isSigned() {
387386
return this.signed;
388387
}
389388

390-
void setupEntryCertificates() {
391-
// Fallback to JarInputStream to obtain certificates, not fast but hopefully not
392-
// happening that often.
389+
JarEntryCertification getCertification(JarEntry entry) {
393390
try {
394-
try (JarInputStream jarStream = new JarInputStream(getData().getInputStream())) {
395-
java.util.jar.JarEntry certEntry = null;
396-
while ((certEntry = jarStream.getNextJarEntry()) != null) {
397-
jarStream.closeEntry();
398-
setCertificates(getJarEntry(certEntry.getName()), certEntry);
399-
}
400-
}
391+
return this.entries.getCertification(entry);
401392
}
402393
catch (IOException ex) {
403394
throw new IllegalStateException(ex);
404395
}
405396
}
406397

407-
private void setCertificates(JarEntry entry, java.util.jar.JarEntry certEntry) {
408-
if (entry != null) {
409-
entry.setCertificates(certEntry);
410-
}
411-
}
412-
413398
public void clearCache() {
414399
this.entries.clearCache();
415400
}

spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2019 the original author or authors.
2+
* Copyright 2012-2020 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
2626
import java.util.NoSuchElementException;
2727
import java.util.jar.Attributes;
2828
import java.util.jar.Attributes.Name;
29+
import java.util.jar.JarInputStream;
2930
import java.util.jar.Manifest;
3031
import java.util.zip.ZipEntry;
3132

@@ -91,14 +92,13 @@ class JarFileEntries implements CentralDirectoryVisitor, Iterable<JarEntry> {
9192

9293
private Boolean multiReleaseJar;
9394

95+
private JarEntryCertification[] certifications;
96+
9497
private final Map<Integer, FileHeader> entriesCache = Collections
9598
.synchronizedMap(new LinkedHashMap<Integer, FileHeader>(16, 0.75f, true) {
9699

97100
@Override
98101
protected boolean removeEldestEntry(Map.Entry<Integer, FileHeader> eldest) {
99-
if (JarFileEntries.this.jarFile.isSigned()) {
100-
return false;
101-
}
102102
return size() >= ENTRY_CACHE_SIZE;
103103
}
104104

@@ -313,7 +313,7 @@ private <T extends FileHeader> T getEntry(int index, Class<T> type, boolean cach
313313
FileHeader entry = (cached != null) ? cached : CentralDirectoryFileHeader
314314
.fromRandomAccessData(this.centralDirectoryData, this.centralDirectoryOffsets[index], this.filter);
315315
if (CentralDirectoryFileHeader.class.equals(entry.getClass()) && type.equals(JarEntry.class)) {
316-
entry = new JarEntry(this.jarFile, (CentralDirectoryFileHeader) entry, nameAlias);
316+
entry = new JarEntry(this.jarFile, index, (CentralDirectoryFileHeader) entry, nameAlias);
317317
}
318318
if (cacheEntry && cached != entry) {
319319
this.entriesCache.put(index, entry);
@@ -344,6 +344,41 @@ private AsciiBytes applyFilter(AsciiBytes name) {
344344
return (this.filter != null) ? this.filter.apply(name) : name;
345345
}
346346

347+
JarEntryCertification getCertification(JarEntry entry) throws IOException {
348+
JarEntryCertification[] certifications = this.certifications;
349+
if (certifications == null) {
350+
certifications = new JarEntryCertification[this.size];
351+
// We fallback to use JarInputStream to obtain the certs. This isn't that
352+
// fast, but hopefully doesn't happen too often.
353+
try (JarInputStream certifiedJarStream = new JarInputStream(this.jarFile.getData().getInputStream())) {
354+
java.util.jar.JarEntry certifiedEntry = null;
355+
while ((certifiedEntry = certifiedJarStream.getNextJarEntry()) != null) {
356+
int index = getEntryIndex(certifiedEntry.getName());
357+
if (index != -1) {
358+
certifications[index] = JarEntryCertification.from(certifiedEntry);
359+
}
360+
certifiedJarStream.closeEntry();
361+
}
362+
}
363+
this.certifications = certifications;
364+
}
365+
JarEntryCertification certification = certifications[entry.getIndex()];
366+
return (certification != null) ? certification : JarEntryCertification.NONE;
367+
}
368+
369+
private int getEntryIndex(CharSequence name) {
370+
int hashCode = AsciiBytes.hashCode(name);
371+
int index = getFirstIndex(hashCode);
372+
while (index >= 0 && index < this.size && this.hashCodes[index] == hashCode) {
373+
CentralDirectoryFileHeader candidate = getEntry(index, CentralDirectoryFileHeader.class, false, null);
374+
if (candidate.hasName(name, NO_SUFFIX)) {
375+
return index;
376+
}
377+
index++;
378+
}
379+
return -1;
380+
}
381+
347382
/**
348383
* Iterator for contained entries.
349384
*/
@@ -363,7 +398,7 @@ public JarEntry next() {
363398
}
364399
int entryIndex = JarFileEntries.this.positions[this.index];
365400
this.index++;
366-
return getEntry(entryIndex, JarEntry.class, true, null);
401+
return getEntry(entryIndex, JarEntry.class, false, null);
367402
}
368403

369404
}

spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
import java.net.URLClassLoader;
2727
import java.nio.charset.Charset;
2828
import java.nio.file.attribute.FileTime;
29-
import java.security.cert.Certificate;
3029
import java.time.Instant;
31-
import java.time.temporal.ChronoUnit;
3230
import java.util.Enumeration;
3331
import java.util.jar.JarEntry;
3432
import java.util.jar.JarInputStream;
@@ -46,6 +44,7 @@
4644
import org.springframework.boot.loader.data.RandomAccessDataFile;
4745
import org.springframework.test.util.ReflectionTestUtils;
4846
import org.springframework.util.FileCopyUtils;
47+
import org.springframework.util.StopWatch;
4948
import org.springframework.util.StreamUtils;
5049

5150
import static org.assertj.core.api.Assertions.assertThat;
@@ -377,39 +376,33 @@ public void sensibleToString() throws Exception {
377376

378377
@Test
379378
public void verifySignedJar() throws Exception {
380-
String classpath = System.getProperty("java.class.path");
381-
String[] entries = classpath.split(System.getProperty("path.separator"));
382-
String signedJarFile = null;
383-
for (String entry : entries) {
384-
if (entry.contains("bcprov")) {
385-
signedJarFile = entry;
379+
File signedJarFile = getSignedJarFile();
380+
assertThat(signedJarFile).exists();
381+
try (java.util.jar.JarFile expected = new java.util.jar.JarFile(signedJarFile)) {
382+
try (JarFile actual = new JarFile(signedJarFile)) {
383+
StopWatch stopWatch = new StopWatch();
384+
Enumeration<JarEntry> actualEntries = actual.entries();
385+
while (actualEntries.hasMoreElements()) {
386+
JarEntry actualEntry = actualEntries.nextElement();
387+
java.util.jar.JarEntry expectedEntry = expected.getJarEntry(actualEntry.getName());
388+
assertThat(actualEntry.getCertificates()).as(actualEntry.getName())
389+
.isEqualTo(expectedEntry.getCertificates());
390+
assertThat(actualEntry.getCodeSigners()).as(actualEntry.getName())
391+
.isEqualTo(expectedEntry.getCodeSigners());
392+
}
393+
assertThat(stopWatch.getTotalTimeSeconds()).isLessThan(3.0);
386394
}
387395
}
388-
assertThat(signedJarFile).isNotNull();
389-
java.util.jar.JarFile jarFile = new JarFile(new File(signedJarFile));
390-
jarFile.getManifest();
391-
Enumeration<JarEntry> jarEntries = jarFile.entries();
392-
393-
// Make sure this whole certificates routine runs in an acceptable time (few
394-
// seconds at most)
395-
// Some signed jars took from 30s to 5 min depending on the implementation.
396-
Instant start = Instant.now();
397-
while (jarEntries.hasMoreElements()) {
398-
JarEntry jarEntry = jarEntries.nextElement();
399-
InputStream inputStream = jarFile.getInputStream(jarEntry);
400-
inputStream.skip(Long.MAX_VALUE);
401-
inputStream.close();
402-
403-
Certificate[] certs = jarEntry.getCertificates();
404-
if (!jarEntry.getName().startsWith("META-INF") && !jarEntry.isDirectory()
405-
&& !jarEntry.getName().endsWith("TigerDigest.class")) {
406-
assertThat(certs).isNotNull();
396+
}
397+
398+
private File getSignedJarFile() {
399+
String[] entries = System.getProperty("java.class.path").split(System.getProperty("path.separator"));
400+
for (String entry : entries) {
401+
if (entry.contains("bcprov")) {
402+
return new File(entry);
407403
}
408404
}
409-
jarFile.close();
410-
411-
// 3 seconds is still quite long, but low enough to catch most problems
412-
assertThat(ChronoUnit.SECONDS.between(start, Instant.now())).isLessThanOrEqualTo(3L);
405+
return null;
413406
}
414407

415408
@Test

0 commit comments

Comments
 (0)