diff --git a/src/java.base/share/classes/jdk/internal/jrtfs/ExplodedImage.java b/src/java.base/share/classes/jdk/internal/jrtfs/ExplodedImage.java index 4fe6612a8ed..a05360554e5 100644 --- a/src/java.base/share/classes/jdk/internal/jrtfs/ExplodedImage.java +++ b/src/java.base/share/classes/jdk/internal/jrtfs/ExplodedImage.java @@ -24,22 +24,24 @@ */ package jdk.internal.jrtfs; +import jdk.internal.jimage.ImageReader.Node; + import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.DirectoryStream; import java.nio.file.FileSystemException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.regex.Pattern; import java.util.stream.Stream; -import jdk.internal.jimage.ImageReader.Node; - /** * A jrt file system built on $JAVA_HOME/modules directory ('exploded modules * build') @@ -54,14 +56,18 @@ class ExplodedImage extends SystemImage { private static final String MODULES = "/modules/"; private static final String PACKAGES = "/packages/"; + private static final Path META_INF_DIR = Paths.get("META-INF"); + private static final Path PREVIEW_DIR = META_INF_DIR.resolve("preview"); private final Path modulesDir; + private final boolean isPreviewMode; private final String separator; private final Map nodes = new HashMap<>(); private final BasicFileAttributes modulesDirAttrs; - ExplodedImage(Path modulesDir) throws IOException { + ExplodedImage(Path modulesDir, boolean isPreviewMode) throws IOException { this.modulesDir = modulesDir; + this.isPreviewMode = isPreviewMode; String str = modulesDir.getFileSystem().getSeparator(); separator = str.equals("/") ? null : str; modulesDirAttrs = Files.readAttributes(modulesDir, BasicFileAttributes.class); @@ -70,15 +76,18 @@ class ExplodedImage extends SystemImage { // A Node that is backed by actual default file system Path private final class PathNode extends Node { - - // Path in underlying default file system - private Path path; + // Path in underlying default file system relative to modulesDir. + // In preview mode this need not correspond to the node's name. + private Path relPath; private PathNode link; - private List children; + private List children; private PathNode(String name, Path path, BasicFileAttributes attrs) { // path super(name, attrs); - this.path = path; + this.relPath = modulesDir.relativize(path); + if (relPath.isAbsolute() || relPath.getNameCount() == 0) { + throw new IllegalArgumentException("Invalid node path (must be relative): " + path); + } } private PathNode(String name, Node link) { // link @@ -86,7 +95,7 @@ private PathNode(String name, Node link) { // link this.link = (PathNode)link; } - private PathNode(String name, List children) { // dir + private PathNode(String name, List children) { // dir super(name, modulesDirAttrs); this.children = children; } @@ -117,36 +126,55 @@ public PathNode resolveLink(boolean recursive) { private byte[] getContent() throws IOException { if (!getFileAttributes().isRegularFile()) throw new FileSystemException(getName() + " is not file"); - return Files.readAllBytes(path); + return Files.readAllBytes(modulesDir.resolve(relPath)); } @Override public Stream getChildNames() { if (!isDirectory()) - throw new IllegalArgumentException("not a directory: " + getName()); - if (children == null) { - List list = new ArrayList<>(); - try (DirectoryStream stream = Files.newDirectoryStream(path)) { - for (Path p : stream) { - p = modulesDir.relativize(p); - String pName = MODULES + nativeSlashToFrontSlash(p.toString()); - Node node = findNode(pName); - if (node != null) { // findNode may choose to hide certain files! - list.add(node); - } + throw new IllegalStateException("not a directory: " + getName()); + List childNodes = children; + if (childNodes == null) { + childNodes = completeDirectory(); + } + return childNodes.stream().map(Node::getName); + } + + private synchronized List completeDirectory() { + if (children != null) { + return children; + } + List list = new ArrayList<>(); + if (relPath.getNameCount() > 1 && !relPath.getName(1).equals(META_INF_DIR)) { + Path absPreviewDir = modulesDir + .resolve(relPath.getName(0)) + .resolve(PREVIEW_DIR) + .resolve(relPath.subpath(1, relPath.getNameCount())); + if (Files.exists(absPreviewDir)) { + collectChildNodes(absPreviewDir, list); + } + } + collectChildNodes(modulesDir.resolve(relPath), list); + return children = list; + } + + private void collectChildNodes(Path absPath, List list) { + try (DirectoryStream stream = Files.newDirectoryStream(absPath)) { + for (Path p : stream) { + PathNode node = (PathNode) findNode(getName() + "/" + p.getFileName().toString()); + if (node != null) { // findNode may choose to hide certain files! + list.add(node); } - } catch (IOException x) { - return null; } - children = list; + } catch (IOException ex) { + throw new UncheckedIOException(ex); } - return children.stream().map(Node::getName); } @Override public long size() { try { - return isDirectory() ? 0 : Files.size(path); + return isDirectory() ? 0 : Files.size(modulesDir.resolve(relPath)); } catch (IOException ex) { throw new UncheckedIOException(ex); } @@ -180,6 +208,42 @@ public synchronized Node findNode(String name) { return createModulesNode(name, path); } + /** + * Returns the expected file path for name in the "/modules/..." namespace, + * or {@code null} if the name is not in the "/modules/..." namespace or the + * path does not reference a file. + */ + private Path underlyingModulesPath(String name) { + if (!isNonEmptyModulesName(name)) { + return null; + } + String relName = name.substring(MODULES.length()); + Path relPath = Paths.get(frontSlashToNativeSlash(relName)); + // The first path segment must exist due to check above. + Path modDir = relPath.getName(0); + Path previewDir = modDir.resolve(PREVIEW_DIR); + if (relPath.startsWith(previewDir)) { + return null; + } + Path path = modulesDir.resolve(relPath); + // Non-preview directories take precedence. + if (Files.isDirectory(path)) { + return path; + } + // Otherwise prefer preview resources over non-preview ones. + if (isPreviewMode + && relPath.getNameCount() > 1 + && !modDir.equals(META_INF_DIR)) { + Path previewPath = modulesDir + .resolve(previewDir) + .resolve(relPath.subpath(1, relPath.getNameCount())); + if (Files.exists(previewPath)) { + return previewPath; + } + } + return Files.exists(path) ? path : null; + } + /** * Lazily creates and caches a {@code Node} for the given "/modules/..." name * and corresponding path to a file or directory. @@ -191,7 +255,7 @@ public synchronized Node findNode(String name) { */ private Node createModulesNode(String name, Path path) { assert !nodes.containsKey(name) : "Node must not already exist: " + name; - assert isNonEmptyModulesPath(name) : "Invalid modules name: " + name; + assert isNonEmptyModulesName(name) : "Invalid modules name: " + name; try { // We only know if we're creating a resource of directory when we @@ -216,23 +280,17 @@ private Node createModulesNode(String name, Path path) { } } - /** - * Returns the expected file path for name in the "/modules/..." namespace, - * or {@code null} if the name is not in the "/modules/..." namespace or the - * path does not reference a file. - */ - private Path underlyingModulesPath(String name) { - if (isNonEmptyModulesPath(name)) { - Path path = modulesDir.resolve(frontSlashToNativeSlash(name.substring(MODULES.length()))); - return Files.exists(path) ? path : null; - } - return null; - } + private static final Pattern NON_EMPTY_MODULES_NAME = + Pattern.compile("/modules(/[^/]+)+"); - private static boolean isNonEmptyModulesPath(String name) { + private static boolean isNonEmptyModulesName(String name) { // Don't just check the prefix, there must be something after it too // (otherwise you end up with an empty string after trimming). - return name.startsWith(MODULES) && name.length() > MODULES.length(); + // Also make sure we can't be tricked by "/modules//absolute/path" or + // "/modules/../../escaped/path" + return NON_EMPTY_MODULES_NAME.matcher(name).matches() + && !name.contains("/../") + && !name.contains("/./"); } // convert "/" to platform path separator @@ -240,11 +298,6 @@ private String frontSlashToNativeSlash(String str) { return separator == null ? str : str.replace("/", separator); } - // convert platform path separator to "/" - private String nativeSlashToFrontSlash(String str) { - return separator == null ? str : str.replace(separator, "/"); - } - // convert "/"s to "."s private String slashesToDots(String str) { return str.replace(separator != null ? separator : "/", "."); @@ -256,23 +309,9 @@ private void initNodes() throws IOException { // is filled by walking "jdk modules" directory recursively! Map> packageToModules = new HashMap<>(); try (DirectoryStream stream = Files.newDirectoryStream(modulesDir)) { - for (Path module : stream) { - if (Files.isDirectory(module)) { - String moduleName = module.getFileName().toString(); - // make sure "/modules/" is created - Objects.requireNonNull(createModulesNode(MODULES + moduleName, module)); - try (Stream contentsStream = Files.walk(module)) { - contentsStream.filter(Files::isDirectory).forEach((p) -> { - p = module.relativize(p); - String pkgName = slashesToDots(p.toString()); - // skip META-INF and empty strings - if (!pkgName.isEmpty() && !pkgName.startsWith("META-INF")) { - packageToModules - .computeIfAbsent(pkgName, k -> new ArrayList<>()) - .add(moduleName); - } - }); - } + for (Path moduleDir : stream) { + if (Files.isDirectory(moduleDir)) { + processModuleDirectory(moduleDir, packageToModules); } } } @@ -282,11 +321,11 @@ private void initNodes() throws IOException { nodes.put(modulesRootNode.getName(), modulesRootNode); // create children under "/packages" - List packagesChildren = new ArrayList<>(packageToModules.size()); + List packagesChildren = new ArrayList<>(packageToModules.size()); for (Map.Entry> entry : packageToModules.entrySet()) { String pkgName = entry.getKey(); List moduleNameList = entry.getValue(); - List moduleLinkNodes = new ArrayList<>(moduleNameList.size()); + List moduleLinkNodes = new ArrayList<>(moduleNameList.size()); for (String moduleName : moduleNameList) { Node moduleNode = Objects.requireNonNull(nodes.get(MODULES + moduleName)); PathNode linkNode = new PathNode(PACKAGES + pkgName + "/" + moduleName, moduleNode); @@ -302,10 +341,40 @@ private void initNodes() throws IOException { nodes.put(packagesRootNode.getName(), packagesRootNode); // finally "/" dir! - List rootChildren = new ArrayList<>(); + List rootChildren = new ArrayList<>(); rootChildren.add(packagesRootNode); rootChildren.add(modulesRootNode); PathNode root = new PathNode("/", rootChildren); nodes.put(root.getName(), root); } + + private void processModuleDirectory(Path moduleDir, Map> packageToModules) + throws IOException { + String moduleName = moduleDir.getFileName().toString(); + // Make sure "/modules/" is created + Objects.requireNonNull(createModulesNode(MODULES + moduleName, moduleDir)); + // Skip the first path (it's always the given root directory). + try (Stream contentsStream = Files.walk(moduleDir).skip(1)) { + contentsStream + // Non-empty relative directory paths inside each module. + .filter(Files::isDirectory) + .map(moduleDir::relativize) + // Map paths inside preview directory to non-preview versions. + .filter(p -> isPreviewMode || !p.startsWith(PREVIEW_DIR)) + .map(p -> isPreviewSubpath(p) ? PREVIEW_DIR.relativize(p) : p) + // Ignore special META-INF directory (including preview directory itself). + .filter(p -> !p.startsWith(META_INF_DIR)) + // Extract unique package names. + .map(p -> slashesToDots(p.toString())) + .distinct() + .forEach(pkgName -> + packageToModules + .computeIfAbsent(pkgName, k -> new ArrayList<>()) + .add(moduleName)); + } + } + + private static boolean isPreviewSubpath(Path p) { + return p.startsWith(PREVIEW_DIR) && p.getNameCount() > PREVIEW_DIR.getNameCount(); + } } diff --git a/src/java.base/share/classes/jdk/internal/jrtfs/SystemImage.java b/src/java.base/share/classes/jdk/internal/jrtfs/SystemImage.java index 1c05e190c24..a8cd41bea4b 100644 --- a/src/java.base/share/classes/jdk/internal/jrtfs/SystemImage.java +++ b/src/java.base/share/classes/jdk/internal/jrtfs/SystemImage.java @@ -48,40 +48,49 @@ * but also compiled and delivered as part of the jrtfs.jar to support access * to the jimage file provided by the shipped JDK by tools running on JDK 8. */ -@SuppressWarnings({ "removal", "suppression"} ) -abstract class SystemImage { +@SuppressWarnings({"removal", "suppression"}) +public abstract class SystemImage implements AutoCloseable { - abstract Node findNode(String path) throws IOException; - abstract byte[] getResource(Node node) throws IOException; - abstract void close() throws IOException; + public abstract Node findNode(String path) throws IOException; + public abstract byte[] getResource(Node node) throws IOException; + public abstract void close() throws IOException; - static SystemImage open(PreviewMode mode) throws IOException { - if (modulesImageExists) { - // open a .jimage and build directory structure - final ImageReader image = ImageReader.open(moduleImageFile, mode); - return new SystemImage() { - @Override - Node findNode(String path) throws IOException { - return image.findNode(path); - } - @Override - byte[] getResource(Node node) throws IOException { - return image.getResource(node); - } - @Override - void close() throws IOException { - image.close(); - } - }; - } + /** + * Opens the system image for the current runtime. + * + * @param mode determines whether preview mode should be enabled. + * @return a new system image based on either the jimage file or an "exploded" + * modules directory, according to the build state. + */ + public static SystemImage open(PreviewMode mode) throws IOException { + return modulesImageExists ? fromJimage(moduleImageFile, mode) : fromDirectory(explodedModulesDir, mode); + } - if (Files.notExists(explodedModulesDir)) - throw new FileSystemNotFoundException(explodedModulesDir.toString()); - // TODO: Support preview mode in ExplodedImage and remove this check. - if (mode.isPreviewModeEnabled()) - throw new UnsupportedOperationException( - "Preview mode not yet supported for exploded images"); - return new ExplodedImage(explodedModulesDir); + /** Internal factory method for testing only, use {@link SystemImage#open(PreviewMode)}. */ + public static SystemImage fromJimage(Path path, PreviewMode mode) throws IOException { + final ImageReader image = ImageReader.open(path, mode); + return new SystemImage() { + @Override + public Node findNode(String path) throws IOException { + return image.findNode(path); + } + @Override + public byte[] getResource(Node node) throws IOException { + return image.getResource(node); + } + @Override + public void close() throws IOException { + image.close(); + } + }; + } + + /** Internal factory method for testing only, use {@link SystemImage#open(PreviewMode)}. */ + public static SystemImage fromDirectory(Path modulesDir, PreviewMode mode) throws IOException { + if (!Files.isDirectory(modulesDir)) { + throw new FileSystemNotFoundException(modulesDir.toString()); + } + return new ExplodedImage(modulesDir, mode.isPreviewModeEnabled()); } private static final String RUNTIME_HOME; diff --git a/test/jdk/jdk/internal/jimage/ImageReaderTest.java b/test/jdk/jdk/internal/jimage/ImageReaderTest.java index 37abb82f7dd..909637bf082 100644 --- a/test/jdk/jdk/internal/jimage/ImageReaderTest.java +++ b/test/jdk/jdk/internal/jimage/ImageReaderTest.java @@ -131,10 +131,10 @@ public void testModuleResources() throws IOException { assertNode(reader, "/modules/modbar/com/bar/One.class"); ImageClassLoader loader = new ImageClassLoader(reader, IMAGE_ENTRIES.keySet()); - assertEquals("Class: com.foo.HasPreviewVersion", loader.loadAndGetToString("modfoo", "com.foo.HasPreviewVersion")); - assertEquals("Class: com.foo.NormalFoo", loader.loadAndGetToString("modfoo", "com.foo.NormalFoo")); - assertEquals("Class: com.foo.bar.NormalBar", loader.loadAndGetToString("modfoo", "com.foo.bar.NormalBar")); - assertEquals("Class: com.bar.One", loader.loadAndGetToString("modbar", "com.bar.One")); + assertNonPreviewVersion(loader, "modfoo", "com.foo.HasPreviewVersion"); + assertNonPreviewVersion(loader, "modfoo", "com.foo.NormalFoo"); + assertNonPreviewVersion(loader, "modfoo", "com.foo.bar.NormalBar"); + assertNonPreviewVersion(loader, "modbar", "com.bar.One"); } } @@ -230,9 +230,9 @@ public void testPreviewResources_disabled() throws IOException { ImageClassLoader loader = new ImageClassLoader(reader, IMAGE_ENTRIES.keySet()); // No preview classes visible. - assertEquals("Class: com.foo.HasPreviewVersion", loader.loadAndGetToString("modfoo", "com.foo.HasPreviewVersion")); - assertEquals("Class: com.foo.NormalFoo", loader.loadAndGetToString("modfoo", "com.foo.NormalFoo")); - assertEquals("Class: com.foo.bar.NormalBar", loader.loadAndGetToString("modfoo", "com.foo.bar.NormalBar")); + assertNonPreviewVersion(loader, "modfoo", "com.foo.HasPreviewVersion"); + assertNonPreviewVersion(loader, "modfoo", "com.foo.NormalFoo"); + assertNonPreviewVersion(loader, "modfoo", "com.foo.bar.NormalBar"); // NormalBar exists but IsPreviewOnly doesn't. assertResource(reader, "modfoo", "com/foo/bar/NormalBar.class"); @@ -248,10 +248,10 @@ public void testPreviewResources_enabled() throws IOException { ImageClassLoader loader = new ImageClassLoader(reader, IMAGE_ENTRIES.keySet()); // Preview version of classes either overwrite existing entries or are added to directories. - assertEquals("Preview: com.foo.HasPreviewVersion", loader.loadAndGetToString("modfoo", "com.foo.HasPreviewVersion")); - assertEquals("Class: com.foo.NormalFoo", loader.loadAndGetToString("modfoo", "com.foo.NormalFoo")); - assertEquals("Class: com.foo.bar.NormalBar", loader.loadAndGetToString("modfoo", "com.foo.bar.NormalBar")); - assertEquals("Preview: com.foo.bar.IsPreviewOnly", loader.loadAndGetToString("modfoo", "com.foo.bar.IsPreviewOnly")); + assertPreviewVersion(loader, "modfoo", "com.foo.HasPreviewVersion"); + assertNonPreviewVersion(loader, "modfoo", "com.foo.NormalFoo"); + assertNonPreviewVersion(loader, "modfoo", "com.foo.bar.NormalBar"); + assertPreviewVersion(loader, "modfoo", "com.foo.bar.IsPreviewOnly"); // Both NormalBar and IsPreviewOnly exist (direct lookup and as child nodes). assertResource(reader, "modfoo", "com/foo/bar/NormalBar.class"); @@ -335,9 +335,9 @@ public void testPreviewEntriesAlwaysHidden(boolean previewMode) throws IOExcepti assertAbsent(reader, "/modules/modfoo/META-INF/preview/com/foo"); // HasPreviewVersion.class is a preview class in the test data, and thus appears in // two places in the jimage). Ensure the preview version is always hidden. - String alphaPath = "com/foo/HasPreviewVersion.class"; - assertNode(reader, "/modules/modfoo/" + alphaPath); - assertAbsent(reader, "/modules/modfoo/META-INF/preview/" + alphaPath); + String previewPath = "com/foo/HasPreviewVersion.class"; + assertNode(reader, "/modules/modfoo/" + previewPath); + assertAbsent(reader, "/modules/modfoo/META-INF/preview/" + previewPath); } } @@ -374,6 +374,14 @@ private static void assertResource(ImageReader reader, String modName, String re assertSame(resNode, reader.findNode(nodeName)); } + private static void assertNonPreviewVersion(ImageClassLoader loader, String module, String fqn) throws IOException { + assertEquals("Class: " + fqn, loader.loadAndGetToString(module, fqn)); + } + + private static void assertPreviewVersion(ImageClassLoader loader, String module, String fqn) throws IOException { + assertEquals("Preview: " + fqn, loader.loadAndGetToString(module, fqn)); + } + private static ImageReader.Node assertLink(ImageReader reader, String name) throws IOException { ImageReader.Node link = assertNode(reader, name); assertTrue(link.isLink(), "Node should be a symbolic link: " + link.getName()); diff --git a/test/jdk/jdk/internal/jrtfs/SystemImageTest.java b/test/jdk/jdk/internal/jrtfs/SystemImageTest.java new file mode 100644 index 00000000000..0294c0b8602 --- /dev/null +++ b/test/jdk/jdk/internal/jrtfs/SystemImageTest.java @@ -0,0 +1,576 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import jdk.internal.jimage.ImageReader; +import jdk.internal.jimage.PreviewMode; +import jdk.internal.jrtfs.SystemImage; +import jdk.test.lib.compiler.InMemoryJavaCompiler; +import jdk.test.lib.util.JarBuilder; +import jdk.tools.jlink.internal.LinkableRuntimeImage; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import tests.Helper; +import tests.JImageGenerator; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.stream.Collectors.toSet; +import static jdk.internal.jimage.PreviewMode.DISABLED; +import static jdk.internal.jimage.PreviewMode.ENABLED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/* + * @test + * @summary Tests for SystemImage to ensure parity between ImageReader and ExplodedImage. + * @modules java.base/jdk.internal.jimage + * java.base/jdk.internal.jrtfs + * jdk.jlink/jdk.tools.jimage + * jdk.jlink/jdk.tools.jlink.internal + * @library /test/jdk/tools/lib + * /test/lib + * @build tests.* + * @run junit/othervm -esa -DDISABLE_PREVIEW_PATCHING=true SystemImageTest + */ +// FIXME: Currently the test output in Jtreg does not show the implementation. +// This is due to using both @ParameterizedClass and @ParameterizedTest to +// create a cross-product of test parameters. The parameters of parameterized +// tests are shown, but not the class level implementation choice. +// +// If you are debugging a failure in this test, change the @EnumSource line to +// include 'names = {"[SYSTEM|EXPLODED]"}' to test a single implementation. +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ParameterizedClass +@EnumSource(SystemImageTest.ImageType.class) +class SystemImageTest { + // Selects the underlying implementation to be tested. + enum ImageType {SYSTEM, EXPLODED} + + // The '@' prefix marks the entry as a preview entry which will be placed in + // the '/modules//META-INF/preview/...' path. + private static final Map> IMAGE_ENTRIES = Map.of( + "modfoo", Arrays.asList( + "com.foo.HasPreviewVersion", + "com.foo.NormalFoo", + "com.foo.bar.NormalBar", + // Replaces original class in preview mode. + "@com.foo.HasPreviewVersion", + // New class in existing package in preview mode. + "@com.foo.bar.IsPreviewOnly"), + "modbar", Arrays.asList( + "com.bar.One", + "com.bar.Two", + // Two new packages in preview mode (new symbolic links). + "@com.bar.preview.stuff.Foo", + "@com.bar.preview.stuff.Bar"), + "modgus", Arrays.asList( + // A second module with a preview-only empty package (preview). + "@com.bar.preview.other.Gus")); + + // Test data paths, built once for all tests. + private Path jimageFile; + private Path explodedModulesDir; + + // The injected implementation type from @EnumSource. + @Parameter(0) + private ImageType implType; + + @BeforeAll + public void buildTestData(@TempDir Path modulesRoot) throws IOException { + Helper helper = getHelper(); + // Compile into the helper's jar directory so jlink will include it. + Path jarDir = compileModules(helper.getJarDir(), IMAGE_ENTRIES); + this.jimageFile = buildJimage(helper, IMAGE_ENTRIES.keySet()); + explodeTestModules(jarDir, modulesRoot); + this.explodedModulesDir = modulesRoot; + } + + // Make new images for each test based on the injected implementation type. + private SystemImage getImage(PreviewMode mode) throws IOException { + return switch (implType) { + case SYSTEM -> SystemImage.fromJimage(jimageFile, mode); + case EXPLODED -> SystemImage.fromDirectory(explodedModulesDir, mode); + }; + } + + @ParameterizedTest + @ValueSource(strings = { + "/", + "/modules", + "/modules/modfoo", + "/modules/modbar", + "/modules/modfoo/com", + "/modules/modfoo/com/foo", + "/modules/modfoo/com/foo/bar"}) + public void testModuleDirectories_expected(String name) throws IOException { + try (var image = getImage(DISABLED)) { + assertDir(image, name); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "//", + "/modules/", + "/modules/unknown", + "/modules/modbar/", + "/modules/modfoo//com", + "/modules/modfoo/com/"}) + public void testModuleNodes_absent(String name) throws IOException { + try (var image = getImage(DISABLED)) { + assertAbsent(image, name); + } + } + + @Test + public void testModuleResources() throws IOException { + try (var image = getImage(DISABLED)) { + assertNode(image, "/modules/modfoo/com/foo/HasPreviewVersion.class"); + assertNode(image, "/modules/modbar/com/bar/One.class"); + + ImageClassLoader loader = loader(image); + loader.assertNonPreviewVersion("modfoo", "com.foo.HasPreviewVersion"); + loader.assertNonPreviewVersion("modfoo", "com.foo.NormalFoo"); + loader.assertNonPreviewVersion("modfoo", "com.foo.bar.NormalBar"); + loader.assertNonPreviewVersion("modbar", "com.bar.One"); + } + } + + @Test + public void testPackageDirectories() throws IOException { + try (var image = getImage(DISABLED)) { + ImageReader.Node root = assertDir(image, "/packages"); + Set pkgNames = root.getChildNames().collect(toSet()); + assertTrue(pkgNames.contains("/packages/com")); + assertTrue(pkgNames.contains("/packages/com.foo")); + assertTrue(pkgNames.contains("/packages/com.bar")); + + // Even though no classes exist directly in the "com" package, it still + // creates a directory with links back to all the modules which contain it. + Set comLinks = assertDir(image, "/packages/com").getChildNames().collect(Collectors.toSet()); + assertTrue(comLinks.contains("/packages/com/modfoo")); + assertTrue(comLinks.contains("/packages/com/modbar")); + } + } + + @Test + public void testPackageLinks() throws IOException { + try (var image = getImage(DISABLED)) { + ImageReader.Node moduleFoo = assertDir(image, "/modules/modfoo"); + ImageReader.Node moduleBar = assertDir(image, "/modules/modbar"); + assertSame(assertLink(image, "/packages/com.foo/modfoo").resolveLink(), moduleFoo); + assertSame(assertLink(image, "/packages/com.bar/modbar").resolveLink(), moduleBar); + } + } + + @Test + public void testPreviewResources_disabled() throws IOException { + try (var image = getImage(DISABLED)) { + // No preview classes visible. + ImageClassLoader loader = loader(image); + loader.assertNonPreviewVersion("modfoo", "com.foo.HasPreviewVersion"); + loader.assertNonPreviewVersion("modfoo", "com.foo.NormalFoo"); + loader.assertNonPreviewVersion("modfoo", "com.foo.bar.NormalBar"); + + // NormalBar exists but IsPreviewOnly doesn't. + assertResource(image, "/modules/modfoo/com/foo/bar/NormalBar.class"); + assertAbsent(image, "/modules/modfoo/com/foo/bar/IsPreviewOnly.class"); + assertDirContents(image, "/modules/modfoo/com/foo", "HasPreviewVersion.class", "NormalFoo.class", "bar"); + assertDirContents(image, "/modules/modfoo/com/foo/bar", "NormalBar.class"); + } + } + + @Test + public void testPreviewResources_enabled() throws IOException { + try (var image = getImage(ENABLED)) { + // Preview version of classes either overwrite existing entries or are added to directories. + ImageClassLoader loader = loader(image); + loader.assertPreviewVersion("modfoo", "com.foo.HasPreviewVersion"); + loader.assertNonPreviewVersion("modfoo", "com.foo.NormalFoo"); + loader.assertNonPreviewVersion("modfoo", "com.foo.bar.NormalBar"); + loader.assertPreviewVersion("modfoo", "com.foo.bar.IsPreviewOnly"); + + // Both NormalBar and IsPreviewOnly exist (direct lookup and as child nodes). + assertResource(image, "/modules/modfoo/com/foo/bar/NormalBar.class"); + assertResource(image, "/modules/modfoo/com/foo/bar/IsPreviewOnly.class"); + assertDirContents(image, "/modules/modfoo/com/foo", "HasPreviewVersion.class", "NormalFoo.class", "bar"); + assertDirContents(image, "/modules/modfoo/com/foo/bar", "NormalBar.class", "IsPreviewOnly.class"); + } + } + + @Test + public void testPreviewOnlyPackages_disabled() throws IOException { + try (var image = getImage(DISABLED)) { + // No 'preview' package or anything inside it. + assertDirContents(image, "/modules/modbar/com/bar", "One.class", "Two.class"); + assertAbsent(image, "/modules/modbar/com/bar/preview"); + assertAbsent(image, "/modules/modbar/com/bar/preview/stuff/Foo.class"); + + // And no package link. + assertAbsent(image, "/packages/com.bar.preview"); + } + } + + @Test + public void testPreviewOnlyPackages_enabled() throws IOException { + try (var image = getImage(ENABLED)) { + // In preview mode 'preview' package exists with preview only content. + assertDirContents(image, "/modules/modbar/com/bar", "One.class", "Two.class", "preview"); + assertDirContents(image, "/modules/modbar/com/bar/preview/stuff", "Foo.class", "Bar.class"); + assertResource(image, "/modules/modbar/com/bar/preview/stuff/Foo.class"); + + // And package links exists. + assertDirContents(image, "/packages/com.bar.preview", "modbar", "modgus"); + } + } + + @Test + public void testPreviewModeLinks_disabled() throws IOException { + try (var image = getImage(DISABLED)) { + assertDirContents(image, "/packages/com.bar", "modbar"); + // Missing symbolic link and directory when not in preview mode. + assertAbsent(image, "/packages/com.bar.preview"); + assertAbsent(image, "/packages/com.bar.preview.stuff"); + assertAbsent(image, "/modules/modbar/com/bar/preview"); + assertAbsent(image, "/modules/modbar/com/bar/preview/stuff"); + } + } + + @Test + public void testPreviewModeLinks_enabled() throws IOException { + try (var image = getImage(ENABLED)) { + // In preview mode there is a new preview-only module visible. + assertDirContents(image, "/packages/com.bar", "modbar", "modgus"); + // And additional packages are present. + assertDirContents(image, "/packages/com.bar.preview", "modbar", "modgus"); + assertDirContents(image, "/packages/com.bar.preview.stuff", "modbar"); + assertDirContents(image, "/packages/com.bar.preview.other", "modgus"); + // And the preview-only content appears as we expect. + assertDirContents(image, "/modules/modbar/com/bar", "One.class", "Two.class", "preview"); + assertDirContents(image, "/modules/modbar/com/bar/preview", "stuff"); + assertDirContents(image, "/modules/modbar/com/bar/preview/stuff", "Foo.class", "Bar.class"); + // In both modules in which it was added. + assertDirContents(image, "/modules/modgus/com/bar", "preview"); + assertDirContents(image, "/modules/modgus/com/bar/preview", "other"); + assertDirContents(image, "/modules/modgus/com/bar/preview/other", "Gus.class"); + } + } + + @ParameterizedTest + @EnumSource(value = PreviewMode.class, names = {"DISABLED", "ENABLED"}) + public void testPreviewEntriesAlwaysHidden(PreviewMode mode) throws IOException { + try (var image = getImage(mode)) { + // The META-INF directory exists, but does not contain the preview directory. + ImageReader.Node dir = assertDir(image, "/modules/modfoo/META-INF"); + assertEquals(0, dir.getChildNames().filter(n -> n.endsWith("/preview")).count()); + // Neither the preview directory, nor anything in it, can be looked-up directly. + assertAbsent(image, "/modules/modfoo/META-INF/preview"); + assertAbsent(image, "/modules/modfoo/META-INF/preview/com/foo"); + // HasPreviewVersion.class is a preview class in the test data, and thus appears in + // two places in the jimage). Ensure the preview version is always hidden. + String previewPath = "com/foo/HasPreviewVersion.class"; + assertNode(image, "/modules/modfoo/" + previewPath); + assertAbsent(image, "/modules/modfoo/META-INF/preview/" + previewPath); + } + } + + // ======== Helper assertions with better error reporting ======== + + private static ImageReader.Node assertNode(SystemImage image, String name) throws IOException { + ImageReader.Node node = image.findNode(name); + assertNotNull(node, "Could not find node: " + name); + return node; + } + + private static void assertResource(SystemImage image, String name) throws IOException { + ImageReader.Node node = assertNode(image, name); + assertTrue(node.isResource(), "Node was not a resource: " + name); + } + + private static ImageReader.Node assertDir(SystemImage image, String name) throws IOException { + ImageReader.Node dir = assertNode(image, name); + assertTrue(dir.isDirectory(), "Node was not a directory: " + name); + return dir; + } + + private static void assertDirContents(SystemImage image, String name, String... expectedChildNames) + throws IOException { + ImageReader.Node dir = assertDir(image, name); + Set localChildNames = dir.getChildNames() + .peek(s -> assertTrue(s.startsWith(name + "/"))) + .map(s -> s.substring(name.length() + 1)) + .collect(toSet()); + assertEquals( + Set.of(expectedChildNames), + localChildNames, + String.format("Unexpected child names in directory '%s'", name)); + } + + private static ImageReader.Node assertLink(SystemImage image, String name) throws IOException { + ImageReader.Node link = assertNode(image, name); + assertTrue(link.isLink(), "Node should be a symbolic link: " + link.getName()); + return link; + } + + private static void assertAbsent(SystemImage image, String name) throws IOException { + assertNull(image.findNode(name), "Should not be able to find node: " + name); + } + + /// Returns a custom class loader for loading instances from a given image + /// and making assertions about the class implementation. + private static ImageClassLoader loader(SystemImage image) { + return new ImageClassLoader(image, IMAGE_ENTRIES.keySet()); + } + + // ======== Test data creation ======== + + /// Builds a jimage file with the specified class entries. The classes in + /// the built image can be loaded and executed to return their names via + /// `toString()` to confirm the correct bytes were returned. + private static Path buildJimage(Helper helper, Set moduleNames) { + Path outDir = helper.createNewImageDir("test"); + // The default module path contains the directory we compiled the jars into. + JImageGenerator.JLinkTask jlink = JImageGenerator.getJLinkTask() + .modulePath(helper.defaultModulePath()) + .output(outDir); + moduleNames.forEach(jlink::addMods); + return jlink.call().assertSuccess().resolve("lib", "modules"); + } + + /// Compiles a set of synthetic modules, as separate Jar files, in the given + /// directory. + private static Path compileModules(Path jarDir, Map> entries) { + entries.forEach((module, classes) -> compileModuleJar(module, classes, jarDir)); + return jarDir; + } + + /// Compiles a synthetic module containing test classes into a single Jar + /// file named {@code .jar} in the given directory. Test classes can + /// be instantiated and have their {@code toString()} method called to + /// return a status string for testing. + /// + /// If a fully qualified class name is prefixed with {@code @} then it is + /// compiled as a preview version of the class, with different + /// {@code toString()} representation. + private static void compileModuleJar(String module, List classNames, Path jarDir) { + JarBuilder jar = new JarBuilder(jarDir.resolve(module + ".jar").toString()); + String moduleInfo = "module " + module + " {}"; + jar.addEntry("module-info.class", InMemoryJavaCompiler.compile("module-info", moduleInfo)); + + classNames.forEach(fqn -> { + boolean isPreviewEntry = fqn.startsWith("@"); + if (isPreviewEntry) { + fqn = fqn.substring(1); + } + int lastDot = fqn.lastIndexOf('.'); + String pkg = fqn.substring(0, lastDot); + String cls = fqn.substring(lastDot + 1); + String source = String.format( + """ + package %s; + public class %s { + public String toString() { + return "%s: %s"; + } + } + """, pkg, cls, isPreviewEntry ? "Preview" : "Class", fqn); + String path = (isPreviewEntry ? "META-INF/preview/" : "") + fqn.replace('.', '/') + ".class"; + jar.addEntry(path, InMemoryJavaCompiler.compile(fqn, source)); + }); + try { + jar.build(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /// Unpacks Jar files in a given directory to construct an "exploded" view + /// of the jimage content. Modules are unpacked into a directory named after + /// the Jar file's base name, and synthetic "marker" files, designed to + /// mimic build artifacts which should be ignored, are added. + private static void explodeTestModules(Path jarDir, Path modulesRoot) throws IOException { + try (var jars = Files.list(jarDir).filter(SystemImageTest::isJarFile)) { + jars.forEach(jar -> explodeModuleJar(jar, modulesRoot)); + } + } + + private static boolean isJarFile(Path p) { + return p.getFileName().toString().endsWith(".jar"); + } + + /// Unpacks the content of a single Jar file into a modules directory, and + /// adds synthetic marker files to mimic build artifacts (which should be + /// ignored). + private static void explodeModuleJar(Path jar, Path modulesRoot) { + String modName = jar.getFileName().toString(); + if (!modName.endsWith(".jar")) { + throw new IllegalArgumentException("Bad jar file: " + jar); + } + modName = modName.substring(0, modName.length() - 4); + Path modDir = modulesRoot.resolve(modName); + try (FileSystem zipfs = FileSystems.newFileSystem(jar, Map.of("accessMode", "readOnly"))) { + Path rootDir = zipfs.getRootDirectories().iterator().next(); + Set dstDirs = new HashSet<>(); + try (var files = Files.walk(rootDir)) { + files.filter(Files::isRegularFile).forEach(path -> { + try { + // Construct equivalent destination path in modules dir. + Path dst = StreamSupport.stream(path.spliterator(), false) + .reduce(modDir, (d, p) -> d.resolve(p.toString())); + if (dstDirs.add(dst.getParent())) { + Files.createDirectories(dst.getParent()); + } + Files.copy(path, dst); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }); + } + // Add a marker file in each directory and the module root. + dstDirs.forEach(SystemImageTest::writeIgnoredBuildMarker); + writeIgnoredBuildMarker(modDir); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /// Writes a "marker" file in a given directory to mimic build artifacts + /// which must be ignored when using "exploded" images. + private static void writeIgnoredBuildMarker(Path dir) { + try { + Files.writeString(dir.resolve("_the.ignored.marker"), "Ignored", UTF_8); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /// Returns the helper for building JAR and jimage files. + private static Helper getHelper() { + Helper helper; + try { + boolean isLinkableRuntime = LinkableRuntimeImage.isLinkableRuntime(); + helper = Helper.newHelper(isLinkableRuntime); + } catch (IOException e) { + throw new RuntimeException(e); + } + Assumptions.assumeTrue(helper != null, "Cannot create test helper, skipping test!"); + return helper; + } + + /// Provides assertions for classes loaded from a specified `SystemImage`. + private static class ImageClassLoader extends ClassLoader { + private final SystemImage image; + private final Set testModules; + + private ImageClassLoader(SystemImage image, Set testModules) { + this.image = image; + this.testModules = testModules; + } + + /// Asserts that a synthetic test class, loaded from a given module, is + /// the *non-preview* version. + /// + /// @param module module name + /// @param fqn fully qualified class name + public void assertNonPreviewVersion(String module, String fqn) throws IOException { + assertEquals("Class: " + fqn, loadAndGetToString(module, fqn)); + } + + /// Asserts that a synthetic test class, loaded from a given module, is + /// the *preview* version. + /// + /// @param module module name + /// @param fqn fully qualified class name + public void assertPreviewVersion(String module, String fqn) throws IOException { + assertEquals("Preview: " + fqn, loadAndGetToString(module, fqn)); + } + + private String loadAndGetToString(String module, String fqn) { + return loadAndCall(module, fqn, c -> c.getDeclaredConstructor().newInstance().toString()); + } + + @FunctionalInterface + public interface ClassAction { + R call(Class cls) throws T; + } + + private R loadAndCall(String module, String fqn, ClassAction action) { + Class cls = findClass(module, fqn); + assertNotNull(cls, "Could not load class: " + module + "/" + fqn); + try { + return action.call(cls); + } catch (Exception e) { + fail("Class loading failed", e); + return null; + } + } + + @Override + protected Class findClass(String module, String fqn) { + assumeTrue(testModules.contains(module), "Can only load classes in modules: " + testModules); + String name = "/modules/" + module + "/" + fqn.replace('.', '/') + ".class"; + Class cls = findLoadedClass(fqn); + if (cls == null) { + try { + ImageReader.Node node = image.findNode(name); + if (node != null && node.isResource()) { + byte[] classBytes = image.getResource(node); + cls = defineClass(fqn, classBytes, 0, classBytes.length); + resolveClass(cls); + return cls; + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return null; + } + } +} diff --git a/test/jdk/jdk/internal/jrtfs/whitebox/ExplodedImageTestDriver.java b/test/jdk/jdk/internal/jrtfs/whitebox/ExplodedImageTestDriver.java deleted file mode 100644 index 884024454d4..00000000000 --- a/test/jdk/jdk/internal/jrtfs/whitebox/ExplodedImageTestDriver.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ - -/* - * @test - * @summary Whitebox tests for ExplodedImage to ensure compatibility with ImageReader. - * @modules java.base/jdk.internal.jrtfs java.base/jdk.internal.jimage - * @run junit/othervm java.base/jdk.internal.jrtfs.ExplodedImageTest - */ -public class ExplodedImageTestDriver {} \ No newline at end of file diff --git a/test/jdk/jdk/internal/jrtfs/whitebox/TEST.properties b/test/jdk/jdk/internal/jrtfs/whitebox/TEST.properties deleted file mode 100644 index 6e60bee4991..00000000000 --- a/test/jdk/jdk/internal/jrtfs/whitebox/TEST.properties +++ /dev/null @@ -1,4 +0,0 @@ -modules = \ - java.base/jdk.internal.jimage \ - java.base/jdk.internal.jrtfs -bootclasspath.dirs=. diff --git a/test/jdk/jdk/internal/jrtfs/whitebox/java.base/jdk/internal/jrtfs/ExplodedImageTest.java b/test/jdk/jdk/internal/jrtfs/whitebox/java.base/jdk/internal/jrtfs/ExplodedImageTest.java deleted file mode 100644 index c63e163467b..00000000000 --- a/test/jdk/jdk/internal/jrtfs/whitebox/java.base/jdk/internal/jrtfs/ExplodedImageTest.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ - -package jdk.internal.jrtfs; - -import jdk.internal.jimage.ImageReader; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import java.io.IOException; -import java.net.URI; -import java.nio.file.DirectoryStream; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Tests an {@link ExplodedImage} view of a class-file hierarchy. - * - *

For simplicity and performance, only a subset of the JRT files are copied - * to disk for testing. - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class ExplodedImageTest { - - private Path modulesRoot; - private SystemImage explodedImage; - private String pathSeparator; - - @BeforeAll - public void createTestDirectory(@TempDir Path modulesRoot) throws IOException { - this.modulesRoot = modulesRoot; - this.pathSeparator = modulesRoot.getFileSystem().getSeparator(); - // Copy only a useful subset of files for testing. Use at least two - // modules with "overlapping" packages to test /package links better. - unpackModulesDirectoriesFromJrtFileSystem(modulesRoot, - "java.base/java/util", - "java.base/java/util/zip", - "java.logging/java/util/logging"); - this.explodedImage = new ExplodedImage(modulesRoot); - } - - /** Unpacks a list of "/modules/..." directories non-recursively into the specified root directory. */ - private static void unpackModulesDirectoriesFromJrtFileSystem(Path modulesRoot, String... dirNames) - throws IOException { - FileSystem jrtfs = FileSystems.getFileSystem(URI.create("jrt:/")); - List srcDirs = Arrays.stream(dirNames).map(s -> "/modules/" + s).map(jrtfs::getPath).toList(); - for (Path srcDir : srcDirs) { - // Skip-1 to remove "modules" segment (not part of the file system path). - Path dstDir = StreamSupport.stream(srcDir.spliterator(), false) - .skip(1) - .reduce(modulesRoot, (path, segment) -> path.resolve(segment.toString())); - Files.createDirectories(dstDir); - try (DirectoryStream files = Files.newDirectoryStream(srcDir)) { - for (Path srcFile : files) { - Files.copy(srcFile, dstDir.resolve(srcFile.getFileName().toString())); - } - } - } - } - - @Test - public void topLevelNodes() throws IOException { - ImageReader.Node root = explodedImage.findNode("/"); - ImageReader.Node modules = explodedImage.findNode("/modules"); - ImageReader.Node packages = explodedImage.findNode("/packages"); - assertEquals( - Set.of(modules.getName(), packages.getName()), - root.getChildNames().collect(Collectors.toSet())); - } - - @ParameterizedTest - @ValueSource(strings = { - "/modules/java.base/java/util/List.class", - "/modules/java.base/java/util/zip/ZipEntry.class", - "/modules/java.logging/java/util/logging/Logger.class"}) - public void basicLookupResource(String expectedResourceName) throws IOException { - ImageReader.Node node = assertResourceNode(expectedResourceName); - - Path fsRelPath = getRelativePath(expectedResourceName); - assertArrayEquals( - Files.readAllBytes(modulesRoot.resolve(fsRelPath)), - explodedImage.getResource(node)); - } - - @ParameterizedTest - @ValueSource(strings = { - "/modules/java.base", - "/modules/java.logging", - "/modules/java.base/java", - "/modules/java.base/java/util", - "/modules/java.logging/java/util", - }) - public void basicLookupDirectory(String expectedDirectoryName) throws IOException { - ImageReader.Node node = assertDirectoryNode(expectedDirectoryName); - - Path fsRelPath = getRelativePath(expectedDirectoryName); - List fsChildBaseNames; - try (DirectoryStream paths = Files.newDirectoryStream(modulesRoot.resolve(fsRelPath))) { - fsChildBaseNames = StreamSupport.stream(paths.spliterator(), false) - .map(Path::getFileName) - .map(Path::toString) - .toList(); - } - List nodeChildBaseNames = node.getChildNames() - .map(s -> s.substring(node.getName().length() + 1)) - .toList(); - assertEquals(fsChildBaseNames, nodeChildBaseNames, "expected same child names"); - } - - @ParameterizedTest - @ValueSource(strings = { - "/packages/java/java.base", - "/packages/java/java.logging", - "/packages/java.util/java.base", - "/packages/java.util/java.logging", - "/packages/java.util.zip/java.base"}) - public void basicLookupPackageLinks(String expectedLinkName) throws IOException { - ImageReader.Node node = assertLinkNode(expectedLinkName); - ImageReader.Node resolved = node.resolveLink(); - assertSame(explodedImage.findNode(resolved.getName()), resolved); - String moduleName = expectedLinkName.substring(expectedLinkName.lastIndexOf('/') + 1); - assertEquals("/modules/" + moduleName, resolved.getName()); - } - - @ParameterizedTest - @ValueSource(strings = { - "/packages/java", - "/packages/java.util", - "/packages/java.util.zip"}) - public void packageDirectories(String expectedDirectoryName) throws IOException { - ImageReader.Node node = assertDirectoryNode(expectedDirectoryName); - assertTrue(node.getChildNames().findFirst().isPresent(), - "Package directories should not be empty: " + node); - } - - @ParameterizedTest - @ValueSource(strings = { - "", - ".", - "/.", - "modules", - "packages", - "/modules/", - "/modules/xxxx", - "/modules/java.base/java/lang/Xxxx.class", - "/packages/", - "/packages/xxxx", - "/packages/java.xxxx", - "/packages/java.util.", - // Mismatched module. - "/packages/java.util.logging/java.base", - "/packages/java.util.zip/java.logging", - // Links are not resolved as they are fetched (old/broken behaviour). - "/packages/java.util/java.base/java/util/Vector.class", - }) - public void invalidNames(String invalidName) throws IOException { - assertNull(explodedImage.findNode(invalidName), "No node expected for: " + invalidName); - } - - private ImageReader.Node assertResourceNode(String name) throws IOException { - ImageReader.Node node = explodedImage.findNode(name); - assertNotNull(node); - assertEquals(name, node.getName(), "expected node name: " + name); - assertTrue(node.isResource(), "expected a resource: " + node); - assertFalse(node.isDirectory(), "resources are not directories: " + node); - assertFalse(node.isLink(), "resources are not links: " + node); - return node; - } - - private ImageReader.Node assertDirectoryNode(String name) throws IOException { - ImageReader.Node node = explodedImage.findNode(name); - assertNotNull(node); - assertEquals(name, node.getName(), "expected node name: " + name); - assertTrue(node.isDirectory(), "expected a directory: " + node); - assertFalse(node.isResource(), "directories are not resources: " + node); - assertFalse(node.isLink(), "directories are not links: " + node); - return node; - } - - private ImageReader.Node assertLinkNode(String name) throws IOException { - ImageReader.Node node = explodedImage.findNode(name); - assertNotNull(node); - assertEquals(name, node.getName(), "expected node name: " + name); - assertTrue(node.isLink(), "expected a link: " + node); - assertFalse(node.isResource(), "links are not resources: " + node); - assertFalse(node.isDirectory(), "links are not directories: " + node); - return node; - } - - private Path getRelativePath(String name) { - return Path.of(name.substring("/modules/".length()).replace("/", pathSeparator)); - } -}