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.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; import org.apache.commons.lang.SystemUtils; import org.jetbrains.annotations.NotNull; import org.testcontainers.images.builder.Transferable; import java.io.*; import java.net.URL; import java.net.URLDecoder; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; 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.PACKAGE; 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 = PACKAGE) @Slf4j public class MountableFile implements Transferable { 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()); } /** * 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(final Path path) { return new MountableFile(path.toAbsolutePath().toString()); } @NotNull private static URL getClasspathResource(@NotNull final String resourcePath, @NotNull final Set<ClassLoader> classLoaders) { final Set<ClassLoader> 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: " + classLoadersToSearch); } private static String unencodeResourceURIToFilePath(@NotNull final String resource) { try { // Convert any url-encoded characters (e.g. spaces) back into unencoded form return URLDecoder.decode(resource, Charsets.UTF_8.name()) .replaceFirst("jar:", "") .replaceFirst("file:", "") .replaceAll("!.*", ""); } catch (UnsupportedEncodingException e) { throw new IllegalStateException(e); } } /** * 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; } /** * 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 urldecodedJarPath = unencodeResourceURIToFilePath(hostPath); String internalPath = hostPath.replaceAll("[^!]*!/", ""); 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)) { 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))); } /** * {@inheritDoc} */ @Override public void transferTo(final TarArchiveOutputStream outputStream, String destinationPathInTar) { recursiveTar(destinationPathInTar, this.getResolvedPath(), this.getResolvedPath(), outputStream); } /* * Recursively copies a file/directory into a TarArchiveOutputStream */ private void recursiveTar(String destination, String sourceRootDir, String sourceCurrentItem, TarArchiveOutputStream tarArchive) { try { final File sourceFile = new File(sourceCurrentItem).getCanonicalFile(); // e.g. /foo/bar/baz final File sourceRootFile = new File(sourceRootDir).getCanonicalFile(); // e.g. /foo final String relativePathToSourceFile = sourceRootFile.toPath().relativize(sourceFile.toPath()).toFile().toString(); // e.g. /bar/baz final TarArchiveEntry tarEntry = new TarArchiveEntry(sourceFile, destination + "/" + relativePathToSourceFile); // entry filename e.g. /xyz/bar/baz // TarArchiveEntry automatically sets the mode for file/directory, but we can update to ensure that the mode is set exactly (inc executable bits) tarEntry.setMode(getUnixFileMode(sourceCurrentItem)); tarArchive.putArchiveEntry(tarEntry); if (sourceFile.isFile()) { Files.copy(sourceFile.toPath(), tarArchive); } // a directory entry merely needs to exist in the TAR file - there is no data stored yet tarArchive.closeArchiveEntry(); final File[] children = sourceFile.listFiles(); if (children != null) { // recurse into child files/directories for (final File child : children) { recursiveTar(destination, sourceRootDir + File.separator, child.getCanonicalPath(), tarArchive); } } } catch (IOException e) { log.error("Error when copying TAR file entry: {}", sourceCurrentItem, e); throw new UncheckedIOException(e); // fail fast } } @Override public long getSize() { final File file = new File(this.getResolvedPath()); if (file.isFile()) { return file.length(); } else { return 0; } } @Override public String getDescription() { return this.getResolvedPath(); } @Override public int getFileMode() { return getUnixFileMode(this.getResolvedPath()); } private int getUnixFileMode(final String pathAsString) { final Path path = Paths.get(pathAsString); try { return (int) Files.getAttribute(path, "unix:mode"); } catch (IOException e) { // fallback for non-posix environments int mode = DEFAULT_FILE_MODE; if (Files.isDirectory(path)) { mode = DEFAULT_DIR_MODE; } else if (Files.isExecutable(path)) { mode |= 0111; // equiv to +x for user/group/others } return mode; } } }