Skip to content
39 changes: 26 additions & 13 deletions core/src/main/java/org/testcontainers/utility/MountableFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -237,20 +237,23 @@ private String extractClassPathResourceToTempLocation(final String hostPath) {

String urldecodedJarPath = unencodeResourceURIToFilePath(hostPath);
String internalPath = hostPath.replaceAll("[^!]*!/", "");
// Match the resource itself or entries beneath it, but not siblings that merely share the
// name as a prefix (e.g. "foo/barbaz/..." for "foo/bar", or "init.sql.bak" for "init.sql")
String entryPrefix = internalPath.endsWith("/") ? internalPath : internalPath + "/";

try (JarFile jarFile = new JarFile(urldecodedJarPath)) {
Enumeration<JarEntry> entries = jarFile.entries();

while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
final String name = entry.getName();
if (name.startsWith(internalPath)) {
if (name.equals(internalPath) || name.startsWith(entryPrefix)) {
log.debug(
"Copying classpath resource(s) from {} to {} to permit Docker to bind",
hostPath,
tmpLocation
);
copyFromJarToLocation(jarFile, entry, internalPath, tmpLocation);
copyFromJarToLocation(jarFile, entry, tmpLocation);
}
}
} catch (IOException e) {
Expand All @@ -264,7 +267,14 @@ private String extractClassPathResourceToTempLocation(final String hostPath) {
deleteOnExit(tmpLocation.toPath());

try {
return tmpLocation.getCanonicalPath();
// Preserve the resource's own path within the JAR underneath the extraction directory
// (see #9423). A single extracted file therefore still ends up inside a directory
// created specifically for this extraction, rather than being written directly onto
// the temp directory's own path - which would leave the extracted file's parent as the
// shared system temp directory, breaking any caller that treats the resolved path's
// parent as a self-contained context (e.g. building an image from a Dockerfile loaded
// via a classpath resource).
return new File(tmpLocation, internalPath).getCanonicalPath();
} catch (IOException e) {
throw new IllegalStateException(e);
}
Expand All @@ -282,16 +292,19 @@ private File createTempDirectory() {
}

@SuppressWarnings("ResultOfMethodCallIgnored")
private void copyFromJarToLocation(
final JarFile jarFile,
final JarEntry entry,
final String fromRoot,
final File toRoot
) throws IOException {
String destinationName = entry.getName().replaceFirst(fromRoot, "");
File newFile = new File(toRoot, destinationName);

log.debug("Copying resource {} from JAR file {}", fromRoot, jarFile.getName());
private void copyFromJarToLocation(final JarFile jarFile, final JarEntry entry, final File toRoot)
throws IOException {
File newFile = new File(toRoot, entry.getName());
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Guard against malformed entries (e.g. containing "../") escaping the extraction directory
Path rootPath = toRoot.getCanonicalFile().toPath();
if (!newFile.getCanonicalFile().toPath().startsWith(rootPath)) {
throw new IOException(
"JAR entry " + entry.getName() + " would be extracted outside of the target directory " + toRoot
);
}

log.debug("Copying resource {} from JAR file {}", entry.getName(), jarFile.getName());

if (!entry.isDirectory()) {
// Create parent directories
Expand Down
164 changes: 164 additions & 0 deletions core/src/test/java/org/testcontainers/utility/MountableFileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class MountableFileTest {

Expand Down Expand Up @@ -53,6 +61,125 @@ void forClasspathResourceFromJarWithAbsolutePath() throws Exception {
performChecks(mountableFile);
}

@Test
void forClasspathResourceFileInJarIsExtractedIntoItsOwnDirectory() throws Exception {
// see #9423: a single-file classpath resource extracted from a JAR must end up inside a
// directory created specifically for this extraction (preserving the resource's own path
// within the JAR), rather than being written directly onto the temp *directory*'s own path.
// Otherwise the extracted file's parent is the shared system temp directory, which breaks
// any caller that treats the resolved path's parent as a self-contained context (e.g.
// building an image from a Dockerfile loaded via MountableFile.forClasspathResource(...)).
final Map<String, String> entries = new LinkedHashMap<>();
entries.put("nested/inside/jar/Dockerfile", "FROM postgres\n");
final Path jarFile = createJarWithEntries(entries);

withJarOnClasspath(
jarFile,
() -> {
final MountableFile mountableFile = MountableFile.forClasspathResource("nested/inside/jar/Dockerfile");
final File extractedFile = new File(mountableFile.getFilesystemPath());

assertThat(extractedFile).as("the resource was extracted to a real file").isFile();
assertThat(Files.readString(extractedFile.toPath())).isEqualTo("FROM postgres\n");

final File parentDir = extractedFile.getParentFile();
assertThat(parentDir)
.as("the extracted file's parent is not the shared system temp directory")
.isNotEqualTo(new File(System.getProperty("java.io.tmpdir")));
assertThat(parentDir.list())
.as("only the extracted resource lives in its own extraction directory")
.containsExactly("Dockerfile");
}
);
}

@Test
void forClasspathResourceDirectoryInJarPreservesRelativeStructure() throws Exception {
// Regression guard: extracting a directory resource from a JAR must still lay out its
// files relative to the resolved path exactly as before this fix.
final Map<String, String> entries = new LinkedHashMap<>();
entries.put("assets/", "");
entries.put("assets/dir/", "");
entries.put("assets/dir/sub/", "");
entries.put("assets/dir/a.txt", "a-content");
entries.put("assets/dir/sub/b.txt", "b-content");
final Path jarFile = createJarWithEntries(entries);

withJarOnClasspath(
jarFile,
() -> {
final MountableFile mountableFile = MountableFile.forClasspathResource("assets/dir");
final String resolvedPath = mountableFile.getResolvedPath();

assertThat(Files.readString(new File(resolvedPath, "a.txt").toPath())).isEqualTo("a-content");
assertThat(Files.readString(new File(resolvedPath, "sub/b.txt").toPath())).isEqualTo("b-content");
}
);
}

@Test
void forClasspathResourceDirectoryInJarDoesNotExtractSiblingsSharingItsNamePrefix() throws Exception {
final Map<String, String> entries = new LinkedHashMap<>();
entries.put("foo/", "");
entries.put("foo/bar/", "");
entries.put("foo/bar/a.txt", "a-content");
entries.put("foo/barbaz/", "");
entries.put("foo/barbaz/b.txt", "b-content");
final Path jarFile = createJarWithEntries(entries);

withJarOnClasspath(
jarFile,
() -> {
final MountableFile mountableFile = MountableFile.forClasspathResource("foo/bar");
final File resolved = new File(mountableFile.getResolvedPath());

assertThat(Files.readString(new File(resolved, "a.txt").toPath())).isEqualTo("a-content");
assertThat(new File(resolved.getParentFile(), "barbaz")).doesNotExist();
}
);
}

@Test
void forClasspathResourceFileInJarDoesNotExtractSiblingsSharingItsNamePrefix() throws Exception {
final Map<String, String> entries = new LinkedHashMap<>();
entries.put("db/", "");
entries.put("db/init.sql", "init-content");
entries.put("db/init.sql.bak", "bak-content");
final Path jarFile = createJarWithEntries(entries);

withJarOnClasspath(
jarFile,
() -> {
final MountableFile mountableFile = MountableFile.forClasspathResource("db/init.sql");
final File resolved = new File(mountableFile.getResolvedPath());

assertThat(Files.readString(resolved.toPath())).isEqualTo("init-content");
assertThat(new File(resolved.getParentFile(), "init.sql.bak")).doesNotExist();
}
);
}

@Test
void forClasspathResourceInJarRejectsEntriesEscapingTheExtractionDirectory() throws Exception {
final Map<String, String> entries = new LinkedHashMap<>();
entries.put("assets/", "");
entries.put("assets/dir/", "");
entries.put("assets/dir/../../../escaped.txt", "escaped-content");
final Path jarFile = createJarWithEntries(entries);

withJarOnClasspath(
jarFile,
() -> {
assertThatThrownBy(() -> MountableFile.forClasspathResource("assets/dir").getResolvedPath())
.isInstanceOf(IllegalStateException.class)
.hasRootCauseInstanceOf(IOException.class)
.hasStackTraceContaining(
"JAR entry assets/dir/../../../escaped.txt would be extracted outside of the target directory"
);
}
);
}

@Test
void forHostPath() throws Exception {
final Path file = createTempFile("somepath");
Expand Down Expand Up @@ -131,6 +258,43 @@ void noTrailingSlashesInTarEntryNames() throws Exception {
}
}

@NotNull
private Path createJarWithEntries(final Map<String, String> entries) throws IOException {
final Path jarFile = Files.createTempFile("mountable-file-test", ".jar");
jarFile.toFile().deleteOnExit();

try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jarFile))) {
for (final Map.Entry<String, String> entry : entries.entrySet()) {
jos.putNextEntry(new JarEntry(entry.getKey()));
jos.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
jos.closeEntry();
}
}

return jarFile;
}

private void withJarOnClasspath(final Path jarFile, final ThrowingRunnable runnable) throws Exception {
final ClassLoader previousContextClassLoader = Thread.currentThread().getContextClassLoader();
try (
URLClassLoader jarClassLoader = new URLClassLoader(
new URL[] { jarFile.toUri().toURL() },
previousContextClassLoader
)
) {
Thread.currentThread().setContextClassLoader(jarClassLoader);
try {
runnable.run();
} finally {
Thread.currentThread().setContextClassLoader(previousContextClassLoader);
}
}
}

private interface ThrowingRunnable {
void run() throws Exception;
}

private TarArchiveInputStream intoTarArchive(Consumer<TarArchiveOutputStream> consumer) throws IOException {
@Cleanup
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
Expand Down
Loading