Skip to content

Commit 87dc99a

Browse files
authored
Refactor ResourceExtractionUtil to use NIO (elastic#2240)
1 parent 3f81f6f commit 87dc99a

File tree

4 files changed

+102
-66
lines changed

4 files changed

+102
-66
lines changed

apm-agent-attach/src/main/java/co/elastic/apm/attach/ElasticApmAttacher.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ private static File getAgentJarFile() {
188188
if (ElasticApmAttacher.class.getResource("/elastic-apm-agent.jar") == null) {
189189
return null;
190190
}
191-
return ResourceExtractionUtil.extractResourceToTempDirectory("elastic-apm-agent.jar", "elastic-apm-agent", ".jar");
191+
return ResourceExtractionUtil.extractResourceToTempDirectory("elastic-apm-agent.jar", "elastic-apm-agent", ".jar").toFile();
192192
}
193193
}
194194

apm-agent-common/src/main/java/co/elastic/apm/agent/common/util/ResourceExtractionUtil.java

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,98 +18,127 @@
1818
*/
1919
package co.elastic.apm.agent.common.util;
2020

21-
import java.io.File;
22-
import java.io.FileInputStream;
23-
import java.io.FileOutputStream;
2421
import java.io.IOException;
2522
import java.io.InputStream;
2623
import java.math.BigInteger;
24+
import java.nio.channels.Channels;
2725
import java.nio.channels.FileChannel;
2826
import java.nio.channels.FileLock;
27+
import java.nio.file.FileAlreadyExistsException;
28+
import java.nio.file.Files;
29+
import java.nio.file.Path;
30+
import java.nio.file.Paths;
31+
import java.nio.file.attribute.FileAttribute;
32+
import java.nio.file.attribute.PosixFilePermissions;
33+
import java.nio.file.attribute.UserPrincipal;
2934
import java.security.DigestInputStream;
3035
import java.security.MessageDigest;
3136
import java.security.NoSuchAlgorithmException;
37+
import java.util.EnumSet;
38+
39+
import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
40+
import static java.nio.file.StandardOpenOption.CREATE_NEW;
41+
import static java.nio.file.StandardOpenOption.READ;
42+
import static java.nio.file.StandardOpenOption.WRITE;
43+
import static java.nio.file.attribute.PosixFilePermission.OWNER_READ;
44+
import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
3245

