diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 114a6329954..606cb3ac8c2 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -11,7 +11,6 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NonNull; -import org.apache.commons.lang.SystemUtils; import org.jetbrains.annotations.Nullable; import org.junit.runner.Description; import org.rnorth.ducttape.ratelimits.RateLimiter; @@ -35,7 +34,6 @@ import java.io.File; import java.io.IOException; -import java.net.URL; import java.nio.charset.Charset; import java.nio.file.Path; import java.time.Duration; @@ -482,16 +480,8 @@ public void addEnv(String key, String value) { @Override public void addFileSystemBind(String hostPath, String containerPath, BindMode mode) { - if (hostPath.contains(".jar!")) { - // the host file is inside a JAR resource - copy to a temporary location that Docker can read - hostPath = PathUtils.extractClassPathResourceToTempLocation(hostPath); - } - - if (SystemUtils.IS_OS_WINDOWS) { - hostPath = PathUtils.createMinGWPath(hostPath); - } - - binds.add(new Bind(hostPath, new Volume(containerPath), mode.accessMode)); + final MountableFile mountableFile = MountableFile.forHostPath(hostPath); + binds.add(new Bind(mountableFile.getResolvedPath(), new Volume(containerPath), mode.accessMode)); } /** @@ -623,14 +613,9 @@ public SELF withNetworkMode(String networkMode) { */ @Override public SELF withClasspathResourceMapping(String resourcePath, String containerPath, BindMode mode) { - URL resource = GenericContainer.class.getClassLoader().getResource(resourcePath); - - if (resource == null) { - throw new IllegalArgumentException("Could not find classpath resource at provided path: " + resourcePath); - } - String resourceFilePath = resource.getFile(); + final MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); - this.addFileSystemBind(resourceFilePath, containerPath, mode); + this.addFileSystemBind(mountableFile.getResolvedPath(), containerPath, mode); return self(); } diff --git a/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java b/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java index 4b18cb8eacc..5617365f2cd 100644 --- a/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java @@ -1,7 +1,7 @@ package org.testcontainers.images.builder.traits; -import java.io.File; -import java.net.URL; +import org.testcontainers.utility.MountableFile; + import java.nio.file.Paths; /** @@ -11,14 +11,8 @@ public interface ClasspathTrait & BuildContextBuilderTrait & FilesTrait> { default SELF withFileFromClasspath(String path, String resourcePath) { - URL resource = ClasspathTrait.class.getClassLoader().getResource(resourcePath); - - if (resource == null) { - throw new IllegalArgumentException("Could not find classpath resource at provided path: " + resourcePath); - } - - String resourceFilePath = new File(resource.getFile()).getAbsolutePath(); + final MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); - return ((SELF) this).withFileFromPath(path, Paths.get(resourceFilePath)); + return ((SELF) this).withFileFromPath(path, Paths.get(mountableFile.getResolvedPath())); } } diff --git a/core/src/main/java/org/testcontainers/utility/MountableFile.java b/core/src/main/java/org/testcontainers/utility/MountableFile.java new file mode 100644 index 00000000000..31ee6008751 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/MountableFile.java @@ -0,0 +1,196 @@ +package org.testcontainers.utility; + +import com.google.common.base.Charsets; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.SystemUtils; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import static lombok.AccessLevel.PRIVATE; +import static org.testcontainers.utility.PathUtils.recursiveDeleteDir; + +/** + * An abstraction over files and classpath resources aimed at encapsulating all the complexity of generating + * a path that the Docker daemon is about to create a volume mount for. + */ +@RequiredArgsConstructor(access = PRIVATE) +@Slf4j +public class MountableFile { + + private final String path; + + @Getter(lazy = true) + private final String resolvedPath = resolvePath(); + + /** + * Obtains a {@link MountableFile} corresponding to a resource on the classpath (including resources in JAR files) + * + * @param resourceName the classpath path to the resource + * @return a {@link MountableFile} that may be used to obtain a mountable path + */ + public static MountableFile forClasspathResource(@NotNull final String resourceName) { + return new MountableFile(getClasspathResource(resourceName, new HashSet<>()).toString()); + } + + /** + * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem. + * + * @param path the path to the resource + * @return a {@link MountableFile} that may be used to obtain a mountable path + */ + public static MountableFile forHostPath(@NotNull final String path) { + return new MountableFile(new File(path).toURI().toString()); + } + + /** + * Obtain a path that the Docker daemon should be able to use to volume mount a file/resource + * into a container. If this is a classpath resource residing in a JAR, it will be extracted to + * a temporary location so that the Docker daemon is able to access it. + * + * @return a volume-mountable path. + */ + private String resolvePath() { + String result; + if (path.contains(".jar!")) { + result = extractClassPathResourceToTempLocation(this.path); + } else { + result = unencodeResourceURIToFilePath(path); + } + + if (SystemUtils.IS_OS_WINDOWS) { + result = PathUtils.createMinGWPath(result); + } + + return result; + } + + @NotNull + private static URL getClasspathResource(@NotNull final String resourcePath, @NotNull final Set classLoaders) { + + final Set classLoadersToSearch = new HashSet<>(classLoaders); + // try context and system classloaders as well + classLoadersToSearch.add(Thread.currentThread().getContextClassLoader()); + classLoadersToSearch.add(ClassLoader.getSystemClassLoader()); + classLoadersToSearch.add(MountableFile.class.getClassLoader()); + + for (final ClassLoader classLoader : classLoadersToSearch) { + URL resource = classLoader.getResource(resourcePath); + if (resource != null) { + return resource; + } + + // Be lenient if an absolute path was given + if (resourcePath.startsWith("/")) { + resource = classLoader.getResource(resourcePath.replaceFirst("/", "")); + if (resource != null) { + return resource; + } + } + } + + throw new IllegalArgumentException("Resource with path " + resourcePath + " could not be found on any of these classloaders: " + classLoaders); + } + + private static String unencodeResourceURIToFilePath(@NotNull final String resource) { + try { + // Convert any url-encoded characters (e.g. spaces) back into unencoded form + return new URI(resource).getPath(); + } catch (URISyntaxException e) { + throw new IllegalStateException(e); + } + } + + /** + * Extract a file or directory tree from a JAR file to a temporary location. + * This allows Docker to mount classpath resources as files. + * + * @param hostPath the path on the host, expected to be of the format 'file:/path/to/some.jar!/classpath/path/to/resource' + * @return the path of the temporary file/directory + */ + private String extractClassPathResourceToTempLocation(final String hostPath) { + File tmpLocation = new File(".testcontainers-tmp-" + Base58.randomString(5)); + //noinspection ResultOfMethodCallIgnored + tmpLocation.delete(); + + String jarPath = hostPath.replaceFirst("jar:", "").replaceFirst("file:", "").replaceAll("!.*", ""); + String urldecodedJarPath; + try { + urldecodedJarPath = URLDecoder.decode(jarPath, Charsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException("Could not URLDecode path with UTF-8 encoding: " + hostPath, e); + } + String internalPath = hostPath.replaceAll("[^!]*!/", ""); + + try (JarFile jarFile = new JarFile(urldecodedJarPath)) { + Enumeration entries = jarFile.entries(); + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + final String name = entry.getName(); + if (name.startsWith(internalPath)) { + log.debug("Copying classpath resource(s) from {} to {} to permit Docker to bind", + hostPath, + tmpLocation); + copyFromJarToLocation(jarFile, entry, internalPath, tmpLocation); + } + } + + } catch (IOException e) { + throw new IllegalStateException("Failed to process JAR file when extracting classpath resource: " + hostPath, e); + } + + // Mark temporary files/dirs for deletion at JVM shutdown + deleteOnExit(tmpLocation.toPath()); + + return tmpLocation.getAbsolutePath(); + } + + @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()); + + if (!entry.isDirectory()) { + // Create parent directories + newFile.mkdirs(); + newFile.delete(); + newFile.deleteOnExit(); + + try (InputStream is = jarFile.getInputStream(entry)) { + Files.copy(is, newFile.toPath()); + } catch (IOException e) { + log.error("Failed to extract classpath resource " + entry.getName() + " from JAR file " + jarFile.getName(), e); + throw e; + } + } + } + + private void deleteOnExit(final Path path) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> recursiveDeleteDir(path))); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/PathUtils.java b/core/src/main/java/org/testcontainers/utility/PathUtils.java index 70798638ec3..2dcf9db8a8d 100644 --- a/core/src/main/java/org/testcontainers/utility/PathUtils.java +++ b/core/src/main/java/org/testcontainers/utility/PathUtils.java @@ -1,28 +1,24 @@ package org.testcontainers.utility; import lombok.NonNull; -import org.slf4j.Logger; +import lombok.experimental.UtilityClass; -import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.*; +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.Enumeration; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; - -import static org.slf4j.LoggerFactory.getLogger; /** * Filesystem operation utility methods. */ +@UtilityClass public class PathUtils { - private static final Logger log = getLogger(PathUtils.class); - /** * Recursively delete a directory and all its subdirectories and files. + * * @param directory path to the directory to delete. */ public static void recursiveDeleteDir(final @NonNull Path directory) { @@ -55,89 +51,24 @@ public static void mkdirp(Path directory) { throw new IllegalStateException("Failed to create directory at: " + directory); } } - + /** * Create a MinGW compatible path based on usual Windows path - * @param path a usual windows path + * + * @param path a usual windows path * @return a MinGW compatible path */ public static String createMinGWPath(String path) { - String mingwPath = path.replace('\\', '/'); - int driveLetterIndex = 1; - if(mingwPath.matches("^[a-zA-Z]:\\/.*")) { - driveLetterIndex = 0; + String mingwPath = path.replace('\\', '/'); + int driveLetterIndex = 1; + if (mingwPath.matches("^[a-zA-Z]:\\/.*")) { + driveLetterIndex = 0; } - + // drive-letter must be lower case mingwPath = "//" + Character.toLowerCase(mingwPath.charAt(driveLetterIndex)) + - mingwPath.substring(driveLetterIndex+1); - mingwPath = mingwPath.replace(":",""); + mingwPath.substring(driveLetterIndex + 1); + mingwPath = mingwPath.replace(":", ""); return mingwPath; } - - /** - * Extract a file or directory tree from a JAR file to a temporary location. - * This allows Docker to mount classpath resources as files. - * @param hostPath the path on the host, expected to be of the format 'file:/path/to/some.jar!/classpath/path/to/resource' - * @return the path of the temporary file/directory - */ - public static String extractClassPathResourceToTempLocation(final String hostPath) { - File tmpLocation = new File(".testcontainers-tmp-" + Base58.randomString(5)); - //noinspection ResultOfMethodCallIgnored - tmpLocation.delete(); - - String jarPath = hostPath.replaceFirst("file:", "").replaceAll("!.*", ""); - String internalPath = hostPath.replaceAll("[^!]*!/", ""); - - try (JarFile jarFile = new JarFile(jarPath)) { - Enumeration entries = jarFile.entries(); - - while (entries.hasMoreElements()) { - JarEntry entry = entries.nextElement(); - final String name = entry.getName(); - if (name.startsWith(internalPath)) { - log.debug("Copying classpath resource(s) from {} to {} to permit Docker to bind", - hostPath, - tmpLocation); - copyFromJarToLocation(jarFile, entry, internalPath, tmpLocation); - } - } - - } catch (IOException e) { - throw new IllegalStateException("Failed to process JAR file when extracting classpath resource: " + hostPath, e); - } - - // Mark temporary files/dirs for deletion at JVM shutdown - deleteOnExit(tmpLocation.toPath()); - - return tmpLocation.getAbsolutePath(); - } - - @SuppressWarnings("ResultOfMethodCallIgnored") - private static 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); - - if (!entry.isDirectory()) { - // Create parent directories - newFile.mkdirs(); - newFile.delete(); - newFile.deleteOnExit(); - - try (InputStream is = jarFile.getInputStream(entry)) { - Files.copy(is, newFile.toPath()); - } catch (IOException e) { - log.error("Failed to extract classpath resource " + entry.getName() + " from JAR file " + jarFile.getName(), e); - throw e; - } - } - } - - public static void deleteOnExit(final Path path) { - Runtime.getRuntime().addShutdownHook(new Thread(() -> recursiveDeleteDir(path))); - } } diff --git a/core/src/test/java/org/testcontainers/utility/MountableFileTest.java b/core/src/test/java/org/testcontainers/utility/MountableFileTest.java new file mode 100644 index 00000000000..e42d92f07c1 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/MountableFileTest.java @@ -0,0 +1,77 @@ +package org.testcontainers.utility; + +import org.jetbrains.annotations.NotNull; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; +import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; + +public class MountableFileTest { + + @Test + public void forClasspathResource() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource("mappable-resource/test-resource.txt"); + + performChecks(mountableFile); + } + + @Test + public void forClasspathResourceWithAbsolutePath() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource("/mappable-resource/test-resource.txt"); + + performChecks(mountableFile); + } + + @Test + public void forClasspathResourceFromJar() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource("docker-java.properties"); + + performChecks(mountableFile); + } + + @Test + public void forHostPath() throws Exception { + final Path file = createTempFile("somepath"); + final MountableFile mountableFile = MountableFile.forHostPath(file.toString()); + + performChecks(mountableFile); + } + + @Test + public void forHostPathWithSpaces() throws Exception { + final Path file = createTempFile("some path"); + final MountableFile mountableFile = MountableFile.forHostPath(file.toString()); + + performChecks(mountableFile); + + assertTrue("The resolved path contains the original space", mountableFile.getResolvedPath().contains(" "));assertFalse("The resolved path does not contain an escaped space", mountableFile.getResolvedPath().contains("\\ ")); + } + + /* + * + */ + + @SuppressWarnings("ResultOfMethodCallIgnored") + @NotNull + private Path createTempFile(final String name) throws IOException { + final File tempParentDir = File.createTempFile("testcontainers", ""); + tempParentDir.delete(); + tempParentDir.mkdirs(); + final Path file = new File(tempParentDir, name).toPath(); + + Files.copy(MountableFileTest.class.getResourceAsStream("/mappable-resource/test-resource.txt"), file); + return file; + } + + private void performChecks(final MountableFile mountableFile) { + final String mountablePath = mountableFile.getResolvedPath(); + assertTrue("The resolved path can be found", new File(mountablePath).exists()); + assertFalse("The resolved path does not contain any URL escaping", mountablePath.contains("%20")); + } + +} \ No newline at end of file diff --git a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java index 2d4136580d0..21a9da73c95 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java +++ b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java @@ -5,8 +5,8 @@ import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; import org.rnorth.ducttape.unreliables.Unreliables; import org.testcontainers.containers.traits.LinkableContainer; +import org.testcontainers.utility.MountableFile; -import java.net.URL; import java.sql.Connection; import java.sql.Driver; import java.sql.SQLException; @@ -130,14 +130,8 @@ protected void optionallyMapResourceParameterAsVolume(@NotNull String paramName, String resourceName = parameters.getOrDefault(paramName, defaultResource); if (resourceName != null) { - URL classPathResource = ClassLoader.getSystemClassLoader().getResource(resourceName); - if (classPathResource == null) { - throw new ContainerLaunchException("Could not locate a classpath resource for " + paramName +" of " + resourceName); - } - - final String resourceFile = classPathResource.getFile(); - - addFileSystemBind(resourceFile, pathNameInContainer, BindMode.READ_ONLY); + final MountableFile mountableFile = MountableFile.forClasspathResource(resourceName); + addFileSystemBind(mountableFile.getResolvedPath(), pathNameInContainer, BindMode.READ_ONLY); } }