From e19035577724f40aca14ef7d5dad0906ce9e89ab Mon Sep 17 00:00:00 2001 From: David Beaumont Date: Thu, 4 Sep 2025 13:19:12 +0000 Subject: [PATCH] 8365467: Issues with jrtfs implementation for exploded run-time images Reviewed-by: rriggs, sundar --- .../jdk/internal/jrtfs/ExplodedImage.java | 175 ++++++------- .../jdk/internal/jrtfs/SystemImage.java | 8 +- .../whitebox/ExplodedImageTestDriver.java | 30 +++ .../internal/jrtfs/whitebox/TEST.properties | 4 + .../jdk/internal/jrtfs/ExplodedImageTest.java | 232 ++++++++++++++++++ 5 files changed, 359 insertions(+), 90 deletions(-) create mode 100644 test/jdk/jdk/internal/jrtfs/whitebox/ExplodedImageTestDriver.java create mode 100644 test/jdk/jdk/internal/jrtfs/whitebox/TEST.properties create mode 100644 test/jdk/jdk/internal/jrtfs/whitebox/java.base/jdk/internal/jrtfs/ExplodedImageTest.java 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 87a00da4393..4fe6612a8ed 100644 --- a/src/java.base/share/classes/jdk/internal/jrtfs/ExplodedImage.java +++ b/src/java.base/share/classes/jdk/internal/jrtfs/ExplodedImage.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015, 2022, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2015, 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 @@ -27,17 +27,15 @@ package jdk.internal.jrtfs; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.DirectoryStream; -import java.nio.file.FileSystem; import java.nio.file.FileSystemException; -import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Stream; import jdk.internal.jimage.ImageReader.Node; @@ -56,16 +54,15 @@ class ExplodedImage extends SystemImage { private static final String MODULES = "/modules/"; private static final String PACKAGES = "/packages/"; - private static final int PACKAGES_LEN = PACKAGES.length(); - private final FileSystem defaultFS; + private final Path modulesDir; private final String separator; - private final Map nodes = Collections.synchronizedMap(new HashMap<>()); + private final Map nodes = new HashMap<>(); private final BasicFileAttributes modulesDirAttrs; ExplodedImage(Path modulesDir) throws IOException { - defaultFS = FileSystems.getDefault(); - String str = defaultFS.getSeparator(); + this.modulesDir = modulesDir; + String str = modulesDir.getFileSystem().getSeparator(); separator = str.equals("/") ? null : str; modulesDirAttrs = Files.readAttributes(modulesDir, BasicFileAttributes.class); initNodes(); @@ -79,21 +76,26 @@ class ExplodedImage extends SystemImage { private PathNode link; private List children; - PathNode(String name, Path path, BasicFileAttributes attrs) { // path + private PathNode(String name, Path path, BasicFileAttributes attrs) { // path super(name, attrs); this.path = path; } - PathNode(String name, Node link) { // link + private PathNode(String name, Node link) { // link super(name, link.getFileAttributes()); this.link = (PathNode)link; } - PathNode(String name, List children) { // dir + private PathNode(String name, List children) { // dir super(name, modulesDirAttrs); this.children = children; } + @Override + public boolean isResource() { + return link == null && !getFileAttributes().isDirectory(); + } + @Override public boolean isDirectory() { return children != null || @@ -112,7 +114,7 @@ class ExplodedImage extends SystemImage { return recursive && link.isLink() ? link.resolveLink(true) : link; } - byte[] getContent() throws IOException { + private byte[] getContent() throws IOException { if (!getFileAttributes().isRegularFile()) throw new FileSystemException(getName() + " is not file"); return Files.readAllBytes(path); @@ -126,7 +128,7 @@ class ExplodedImage extends SystemImage { List list = new ArrayList<>(); try (DirectoryStream stream = Files.newDirectoryStream(path)) { for (Path p : stream) { - p = explodedModulesDir.relativize(p); + p = modulesDir.relativize(p); String pName = MODULES + nativeSlashToFrontSlash(p.toString()); Node node = findNode(pName); if (node != null) { // findNode may choose to hide certain files! @@ -152,7 +154,7 @@ class ExplodedImage extends SystemImage { } @Override - public void close() throws IOException { + public synchronized void close() throws IOException { nodes.clear(); } @@ -161,72 +163,76 @@ class ExplodedImage extends SystemImage { return ((PathNode)node).getContent(); } - // find Node for the given Path @Override - public synchronized Node findNode(String str) { - Node node = findModulesNode(str); + public synchronized Node findNode(String name) { + PathNode node = nodes.get(name); if (node != null) { return node; } - // lazily created for paths like /packages///xyz - // For example /packages/java.lang/java.base/java/lang/ - if (str.startsWith(PACKAGES)) { - // pkgEndIdx marks end of part - int pkgEndIdx = str.indexOf('/', PACKAGES_LEN); - if (pkgEndIdx != -1) { - // modEndIdx marks end of part - int modEndIdx = str.indexOf('/', pkgEndIdx + 1); - if (modEndIdx != -1) { - // make sure we have such module link! - // ie., /packages// is valid - Node linkNode = nodes.get(str.substring(0, modEndIdx)); - if (linkNode == null || !linkNode.isLink()) { - return null; - } - // map to "/modules/zyz" path and return that node - // For example, "/modules/java.base/java/lang" for - // "/packages/java.lang/java.base/java/lang". - String mod = MODULES + str.substring(pkgEndIdx + 1); - return findModulesNode(mod); + // If null, this was not the name of "/modules/..." node, and since all + // "/packages/..." nodes were created and cached in advance, the name + // cannot reference a valid node. + Path path = underlyingModulesPath(name); + if (path == null) { + return null; + } + // This can still return null for hidden files. + return createModulesNode(name, path); + } + + /** + * Lazily creates and caches a {@code Node} for the given "/modules/..." name + * and corresponding path to a file or directory. + * + * @param name a resource or directory node name, of the form "/modules/...". + * @param path the path of a file for a resource or directory. + * @return the newly created and cached node, or {@code null} if the given + * path references a file which must be hidden in the node hierarchy. + */ + private Node createModulesNode(String name, Path path) { + assert !nodes.containsKey(name) : "Node must not already exist: " + name; + assert isNonEmptyModulesPath(name) : "Invalid modules name: " + name; + + try { + // We only know if we're creating a resource of directory when we + // look up file attributes, and we only do that once. Thus, we can + // only reject "marker files" here, rather than by inspecting the + // given name string, since it doesn't apply to directories. + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + if (attrs.isRegularFile()) { + Path f = path.getFileName(); + if (f.toString().startsWith("_the.")) { + return null; } + } else if (!attrs.isDirectory()) { + return null; } + PathNode node = new PathNode(name, path, attrs); + nodes.put(name, node); + return node; + } catch (IOException x) { + // Since the path reference a file, any errors should not be ignored. + throw new UncheckedIOException(x); + } + } + + /** + * 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; } - // find a Node for a path that starts like "/modules/..." - Node findModulesNode(String str) { - PathNode node = nodes.get(str); - if (node != null) { - return node; - } - // lazily created "/modules/xyz/abc/" Node - // This is mapped to default file system path "/xyz/abc" - Path p = underlyingPath(str); - if (p != null) { - try { - BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class); - if (attrs.isRegularFile()) { - Path f = p.getFileName(); - if (f.toString().startsWith("_the.")) - return null; - } - node = new PathNode(str, p, attrs); - nodes.put(str, node); - return node; - } catch (IOException x) { - // does not exists or unable to determine - } - } - return null; - } - - Path underlyingPath(String str) { - if (str.startsWith(MODULES)) { - str = frontSlashToNativeSlash(str.substring("/modules".length())); - return defaultFS.getPath(explodedModulesDir.toString(), str); - } - return null; + private static boolean isNonEmptyModulesPath(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(); } // convert "/" to platform path separator @@ -249,24 +255,21 @@ class ExplodedImage extends SystemImage { // same package prefix may exist in multiple modules. This Map // is filled by walking "jdk modules" directory recursively! Map> packageToModules = new HashMap<>(); - try (DirectoryStream stream = Files.newDirectoryStream(explodedModulesDir)) { + try (DirectoryStream stream = Files.newDirectoryStream(modulesDir)) { for (Path module : stream) { if (Files.isDirectory(module)) { String moduleName = module.getFileName().toString(); // make sure "/modules/" is created - findModulesNode(MODULES + moduleName); + 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")) { - List moduleNames = packageToModules.get(pkgName); - if (moduleNames == null) { - moduleNames = new ArrayList<>(); - packageToModules.put(pkgName, moduleNames); - } - moduleNames.add(moduleName); + packageToModules + .computeIfAbsent(pkgName, k -> new ArrayList<>()) + .add(moduleName); } }); } @@ -275,8 +278,8 @@ class ExplodedImage extends SystemImage { } // create "/modules" directory // "nodes" map contains only /modules/ nodes only so far and so add all as children of /modules - PathNode modulesDir = new PathNode("/modules", new ArrayList<>(nodes.values())); - nodes.put(modulesDir.getName(), modulesDir); + PathNode modulesRootNode = new PathNode("/modules", new ArrayList<>(nodes.values())); + nodes.put(modulesRootNode.getName(), modulesRootNode); // create children under "/packages" List packagesChildren = new ArrayList<>(packageToModules.size()); @@ -285,7 +288,7 @@ class ExplodedImage extends SystemImage { List moduleNameList = entry.getValue(); List moduleLinkNodes = new ArrayList<>(moduleNameList.size()); for (String moduleName : moduleNameList) { - Node moduleNode = findModulesNode(MODULES + moduleName); + Node moduleNode = Objects.requireNonNull(nodes.get(MODULES + moduleName)); PathNode linkNode = new PathNode(PACKAGES + pkgName + "/" + moduleName, moduleNode); nodes.put(linkNode.getName(), linkNode); moduleLinkNodes.add(linkNode); @@ -295,13 +298,13 @@ class ExplodedImage extends SystemImage { packagesChildren.add(pkgDir); } // "/packages" dir - PathNode packagesDir = new PathNode("/packages", packagesChildren); - nodes.put(packagesDir.getName(), packagesDir); + PathNode packagesRootNode = new PathNode("/packages", packagesChildren); + nodes.put(packagesRootNode.getName(), packagesRootNode); // finally "/" dir! List rootChildren = new ArrayList<>(); - rootChildren.add(packagesDir); - rootChildren.add(modulesDir); + rootChildren.add(packagesRootNode); + rootChildren.add(modulesRootNode); PathNode root = new PathNode("/", rootChildren); nodes.put(root.getName(), root); } 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 6813c7e627f..b38e953a5f9 100644 --- a/src/java.base/share/classes/jdk/internal/jrtfs/SystemImage.java +++ b/src/java.base/share/classes/jdk/internal/jrtfs/SystemImage.java @@ -78,13 +78,13 @@ abstract class SystemImage { return new ExplodedImage(explodedModulesDir); } - static final String RUNTIME_HOME; + private static final String RUNTIME_HOME; // "modules" jimage file Path - static final Path moduleImageFile; + private static final Path moduleImageFile; // "modules" jimage exists or not? - static final boolean modulesImageExists; + private static final boolean modulesImageExists; // /modules directory Path - static final Path explodedModulesDir; + private static final Path explodedModulesDir; static { PrivilegedAction pa = SystemImage::findHome; diff --git a/test/jdk/jdk/internal/jrtfs/whitebox/ExplodedImageTestDriver.java b/test/jdk/jdk/internal/jrtfs/whitebox/ExplodedImageTestDriver.java new file mode 100644 index 00000000000..884024454d4 --- /dev/null +++ b/test/jdk/jdk/internal/jrtfs/whitebox/ExplodedImageTestDriver.java @@ -0,0 +1,30 @@ +/* + * 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 new file mode 100644 index 00000000000..6e60bee4991 --- /dev/null +++ b/test/jdk/jdk/internal/jrtfs/whitebox/TEST.properties @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000000..c63e163467b --- /dev/null +++ b/test/jdk/jdk/internal/jrtfs/whitebox/java.base/jdk/internal/jrtfs/ExplodedImageTest.java @@ -0,0 +1,232 @@ +/* + * 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)); + } +}