3346
public class ResourceExtractionUtil {
3447

3548
/**
3649
* Extracts a classpath resource to {@code ${System.getProperty("java.io.tmpdir")}/$prefix-$hash.$suffix}.
3750
* If the file has already been extracted it will not be extracted again.
3851
*
39-
* @param resource The classpath resource to extract.
40-
* @param prefix The prefix of the extracted file.
41-
* @param suffix The suffix of the extracted file.
52+
* @param resource The classpath resource to extract.
53+
* @param prefix The prefix of the extracted file.
54+
* @param suffix The suffix of the extracted file.
4255
* @return the extracted file.
4356
*/
44-
public static synchronized File extractResourceToTempDirectory(String resource, String prefix, String suffix) {
45-
return extractResourceToDirectory(resource, prefix, suffix, System.getProperty("java.io.tmpdir"));
57+
public static synchronized Path extractResourceToTempDirectory(String resource, String prefix, String suffix) {
58+
return extractResourceToDirectory(resource, prefix, suffix, Paths.get(System.getProperty("java.io.tmpdir")));
4659
}
4760

4861
/**
4962
* Extracts a classpath resource to {@code $directory/$prefix-$userHash-$hash.$suffix}.
5063
* If the file has already been extracted it will not be extracted again.
5164
*
52-
* @param resource The classpath resource to extract.
53-
* @param prefix The prefix of the extracted file.
54-
* @param suffix The suffix of the extracted file.
55-
* @param directory The directory in which the file is to be created, or null if the default temporary-file directory is to be used.
65+
* @param resource The classpath resource to extract.
66+
* @param prefix The prefix of the extracted file.
67+
* @param suffix The suffix of the extracted file.
68+
* @param directory The directory in which the file is to be created, or null if the default temporary-file directory is to be used.
5669
* @return the extracted file.
5770
*/
5871
/*
5972
* Why it's synchronized : if the same JVM try to lock file, we got an java.nio.channels.OverlappingFileLockException.
6073
* So we need to block until the file is totally written.
6174
*/
62-
public static synchronized File extractResourceToDirectory(String resource, String prefix, String suffix, String directory) {
75+
public static synchronized Path extractResourceToDirectory(String resource, String prefix, String suffix, Path directory) {
6376
try (InputStream resourceStream = ResourceExtractionUtil.class.getResourceAsStream("/" + resource)) {
6477
if (resourceStream == null) {
6578
throw new IllegalStateException(resource + " not found");
6679
}
67-
String userHash = "";
68-
if (System.getProperties().contains("user.name")) {
69-
// we have to include current user name as multiple copies of the same agent could be attached
70-
// to multiple JVMs, each running under a different user. Also, we have to make it path-friendly.
71-
userHash = md5Hash(System.getProperty("user.name"));
72-
userHash += "-";
73-
}
74-
String resourceHash = md5Hash(ResourceExtractionUtil.class.getResourceAsStream("/" + resource));
80+
UserPrincipal currentUserPrincipal = getCurrentUserPrincipal();
81+
// we have to include current user name as multiple copies of the same agent could be attached
82+
// to multiple JVMs, each running under a different user. Hashing makes the name path-friendly.
83+
String userHash = hash(currentUserPrincipal.getName());
84+
// to guard against re-using previous versions
85+
String resourceHash = hash(ResourceExtractionUtil.class.getResourceAsStream("/" + resource));
7586

76-
File tempFile = new File(directory, prefix + "-" + userHash + resourceHash + suffix);
77-
if (!tempFile.exists()) {
78-
try (FileOutputStream out = new FileOutputStream(tempFile)) {
79-
FileChannel channel = out.getChannel();
80-
// If multiple JVM start on same compute, they can write in same file
81-
// and this file will be corrupted.
82-
try (FileLock ignored = channel.lock()) {
83-
if (tempFile.length() == 0) {
84-
byte[] buffer = new byte[1024];
85-
for (int length; (length = resourceStream.read(buffer)) != -1; ) {
86-
out.write(buffer, 0, length);
87-
}
87+
Path tempFile = directory.resolve(prefix + "-" + userHash.substring(0, 32) + "-" + resourceHash.substring(0, 32) + suffix);
88+
try {
89+
FileAttribute<?>[] attr;
90+
if (tempFile.getFileSystem().supportedFileAttributeViews().contains("posix")) {
91+
attr = new FileAttribute[]{PosixFilePermissions.asFileAttribute(EnumSet.of(OWNER_WRITE, OWNER_READ))};
92+
} else {
93+
attr = new FileAttribute[0];
94+
}
95+
try (FileChannel channel = FileChannel.open(tempFile, EnumSet.of(CREATE_NEW, WRITE), attr)) {
96+
// make other JVM instances wait until fully written
97+
try (FileLock writeLock = channel.lock()) {
98+
channel.transferFrom(Channels.newChannel(resourceStream), 0, Long.MAX_VALUE);
99+
}
100+
}
101+
} catch (FileAlreadyExistsException e) {
102+
try (FileChannel channel = FileChannel.open(tempFile, READ, NOFOLLOW_LINKS)) {
103+
// wait until other JVM instances have fully written the file
104+
// multiple JVMs can read the file at the same time
105+
try (FileLock readLock = channel.lock(0, Long.MAX_VALUE, true)) {
106+
if (!hash(Files.newInputStream(tempFile)).equals(resourceHash)) {
107+
throw new IllegalStateException("Invalid checksum of " + tempFile + ". Please delete this file.");
108+
} else if (!Files.getOwner(tempFile).equals(currentUserPrincipal)) {
109+
throw new IllegalStateException("File " + tempFile + " is not owned by '" + currentUserPrincipal.getName() + "'. Please delete this file.");
88110
}
89111
}
90112
}
91-
} else if (!md5Hash(new FileInputStream(tempFile)).equals(resourceHash)) {
92-
throw new IllegalStateException("Invalid MD5 checksum of " + tempFile + ". Please delete this file.");
93113
}
94-
return tempFile;
114+
return tempFile.toAbsolutePath();
95115
} catch (NoSuchAlgorithmException | IOException e) {
96116
throw new IllegalStateException(e);
97117
}
98118
}
99119

100-
private static String md5Hash(InputStream resourceAsStream) throws IOException, NoSuchAlgorithmException {
120+
private static UserPrincipal getCurrentUserPrincipal() throws IOException {
121+
Path whoami = Files.createTempFile("whoami", ".tmp");
122+
try {
123+
return Files.getOwner(whoami);
124+
} finally {
125+
Files.delete(whoami);
126+
}
127+
}
128+
129+
private static String hash(InputStream resourceAsStream) throws IOException, NoSuchAlgorithmException {
101130
try (InputStream is = resourceAsStream) {
102-
MessageDigest md = MessageDigest.getInstance("MD5");
131+
MessageDigest md = MessageDigest.getInstance("SHA-256");
103132
byte[] buffer = new byte[1024];
104133
DigestInputStream dis = new DigestInputStream(is, md);
105134
while (dis.read(buffer) != -1) {}
106-
return String.format("%032x", new BigInteger(1, md.digest()));
135+
return new BigInteger(1, md.digest()).toString(16);
107136
}
108137
}
109138

110-
private static String md5Hash(String s) throws NoSuchAlgorithmException {
111-
MessageDigest md = MessageDigest.getInstance("MD5");
139+
private static String hash(String s) throws NoSuchAlgorithmException {
140+
MessageDigest md = MessageDigest.getInstance("SHA-256");
112141
md.update(s.getBytes());
113-
return String.format("%032x", new BigInteger(1, md.digest()));
142+
return new BigInteger(1, md.digest()).toString(16);
114143
}
115144
}

apm-agent-common/src/test/java/co/elastic/apm/agent/common/util/ResourceExtractionUtilTest.java

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
package co.elastic.apm.agent.common.util;
2020

2121
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.io.TempDir;
2223

23-
import java.io.File;
2424
import java.net.URISyntaxException;
25+
import java.nio.file.Files;
2526
import java.nio.file.Path;
2627
import java.nio.file.Paths;
28+
import java.nio.file.attribute.FileTime;
2729
import java.util.ArrayList;
2830
import java.util.List;
2931
import java.util.UUID;
@@ -40,54 +42,57 @@
4042
class ResourceExtractionUtilTest {
4143

4244
@Test
43-
void exportResourceToDirectory() throws URISyntaxException {
44-
File tmp = ResourceExtractionUtil.extractResourceToTempDirectory("test.txt", UUID.randomUUID().toString(), ".tmp");
45-
tmp.deleteOnExit();
45+
void exportResourceToDirectory(@TempDir Path tmpDir) throws URISyntaxException {
46+
Path tmp = ResourceExtractionUtil.extractResourceToDirectory("test.txt", "test", ".tmp", tmpDir);
4647

4748
Path referenceFile = Paths.get(ResourceExtractionUtil.class.getResource("/test.txt").toURI());
4849

49-
assertThat(tmp)
50-
.hasSameTextualContentAs(referenceFile.toFile());
50+
assertThat(tmp).hasSameTextualContentAs(referenceFile);
5151
}
5252

5353
@Test
54-
void exportResourceToDirectoryIdempotence() throws InterruptedException {
55-
String destination = UUID.randomUUID().toString();
56-
File tmp = ResourceExtractionUtil.extractResourceToTempDirectory("test.txt", destination, ".tmp");
57-
tmp.deleteOnExit();
58-
long actual = tmp.lastModified();
54+
void exportResourceToDirectoryIdempotence(@TempDir Path tmpDir) throws Exception {
55+
Path tmp = ResourceExtractionUtil.extractResourceToDirectory("test.txt", "test", ".tmp", tmpDir);
56+
FileTime created = Files.getLastModifiedTime(tmp);
5957
Thread.sleep(1000);
60-
File after = ResourceExtractionUtil.extractResourceToTempDirectory("test.txt", destination, ".tmp");
61-
assertThat(actual).isEqualTo(after.lastModified());
58+
Path after = ResourceExtractionUtil.extractResourceToDirectory("test.txt", "test", ".tmp", tmpDir);
59+
assertThat(created).isEqualTo(Files.getLastModifiedTime(after));
6260
}
6361

6462
@Test
65-
void exportResourceToDirectory_throwExceptionIfNotFound() {
66-
assertThatThrownBy(() -> ResourceExtractionUtil.extractResourceToTempDirectory("nonexist", UUID.randomUUID().toString(), ".tmp")).hasMessage("nonexist not found");
63+
void testContentDoesNotMatch(@TempDir Path tmpDir) throws Exception {
64+
Path tmp = ResourceExtractionUtil.extractResourceToDirectory("test.txt", "test", ".tmp", tmpDir);
65+
Files.writeString(tmp, "changed");
66+
assertThatThrownBy(() -> ResourceExtractionUtil.extractResourceToDirectory("test.txt", "test", ".tmp", tmpDir))
67+
.isInstanceOf(IllegalStateException.class);
6768
}
6869

6970
@Test
70-
void exportResourceToDirectoryInMultipleThreads() throws InterruptedException, ExecutionException {
71+
void exportResourceToDirectory_throwExceptionIfNotFound(@TempDir Path tmpDir) {
72+
assertThatThrownBy(() -> ResourceExtractionUtil.extractResourceToDirectory("nonexist", "nonexist", ".tmp", tmpDir))
73+
.hasMessage("nonexist not found");
74+
}
75+
76+
@Test
77+
void exportResourceToDirectoryInMultipleThreads(@TempDir Path tmpDir) throws InterruptedException, ExecutionException {
7178
final int nbThreads = 10;
7279
final ExecutorService executorService = Executors.newFixedThreadPool(nbThreads);
7380
final CountDownLatch countDownLatch = new CountDownLatch(nbThreads);
74-
final List<Future<File>> futureList = new ArrayList<>(nbThreads);
81+
final List<Future<Path>> futureList = new ArrayList<>(nbThreads);
7582
final String tempFileNamePrefix = UUID.randomUUID().toString();
7683

7784
for (int i = 0; i < nbThreads; i++) {
7885
futureList.add(executorService.submit(() -> {
7986
countDownLatch.countDown();
8087
countDownLatch.await();
81-
File file = ResourceExtractionUtil.extractResourceToTempDirectory("test.txt", tempFileNamePrefix, ".tmp");
82-
file.deleteOnExit();
83-
return file;
88+
return ResourceExtractionUtil.extractResourceToDirectory("test.txt", tempFileNamePrefix, ".tmp", tmpDir);
8489
}));
8590
}
8691

8792
executorService.shutdown();
8893
executorService.awaitTermination(1, TimeUnit.SECONDS);
8994

90-
for (Future<File> future : futureList) {
95+
for (Future<Path> future : futureList) {
9196
assertThat(future.get()).isNotNull();
9297
assertThat(future.get()).exists();
9398
}

apm-agent-plugins/apm-profiling-plugin/src/main/java/co/elastic/apm/agent/profiler/asyncprofiler/AsyncProfiler.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
import javax.annotation.Nullable;
4040
import java.io.File;
4141
import java.io.IOException;
42+
import java.nio.file.Path;
43+
import java.nio.file.Paths;
4244

4345
/**
4446
* Java API for in-process profiling. Serves as a wrapper around
@@ -101,8 +103,8 @@ static void reset() {
101103

102104
private static void loadNativeLibrary(String libraryDirectory) {
103105
String libraryName = getLibraryFileName();
104-
File file = ResourceExtractionUtil.extractResourceToDirectory("asyncprofiler/" + libraryName + ".so", libraryName, ".so", libraryDirectory);
105-
System.load(file.getAbsolutePath());
106+
Path file = ResourceExtractionUtil.extractResourceToDirectory("asyncprofiler/" + libraryName + ".so", libraryName, ".so", Paths.get(libraryDirectory));
107+
System.load(file.toString());
106108
}
107109

108110
static String getLibraryFileName() {

0 commit comments

Comments
 (0)