diff --git a/independent-projects/bootstrap/app-model/pom.xml b/independent-projects/bootstrap/app-model/pom.xml index c51ef8263dda42..dc995aec6479a6 100644 --- a/independent-projects/bootstrap/app-model/pom.xml +++ b/independent-projects/bootstrap/app-model/pom.xml @@ -50,6 +50,11 @@ junit-jupiter test + + org.assertj + assertj-core + test + io.quarkus diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/ArchivePathTree.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/ArchivePathTree.java index a527fc356e5851..b9f3c4318af1df 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/ArchivePathTree.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/ArchivePathTree.java @@ -49,8 +49,24 @@ public void walk(PathVisitor visitor) { } } + private String ensureRelative(String path) { + if (path == null || path.isEmpty()) { + return path; + } + if (DirectoryPathTree.isWindowsAbsolutePath(path)) { + // We are allowing absolute paths on Linux but interpreting the root as the root of the tree. + // However, Windows absolute paths, including the disk part, are not expected. + throw new IllegalArgumentException(path + " does not appear to be a path relative to the root of the path tree"); + } + if (path.charAt(0) == '/') { + return path.substring(1); + } + return path; + } + @Override protected T apply(String relativePath, Function func, boolean manifestEnabled) { + relativePath = ensureRelative(relativePath); if (!PathFilter.isVisible(pathFilter, relativePath)) { return func.apply(null); } @@ -73,6 +89,7 @@ protected T apply(String relativePath, Function func, boolean @Override public void accept(String relativePath, Consumer consumer) { + relativePath = ensureRelative(relativePath); if (!PathFilter.isVisible(pathFilter, relativePath)) { consumer.accept(null); return; @@ -97,6 +114,7 @@ public void accept(String relativePath, Consumer consumer) { @Override public boolean contains(String relativePath) { + relativePath = ensureRelative(relativePath); if (!PathFilter.isVisible(pathFilter, relativePath)) { return false; } diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/DirectoryPathTree.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/DirectoryPathTree.java index c2690610056e6f..9516372b384674 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/DirectoryPathTree.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/DirectoryPathTree.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.io.Serializable; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -10,11 +11,25 @@ import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; +import java.util.regex.Pattern; public class DirectoryPathTree extends PathTreeWithManifest implements OpenPathTree, Serializable { private static final long serialVersionUID = 2255956884896445059L; + private static final boolean USE_WINDOWS_ABSOLUTE_PATH_PATTERN = !FileSystems.getDefault().getSeparator().equals("/"); + + private static volatile Pattern windowsAbsolutePathPattern; + + private static Pattern windowsAbsolutePathPattern() { + return windowsAbsolutePathPattern == null ? windowsAbsolutePathPattern = Pattern.compile("[a-zA-Z]:\\\\.*") + : windowsAbsolutePathPattern; + } + + static boolean isWindowsAbsolutePath(String path) { + return USE_WINDOWS_ABSOLUTE_PATH_PATTERN ? windowsAbsolutePathPattern().matcher(path).matches() : false; + } + private Path dir; private PathFilter pathFilter; @@ -55,8 +70,40 @@ public void walk(PathVisitor visitor) { PathTreeVisit.walk(dir, dir, pathFilter, getMultiReleaseMapping(), visitor); } + private String ensureRelative(String path) { + if (path == null || path.isEmpty()) { + return path; + } + if (isWindowsAbsolutePath(path)) { + // We are allowing absolute paths on Linux but interpreting the root as the root of the tree. + // However, Windows absolute paths, including the disk part, are not expected. + throw new IllegalArgumentException( + path + " does not appear to be a path relative to the root of the path tree " + dir); + } + // this is to disallow reading outside the path tree root + if (path.contains("..")) { + final Path absolutePath = dir.resolve(path).normalize().toAbsolutePath(); + if (absolutePath.startsWith(dir)) { + return dir.relativize(absolutePath).toString(); + } + return null; + } + if (path.charAt(0) == '/') { + if (path.length() == 1) { + return ""; + } + if (path.charAt(1) == '/') { + throw new IllegalArgumentException( + path + " does not appear to be a path relative to the root of the path tree " + dir); + } + return path.substring(1); + } + return path; + } + @Override protected T apply(String relativePath, Function func, boolean manifestEnabled) { + relativePath = ensureRelative(relativePath); if (!PathFilter.isVisible(pathFilter, relativePath)) { return func.apply(null); } @@ -69,6 +116,7 @@ protected T apply(String relativePath, Function func, boolean @Override public void accept(String relativePath, Consumer consumer) { + relativePath = ensureRelative(relativePath); if (!PathFilter.isVisible(pathFilter, relativePath)) { consumer.accept(null); return; @@ -83,6 +131,7 @@ public void accept(String relativePath, Consumer consumer) { @Override public boolean contains(String relativePath) { + relativePath = ensureRelative(relativePath); if (!PathFilter.isVisible(pathFilter, relativePath)) { return false; } @@ -92,6 +141,7 @@ public boolean contains(String relativePath) { @Override public Path getPath(String relativePath) { + relativePath = ensureRelative(relativePath); if (!PathFilter.isVisible(pathFilter, relativePath)) { return null; } diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/PathFilter.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/PathFilter.java index f3eeb2117d9349..0ce24323c00779 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/PathFilter.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/paths/PathFilter.java @@ -13,6 +13,9 @@ public class PathFilter implements Serializable { private static final long serialVersionUID = -5712472676677054175L; public static boolean isVisible(PathFilter filter, String path) { + if (path == null) { + return false; + } if (filter == null) { return true; } diff --git a/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/paths/DirectoryPathTreeTest.java b/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/paths/DirectoryPathTreeTest.java new file mode 100644 index 00000000000000..dbbdc080f66d9a --- /dev/null +++ b/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/paths/DirectoryPathTreeTest.java @@ -0,0 +1,166 @@ +package io.quarkus.paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +public class DirectoryPathTreeTest { + + private static final String BASE_DIR = "paths/directory-path-tree"; + + private static volatile Path baseDir; + + @BeforeAll + public static void staticInit() throws Exception { + final URL url = Thread.currentThread().getContextClassLoader().getResource(BASE_DIR); + if (url == null) { + throw new IllegalStateException("Failed to locate " + BASE_DIR + " on the classpath"); + } + baseDir = Path.of(url.toURI()).toAbsolutePath(); + if (!Files.exists(baseDir)) { + throw new IllegalStateException("Failed to locate " + baseDir); + } + } + + @Test + public void acceptExistingPath() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("README.md", visit -> { + assertThat(visit).isNotNull(); + assertThat(visit.getRelativePath("/")).isEqualTo("README.md"); + assertThat(visit.getPath()).exists(); + assertThat(visit.getRoot()).isEqualTo(root); + try { + assertThat(Files.readString(visit.getPath())).isEqualTo("test readme"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + @Test + public void acceptNonExistentPath() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("non-existent", visit -> { + assertThat(visit).isNull(); + }); + } + + @Test + public void acceptLegalAbsolutePath() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("/README.md", visit -> { + assertThat(visit).isNotNull(); + assertThat(visit.getRelativePath("/")).isEqualTo("README.md"); + assertThat(visit.getPath()).exists(); + assertThat(visit.getRoot()).isEqualTo(root); + try { + assertThat(Files.readString(visit.getPath())).isEqualTo("test readme"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + public void acceptIllegalAbsolutePathOnLinux() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + final Path absolute = root.getParent().resolve("external.txt"); + assertThat(absolute).exists(); + tree.accept(absolute.toString(), visit -> { + assertThat(visit).isNull(); + }); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + public void acceptIllegalAbsolutePathOnWindows() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + final Path absolute = root.getParent().resolve("external.txt"); + assertThat(absolute).exists(); + try { + tree.accept(absolute.toString(), visit -> { + fail("Windows absolute paths are not allowed"); + }); + } catch (IllegalArgumentException e) { + // expected + } + } + + @Test + public void acceptExistingRelativeNonNormalizedPath() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("../root/./other/../README.md", visit -> { + assertThat(visit).isNotNull(); + assertThat(visit.getRelativePath("/")).isEqualTo("README.md"); + assertThat(visit.getPath()).exists(); + assertThat(visit.getRoot()).isEqualTo(root); + try { + assertThat(Files.readString(visit.getPath())).isEqualTo("test readme"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + @Test + public void acceptNonExistentRelativeNonNormalizedPath() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("../root/./README.md/../non-existent.txt", visit -> { + assertThat(visit).isNull(); + }); + } + + @Test + public void walk() throws Exception { + final Path root = resolveTreeRoot("root"); + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + + final Set visited = new HashSet<>(); + final PathVisitor visitor = new PathVisitor() { + @Override + public void visitPath(PathVisit visit) { + visited.add(visit.getRelativePath("/")); + } + }; + tree.walk(visitor); + + assertThat(visited).isEqualTo(Set.of( + "", + "README.md", + "src", + "src/main", + "src/main/java", + "src/main/java/Main.java")); + } + + /** + * Returns a path relative to src/test/resources/paths/directory-path-tree/ + * + * @param relative relative path + * @return Path instance + */ + private Path resolveTreeRoot(String relative) { + return baseDir.resolve(relative); + } +} diff --git a/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/paths/JarPathTreeTest.java b/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/paths/JarPathTreeTest.java new file mode 100644 index 00000000000000..97a540ad4fe3f1 --- /dev/null +++ b/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/paths/JarPathTreeTest.java @@ -0,0 +1,128 @@ +package io.quarkus.paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import io.quarkus.fs.util.ZipUtils; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +public class JarPathTreeTest { + + private static final String BASE_DIR = "paths/directory-path-tree"; + + private static Path root; + + @BeforeAll + public static void staticInit() throws Exception { + final URL url = Thread.currentThread().getContextClassLoader().getResource(BASE_DIR + "/root"); + if (url == null) { + throw new IllegalStateException("Failed to locate " + BASE_DIR + " on the classpath"); + } + final Path rootDir = Path.of(url.toURI()).toAbsolutePath(); + if (!Files.exists(rootDir)) { + throw new IllegalStateException("Failed to locate " + rootDir); + } + + root = rootDir.getParent().resolve("root.jar"); + ZipUtils.zip(rootDir, root); + } + + @Test + public void acceptExistingPath() throws Exception { + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("README.md", visit -> { + assertThat(visit).isNotNull(); + assertThat(visit.getRelativePath("/")).isEqualTo("README.md"); + assertThat(visit.getPath()).exists(); + assertThat(visit.getRoot()).isEqualTo(root); + try { + assertThat(Files.readString(visit.getPath())).isEqualTo("test readme"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + @Test + public void acceptNonExistentPath() throws Exception { + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("non-existent", visit -> { + assertThat(visit).isNull(); + }); + } + + @Test + public void acceptLegalAbsolutePath() throws Exception { + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + tree.accept("/README.md", visit -> { + assertThat(visit).isNotNull(); + assertThat(visit.getRelativePath("/")).isEqualTo("README.md"); + assertThat(visit.getPath()).exists(); + assertThat(visit.getRoot()).isEqualTo(root); + try { + assertThat(Files.readString(visit.getPath())).isEqualTo("test readme"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + public void acceptIllegalAbsolutePathOnLinux() throws Exception { + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + final Path absolute = root.getParent().resolve("external.txt"); + assertThat(absolute).exists(); + tree.accept(absolute.toString(), visit -> { + assertThat(visit).isNull(); + }); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + public void acceptIllegalAbsolutePathOnWindows() throws Exception { + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + final Path absolute = root.getParent().resolve("external.txt"); + assertThat(absolute).exists(); + try { + tree.accept(absolute.toString(), visit -> { + fail("Windows absolute paths are not allowed"); + }); + } catch (IllegalArgumentException e) { + // expected + } + } + + @Test + public void walk() throws Exception { + final PathTree tree = PathTree.ofDirectoryOrArchive(root); + + final Set visited = new HashSet<>(); + final PathVisitor visitor = new PathVisitor() { + @Override + public void visitPath(PathVisit visit) { + visited.add(visit.getRelativePath("/")); + } + }; + tree.walk(visitor); + + assertThat(visited).isEqualTo(Set.of( + "", + "README.md", + "src", + "src/main", + "src/main/java", + "src/main/java/Main.java")); + } +} diff --git a/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/external.txt b/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/external.txt new file mode 100644 index 00000000000000..62495ef8d7050e --- /dev/null +++ b/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/external.txt @@ -0,0 +1 @@ +external \ No newline at end of file diff --git a/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/root/README.md b/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/root/README.md new file mode 100644 index 00000000000000..ad6d083a4fa723 --- /dev/null +++ b/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/root/README.md @@ -0,0 +1 @@ +test readme \ No newline at end of file diff --git a/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/root/src/main/java/Main.java b/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/root/src/main/java/Main.java new file mode 100644 index 00000000000000..9ecbd8e94d1c3f --- /dev/null +++ b/independent-projects/bootstrap/app-model/src/test/resources/paths/directory-path-tree/root/src/main/java/Main.java @@ -0,0 +1,2 @@ +public class Main { +} \ No newline at end of file