diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ba06ff6a..163ecba616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - [Guice] Inject static fields prior to before all hooks ([#2803](https://github.com/cucumber/cucumber-jvm/pull/2803) M.P. Korstanje) +### Added +- [Core] Support nested jar file systems (i.e. Spring Boot 3.2) ([#2830](https://github.com/cucumber/cucumber-jvm/pull/2830) M.P. Korstanje) + ## [7.14.0] - 2023-09-09 ### Changed - [Core] Update dependency io.cucumber:html-formatter to v20.4.0 diff --git a/cucumber-core/src/main/java/io/cucumber/core/resource/ClasspathSupport.java b/cucumber-core/src/main/java/io/cucumber/core/resource/ClasspathSupport.java index 2e0d03fa70..f3058241e8 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/resource/ClasspathSupport.java +++ b/cucumber-core/src/main/java/io/cucumber/core/resource/ClasspathSupport.java @@ -167,7 +167,8 @@ static String nestedJarEntriesExplanation(URI uri) { "This typically happens when trying to run Cucumber inside a Spring Boot Executable Jar.\n" + "Cucumber currently doesn't support classpath scanning in nested jars.\n" + "\n" + - "You can avoid this error by unpacking your application before executing.\n" + + "You can avoid this error by unpacking your application before executing or upgrading to Spring Boot 3.2 or higher.\n" + + "\n" + "Alternatively you can restrict which packages cucumber scans configuring the glue path such that " + "Cucumber only scans un-nested jars.\n" + diff --git a/cucumber-core/src/main/java/io/cucumber/core/resource/JarUriFileSystemService.java b/cucumber-core/src/main/java/io/cucumber/core/resource/JarUriFileSystemService.java index 880606bca9..2af2320d14 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/resource/JarUriFileSystemService.java +++ b/cucumber-core/src/main/java/io/cucumber/core/resource/JarUriFileSystemService.java @@ -22,7 +22,7 @@ class JarUriFileSystemService { private static final String JAR_URI_SCHEME = "jar"; private static final String JAR_URI_SCHEME_PREFIX = JAR_URI_SCHEME + ":"; private static final String JAR_FILE_SUFFIX = ".jar"; - private static final String JAR_URI_SEPARATOR = "!"; + private static final String JAR_URI_SEPARATOR = "!/"; private static final Map openFiles = new HashMap<>(); private static final Map referenceCount = new HashMap<>(); @@ -67,13 +67,14 @@ private static boolean hasFileUriSchemeWithJarExtension(URI uri) { } static CloseablePath open(URI uri) throws URISyntaxException, IOException { - if (hasJarUriScheme(uri)) { - return handleJarUriScheme(uri); - } + assert supports(uri); if (hasFileUriSchemeWithJarExtension(uri)) { return handleFileUriSchemeWithJarExtension(uri); } - throw new IllegalArgumentException("Unsupported uri " + uri.toString()); + if (isSpringBoot31OrLower(uri)) { + return handleSpringBoot31JarUri(uri); + } + return handleJarUriScheme(uri); } private static CloseablePath handleFileUriSchemeWithJarExtension(URI uri) throws IOException, URISyntaxException { @@ -82,22 +83,44 @@ private static CloseablePath handleFileUriSchemeWithJarExtension(URI uri) throws } private static CloseablePath handleJarUriScheme(URI uri) throws IOException, URISyntaxException { - String[] parts = uri.toString().split(JAR_URI_SEPARATOR); - // Regular jar schemes - if (parts.length <= 2) { - String jarUri = parts[0]; - String jarPath = parts.length == 2 ? parts[1] : "/"; - return open(new URI(jarUri), fileSystem -> fileSystem.getPath(jarPath)); + // Regular Jar Uris + // Format: jar:!/[] + String uriString = uri.toString(); + int lastJarUriSeparator = uriString.lastIndexOf(JAR_URI_SEPARATOR); + if (lastJarUriSeparator < 0) { + throw new IllegalArgumentException(String.format("jar uri '%s' must contain '%s'", uri, JAR_URI_SEPARATOR)); } + String url = uriString.substring(0, lastJarUriSeparator); + String entry = uriString.substring(lastJarUriSeparator + 1); + return open(new URI(url), fileSystem -> fileSystem.getPath(entry)); + } - // Spring boot jar scheme + private static boolean isSpringBoot31OrLower(URI uri) { + // Starting Spring Boot 3.2 the nested scheme is used. This works with + // regular jar file handling and doesn't need a workaround. + // Example 3.2: + // jar:nested:/dir/myjar.jar/!/BOOT-INF/lib/nested.jar!/com/example/MyClass.class + // Example 3.1: + // jar:file:/dir/myjar.jar/!/BOOT-INF/lib/nested.jar!/com/example/MyClass.class + String schemeSpecificPart = uri.getSchemeSpecificPart(); + return schemeSpecificPart.startsWith("file:") && schemeSpecificPart.contains("!/BOOT-INF"); + } + + private static CloseablePath handleSpringBoot31JarUri(URI uri) throws IOException, URISyntaxException { + // Spring boot 3.1 jar scheme + // Examples: + // jar:file:/home/user/application.jar!/BOOT-INF/lib/dependency.jar!/com/example/dependency/resource.txt + // jar:file:/home/user/application.jar!/BOOT-INF/classes!/com/example/package/resource.txt + String[] parts = uri.toString().split("!"); String jarUri = parts[0]; String jarEntry = parts[1]; String subEntry = parts[2]; if (jarEntry.endsWith(JAR_FILE_SUFFIX)) { throw new CucumberException(nestedJarEntriesExplanation(uri)); } + // We're looking directly at the files in the jar, so we construct the + // file path by concatenating the jarEntry and subEntry without the jar + // uri separator. return open(new URI(jarUri), fileSystem -> fileSystem.getPath(jarEntry + subEntry)); } - } diff --git a/cucumber-core/src/test/java/io/cucumber/core/resource/ResourceScannerTest.java b/cucumber-core/src/test/java/io/cucumber/core/resource/ResourceScannerTest.java index ff8cf9c1b1..dc6f258ddc 100644 --- a/cucumber-core/src/test/java/io/cucumber/core/resource/ResourceScannerTest.java +++ b/cucumber-core/src/test/java/io/cucumber/core/resource/ResourceScannerTest.java @@ -159,6 +159,29 @@ void scanForResourcesJarUri() { assertThat(resources, contains(resourceUri)); } + @Test + void scanForResourcesJarUriMalformed() { + URI jarFileUri = new File("src/test/resources/io/cucumber/core/resource/test/jar-resource.jar").toURI(); + URI resourceUri = URI + .create("jar:file://" + jarFileUri.getSchemeSpecificPart() + "/com/example/package-jar-resource.txt"); + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> resourceScanner.scanForResourcesUri(resourceUri)); + assertThat(exception.getMessage(), + containsString("jar uri '" + resourceUri + "' must contain '!/'")); + } + + @Test + void scanForResourcesJarUriMissingEntry() { + URI jarFileUri = new File("src/test/resources/io/cucumber/core/resource/test/jar-resource.jar").toURI(); + URI resourceUri = URI.create("jar:file://" + jarFileUri.getSchemeSpecificPart() + ""); + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> resourceScanner.scanForResourcesUri(resourceUri)); + assertThat(exception.getMessage(), + containsString("jar uri '" + resourceUri + "' must contain '!/'")); + } + @Test void scanForResourcesNestedJarUri() { URI jarFileUri = new File("src/test/resources/io/cucumber/core/resource/test/spring-resource.jar").toURI();