From 1134f5af45456bf98a1b53ca811610812492163e Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sun, 12 Oct 2025 17:39:27 +0200 Subject: [PATCH 01/25] Migrate from Maven Archiver to standard `jar` tool by using the standard `java.util.spi.ToolProvider` API (requires Java 9+). Files in the `classes` directories are dispatched to the `--manifest` and `--release` options, which allow additional verifications by the `jar` tool. Derive a POM for each individual JAR file as the intersection of the project model and the module-info of the JAR file. Side effects: * Remove the default `**/package.html` exclude. * Automatic use of `META-INF/MANIFEST.MF` file found in `classes` directory. * Automatic Multi-Release always enabled, unless `detectMultiReleaseJar` is set to `false`. * In a multi-module project, use Java module names as artifact names. * Whether the `--date` option is supported depends on the Java version. * Re-run the `jar` tool with the --validate operation mode if validation was not implicit. --- pom.xml | 28 +- .../verify.bsh | 3 +- .../invoker.properties | 10 +- src/it/MJAR-292-detect-mjar/pom.xml | 4 +- src/it/MJAR-292-disable-detect-mjar/pom.xml | 1 + src/it/MJAR-30-include/verify.groovy | 2 +- src/it/MJAR-70-recreation/verify.bsh | 2 +- src/it/mjar-71-01/pom.xml | 4 +- .../some-manifest.mf} | 0 src/it/mjar-71-01/verify.groovy | 16 +- src/it/mjar-71-02/pom.xml | 2 +- src/it/mjar-71-02/verify.groovy | 16 +- src/it/multi-module/pom.xml | 67 ++ .../foo.bar.more/main/java/module-info.java | 19 + .../foo.bar.more/main/java/more/MainFile.java | 29 + .../src/foo.bar/main/java/foo/MainFile.java | 29 + .../src/foo.bar/main/java/module-info.java | 19 + src/it/multi-module/verify.groovy | 62 ++ src/it/multirelease-with-modules/pom.xml | 102 +++ .../foo.bar.more/main/java/module-info.java | 19 + .../foo.bar.more/main/java/more/MainFile.java | 29 + .../main/java/more/OtherFile.java | 29 + .../main/java_16/more/OtherFile.java | 30 + .../src/foo.bar/main/java/foo/MainFile.java | 29 + .../src/foo.bar/main/java/foo/OtherFile.java | 29 + .../foo.bar/main/java/foo/YetAnotherFile.java | 29 + .../src/foo.bar/main/java/module-info.java | 19 + .../foo.bar/main/java_16/foo/OtherFile.java | 34 + .../multirelease-with-modules/verify.groovy | 67 ++ .../maven/plugins/jar/AbstractJarMojo.java | 384 ++++++----- .../org/apache/maven/plugins/jar/Archive.java | 605 ++++++++++++++++++ .../maven/plugins/jar/DirectoryRole.java | 73 +++ .../maven/plugins/jar/FileCollector.java | 464 ++++++++++++++ .../org/apache/maven/plugins/jar/JarMojo.java | 11 + .../maven/plugins/jar/MetadataFiles.java | 250 ++++++++ .../maven/plugins/jar/PomDerivation.java | 519 +++++++++++++++ .../apache/maven/plugins/jar/Providers.java | 18 +- .../apache/maven/plugins/jar/TestJarMojo.java | 13 +- .../maven/plugins/jar/TimestampCheck.java | 237 +++++++ .../maven/plugins/jar/ToolExecutor.java | 474 ++++++++++++++ .../apache/maven/plugins/jar/JarMojoTest.java | 11 +- 41 files changed, 3552 insertions(+), 236 deletions(-) rename src/it/mjar-71-01/src/main/{resources/META-INF/MANIFEST.MF => my-custom-dir/some-manifest.mf} (100%) create mode 100644 src/it/multi-module/pom.xml create mode 100644 src/it/multi-module/src/foo.bar.more/main/java/module-info.java create mode 100644 src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java create mode 100644 src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java create mode 100644 src/it/multi-module/src/foo.bar/main/java/module-info.java create mode 100644 src/it/multi-module/verify.groovy create mode 100644 src/it/multirelease-with-modules/pom.xml create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java create mode 100644 src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java create mode 100644 src/it/multirelease-with-modules/verify.groovy create mode 100644 src/main/java/org/apache/maven/plugins/jar/Archive.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/FileCollector.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/PomDerivation.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java create mode 100644 src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java diff --git a/pom.xml b/pom.xml index c84c2485..a00bd420 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,26 @@ Apache Maven JAR Plugin Builds a Java Archive (JAR) file from the compiled project classes and resources. + + + evenisse + Emmanuel Venisse + evenisse@apache.org + + Java Developer + + + + desruisseaux + Martin Desruisseaux + desruisseaux@apache.org + Geomatys + + Java Developer + + +1 + + Jerome Lacoste @@ -133,9 +153,9 @@ provided - org.apache.maven.shared - file-management - ${mavenFileManagementVersion} + org.apache.maven + maven-support + ${mavenVersion} org.apache.maven.shared @@ -205,7 +225,7 @@ - src/it/mjar-71-01/src/main/resources/META-INF/MANIFEST.MF + src/it/mjar-71-01/src/main/my-custom-dir/some-manifest.mf src/it/mjar-71-02/src/main/resources/META-INF/MANIFEST.MF diff --git a/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh b/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh index 5b9d457f..7b39fb52 100644 --- a/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh +++ b/src/it/MJAR-260-invalid-automatic-module-name/verify.bsh @@ -45,8 +45,7 @@ try String[] snippets = new String[] { "[INFO] BUILD FAILURE", "[ERROR] Failed to execute goal org.apache.maven.plugins:maven-jar-plugin", - "Caused by: org.apache.maven.api.plugin.MojoException: Error assembling JAR", - "Caused by: org.codehaus.plexus.archiver.jar.ManifestException: Invalid automatic module name: 'in-valid.name.with.new.keyword'" + "Caused by: org.apache.maven.api.plugin.MojoException: Invalid automatic module name: \"in-valid.name.with.new.keyword\"." }; System.out.println("\nVerifying log snippets..."); diff --git a/src/it/MJAR-275-reproducible-module-info/invoker.properties b/src/it/MJAR-275-reproducible-module-info/invoker.properties index 71eea457..452fbdb2 100644 --- a/src/it/MJAR-275-reproducible-module-info/invoker.properties +++ b/src/it/MJAR-275-reproducible-module-info/invoker.properties @@ -5,9 +5,9 @@ # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at -# +# # http://www.apache.org/licenses/LICENSE-2.0 -# +# # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @@ -15,7 +15,5 @@ # specific language governing permissions and limitations # under the License. -# NOTE: Requires Java 10+ to compile the module declaration for Java 9+, -# this is due that compiling the module declaration generates a -# module descriptor with the JDK version on it, making it unreproducible. -invoker.java.version = 10+ +# The --date option needed for reproducible build is available only since Java 19. +invoker.java.version = 19+ diff --git a/src/it/MJAR-292-detect-mjar/pom.xml b/src/it/MJAR-292-detect-mjar/pom.xml index 9c0afd52..d473e74c 100644 --- a/src/it/MJAR-292-detect-mjar/pom.xml +++ b/src/it/MJAR-292-detect-mjar/pom.xml @@ -86,8 +86,8 @@ false diff --git a/src/it/MJAR-292-disable-detect-mjar/pom.xml b/src/it/MJAR-292-disable-detect-mjar/pom.xml index 1cc61ea9..c868bdce 100644 --- a/src/it/MJAR-292-disable-detect-mjar/pom.xml +++ b/src/it/MJAR-292-disable-detect-mjar/pom.xml @@ -84,6 +84,7 @@ myproject.HelloWorld + false diff --git a/src/it/MJAR-30-include/verify.groovy b/src/it/MJAR-30-include/verify.groovy index 28d3e5f3..1b0b838c 100644 --- a/src/it/MJAR-30-include/verify.groovy +++ b/src/it/MJAR-30-include/verify.groovy @@ -67,7 +67,7 @@ try { String artifactName = artifactNames[i]; if ( !contents.contains( artifactName ) ) - { + { System.err.println( "Artifact[" + artifactName + "] not found in jar archive" ); return false; } diff --git a/src/it/MJAR-70-recreation/verify.bsh b/src/it/MJAR-70-recreation/verify.bsh index 29b80ca2..25da0f97 100644 --- a/src/it/MJAR-70-recreation/verify.bsh +++ b/src/it/MJAR-70-recreation/verify.bsh @@ -58,7 +58,7 @@ if ( buildLog.exists() ) { int jarPluginExecutions = 0; String[] lines = buildLogContent.split( "\n" ); for ( String line : lines ) { - if ( line.contains( "Building jar:" ) && line.contains( "MJAR-70-recreation-1.0-SNAPSHOT.jar" ) ) { + if ( line.contains( "Building JAR:" ) && line.contains( "MJAR-70-recreation-1.0-SNAPSHOT.jar" ) ) { jarPluginExecutions++; System.out.println( "Found JAR creation: " + line ); } diff --git a/src/it/mjar-71-01/pom.xml b/src/it/mjar-71-01/pom.xml index cb6b3195..d5b6de07 100644 --- a/src/it/mjar-71-01/pom.xml +++ b/src/it/mjar-71-01/pom.xml @@ -25,7 +25,7 @@ under the License. 1.0 jar it-mjar-71 - Test that the default manifest is added by default if found under target/classes. Can also be overriden. + Test that the specified manifest is used. http://maven.apache.org @@ -36,7 +36,7 @@ under the License. @project.version@ - src/main/resources/META-INF/MANIFEST.MF + src/main/my-custom-dir/some-manifest.mf diff --git a/src/it/mjar-71-01/src/main/resources/META-INF/MANIFEST.MF b/src/it/mjar-71-01/src/main/my-custom-dir/some-manifest.mf similarity index 100% rename from src/it/mjar-71-01/src/main/resources/META-INF/MANIFEST.MF rename to src/it/mjar-71-01/src/main/my-custom-dir/some-manifest.mf diff --git a/src/it/mjar-71-01/verify.groovy b/src/it/mjar-71-01/verify.groovy index 97f78e88..60faf65e 100644 --- a/src/it/mjar-71-01/verify.groovy +++ b/src/it/mjar-71-01/verify.groovy @@ -51,14 +51,14 @@ try // Only compare files if ( entry.getName().equals( "META-INF/MANIFEST.MF" ) ) { - String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); - int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); - if ( index <= 0 ) - { - System.err.println( "MANIFEST doesn't contain: 'Archiver-Version: foobar-1.23456'" ); - return false; - } - return true; + String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); + int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); + if ( index <= 0 ) + { + System.err.println( "MANIFEST doesn't contain: 'Archiver-Version: foobar-1.23456'" ); + return false; + } + return true; } } } diff --git a/src/it/mjar-71-02/pom.xml b/src/it/mjar-71-02/pom.xml index fa3adac7..3fd9b506 100644 --- a/src/it/mjar-71-02/pom.xml +++ b/src/it/mjar-71-02/pom.xml @@ -25,7 +25,7 @@ under the License. 1.0 jar it-mjar-71-02 - Test that the default manifest is not added when found under target/classes but support is disabled. + Test that the manifest found under target/classes is automatically used. http://maven.apache.org diff --git a/src/it/mjar-71-02/verify.groovy b/src/it/mjar-71-02/verify.groovy index 33e40531..ffddfcbf 100644 --- a/src/it/mjar-71-02/verify.groovy +++ b/src/it/mjar-71-02/verify.groovy @@ -51,14 +51,14 @@ try // Only compare files if ( entry.getName().equals ( "META-INF/MANIFEST.MF" ) ) { - String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); - int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); - if ( index > 0 ) - { - System.err.println( "MANIFEST contains: 'Archiver-Version: foobar-1.23456', but shouldn't" ); - return false; - } - return true; + String manifest = IOUtils.toString( jar.getInputStream ( entry ) ); + int index = manifest.indexOf( "Archiver-Version: foobar-1.23456" ); + if ( index <= 0 ) + { + System.err.println( "MANIFEST doesn't contain: 'Archiver-Version: foobar-1.23456'" ); + return false; + } + return true; } } } diff --git a/src/it/multi-module/pom.xml b/src/it/multi-module/pom.xml new file mode 100644 index 00000000..d4a781e9 --- /dev/null +++ b/src/it/multi-module/pom.xml @@ -0,0 +1,67 @@ + + + + 4.1.0 + + org.apache.maven.plugins + multi-module + 1.0-SNAPSHOT + jar + Multi-module + + + + + org.apache.maven.plugins + maven-compiler-plugin + 4.0.0-beta-3 + + + + + 17 + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + true + foo.bar/foo.MainFile + + + + + + + + foo.bar + src/foo.bar/main/java + + + foo.bar.more + src/foo.bar.more/main/java + + + + diff --git a/src/it/multi-module/src/foo.bar.more/main/java/module-info.java b/src/it/multi-module/src/foo.bar.more/main/java/module-info.java new file mode 100644 index 00000000..778a3a4a --- /dev/null +++ b/src/it/multi-module/src/foo.bar.more/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +module foo.bar.more {} diff --git a/src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java new file mode 100644 index 00000000..d64f30a7 --- /dev/null +++ b/src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile of more"); + } +} diff --git a/src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java b/src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java new file mode 100644 index 00000000..502f2780 --- /dev/null +++ b/src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile"); + } +} diff --git a/src/it/multi-module/src/foo.bar/main/java/module-info.java b/src/it/multi-module/src/foo.bar/main/java/module-info.java new file mode 100644 index 00000000..38f61c0e --- /dev/null +++ b/src/it/multi-module/src/foo.bar/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +module foo.bar {} diff --git a/src/it/multi-module/verify.groovy b/src/it/multi-module/verify.groovy new file mode 100644 index 00000000..df8756de --- /dev/null +++ b/src/it/multi-module/verify.groovy @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.io.*; +import java.util.*; +import java.util.jar.*; + +File target = new File(basedir, "target"); + +Set content = new HashSet<>(); +content.add("module-info.class") +content.add("foo/MainFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.properties") +verify(new File(target, "foo.bar-1.0-SNAPSHOT.jar"), content, "foo.MainFile") + +content.clear() +content.add("module-info.class") +content.add("more/MainFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multi-module/pom.properties") +verify(new File(target, "foo.bar.more-1.0-SNAPSHOT.jar"), content, null) + +void verify(File artifact, Set content, String mainClass) +{ + JarFile jar = new JarFile(artifact) + Enumeration jarEntries = jar.entries() + while (jarEntries.hasMoreElements()) + { + JarEntry entry = (JarEntry) jarEntries.nextElement() + if (!entry.isDirectory()) + { + String name = entry.getName() + assert content.remove(name) : "Missing entry: " + name + } + } + assert content.isEmpty() : "Unexpected entries: " + content + + Attributes attributes = jar.getManifest().getMainAttributes() + assert attributes.get(Attributes.Name.MULTI_RELEASE) == null + assert Objects.equals(mainClass, attributes.get(Attributes.Name.MAIN_CLASS)) + + jar.close(); +} diff --git a/src/it/multirelease-with-modules/pom.xml b/src/it/multirelease-with-modules/pom.xml new file mode 100644 index 00000000..5f02bbbe --- /dev/null +++ b/src/it/multirelease-with-modules/pom.xml @@ -0,0 +1,102 @@ + + + + 4.1.0 + + org.apache.maven.plugins + multirelease-with-modules + 1.0-SNAPSHOT + jar + Multirelease with modules + + + + + org.apache.maven.plugins + maven-compiler-plugin + 4.0.0-beta-3 + + + + + 17 + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + move + + run + + prepare-package + + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + true + true + foo.bar/foo.MainFile + + + + + + + + foo.bar + src/foo.bar/main/java + 15 + + + foo.bar + src/foo.bar/main/java_16 + 16 + + + foo.bar.more + src/foo.bar.more/main/java + 15 + + + foo.bar.more + src/foo.bar.more/main/java_16 + 16 + + + + diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java new file mode 100644 index 00000000..778a3a4a --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +module foo.bar.more {} diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java new file mode 100644 index 00000000..d64f30a7 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile of more"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java new file mode 100644 index 00000000..54e29b3c --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile of more"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java new file mode 100644 index 00000000..4b21485e --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package more; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile of more on Java 16"); + MainFile.main(args); // Verify that we have access to the base version. + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java new file mode 100644 index 00000000..502f2780 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java new file mode 100644 index 00000000..472210e1 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java new file mode 100644 index 00000000..ab5f9009 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class YetAnotherFile { + public static void main(String[] args) { + System.out.println("YetAnotherFile"); + } +} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java b/src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java new file mode 100644 index 00000000..38f61c0e --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +module foo.bar {} diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java b/src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java new file mode 100644 index 00000000..cbfa0b98 --- /dev/null +++ b/src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +/** + * Test {@code <Source>}. + * Another {@code <Source>}. + */ +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile on Java 16"); + MainFile.main(args); // Verify that we have access to the base version. + } + + static void requireJava16() { + System.out.println("Method available only on Java 16+"); + } +} diff --git a/src/it/multirelease-with-modules/verify.groovy b/src/it/multirelease-with-modules/verify.groovy new file mode 100644 index 00000000..d62d91f2 --- /dev/null +++ b/src/it/multirelease-with-modules/verify.groovy @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.io.*; +import java.util.*; +import java.util.jar.*; + +File target = new File(basedir, "target"); + +Set content = new HashSet<>(); +content.add("module-info.class") +content.add("foo/MainFile.class") +content.add("foo/OtherFile.class") +content.add("foo/YetAnotherFile.class") +content.add("META-INF/versions/16/foo/OtherFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.properties") +verify(new File(target, "foo.bar-1.0-SNAPSHOT.jar"), content, "foo.MainFile") + +content.clear() +content.add("module-info.class") +content.add("more/MainFile.class") +content.add("more/OtherFile.class") +content.add("META-INF/versions/16/more/OtherFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-with-modules/pom.properties") +verify(new File(target, "foo.bar.more-1.0-SNAPSHOT.jar"), content, null) + +void verify(File artifact, Set content, String mainClass) +{ + JarFile jar = new JarFile(artifact) + Enumeration jarEntries = jar.entries() + while (jarEntries.hasMoreElements()) + { + JarEntry entry = (JarEntry) jarEntries.nextElement() + if (!entry.isDirectory()) + { + String name = entry.getName() + assert content.remove(name) : "Missing entry: " + name + } + } + assert content.isEmpty() : "Unexpected entries: " + content + + Attributes attributes = jar.getManifest().getMainAttributes() + assert Objects.equals("true", attributes.get(Attributes.Name.MULTI_RELEASE)) + assert Objects.equals(mainClass, attributes.get(Attributes.Name.MAIN_CLASS)) + + jar.close(); +} diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 10e00afb..df93deff 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -18,44 +18,46 @@ */ package org.apache.maven.plugins.jar; -import java.io.File; +import javax.lang.model.SourceVersion; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.Arrays; +import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.jar.Attributes; -import java.util.stream.Stream; +import java.util.spi.ToolProvider; +import org.apache.maven.api.PathScope; import org.apache.maven.api.ProducedArtifact; import org.apache.maven.api.Project; import org.apache.maven.api.Session; +import org.apache.maven.api.Type; import org.apache.maven.api.di.Inject; import org.apache.maven.api.plugin.Log; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.plugin.annotations.Parameter; +import org.apache.maven.api.services.PathMatcherFactory; import org.apache.maven.api.services.ProjectManager; import org.apache.maven.shared.archiver.MavenArchiveConfiguration; import org.apache.maven.shared.archiver.MavenArchiver; -import org.apache.maven.shared.archiver.MavenArchiverException; -import org.apache.maven.shared.model.fileset.FileSet; -import org.apache.maven.shared.model.fileset.util.FileSetManager; -import org.codehaus.plexus.archiver.Archiver; -import org.codehaus.plexus.archiver.jar.JarArchiver; /** * Base class for creating a JAR file from project classes. * * @author Emmanuel Venisse + * @author Martin Desruisseaux */ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Mojo { - - private static final String[] DEFAULT_EXCLUDES = new String[] {"**/package.html"}; - - private static final String[] DEFAULT_INCLUDES = new String[] {"**/**"}; - - private static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; + /** + * Identifier of the tool to use. This identifier shall match the identifier of a tool + * registered as a {@link ToolProvider}. By default, the {@code "jar"} tool is used. + * + * @since 4.0.0-beta-2 + */ + @Parameter(defaultValue = "jar", required = true) + protected String toolId; /** * List of files to include. Specified as fileset patterns which are relative to the input directory whose contents @@ -72,23 +74,20 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj protected String[] excludes; /** - * Directory containing the generated JAR. + * Directory containing the generated JAR files. */ @Parameter(defaultValue = "${project.build.directory}", required = true) protected Path outputDirectory; /** - * Name of the generated JAR. + * Name of the generated JAR file. + * The default value is {@code "${project.build.finalName}"}, + * which itself defaults to {@code "${artifactId}-${version}"}. + * Ignored if the Maven sub-project to archive uses module hierarchy. */ @Parameter(defaultValue = "${project.build.finalName}", readonly = true) protected String finalName; - /** - * The JAR archiver. - */ - @Inject - protected Map archivers; - /** * The Maven project. */ @@ -108,13 +107,22 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj @Parameter protected MavenArchiveConfiguration archive = new MavenArchiveConfiguration(); + /** + * The service to use for attaching the artifacts produced by this plugin. + */ @Inject protected ProjectManager projectManager; /** - * Require the jar plugin to build a new JAR even if none of the contents appear to have changed. - * By default, this plugin looks to see if the output JAR exists and inputs have not changed. - * If these conditions are true, the plugin skips creation of the JAR file. + * The service to use for creating include and exclude filters. + */ + @Inject + private PathMatcherFactory matcherFactory; + + /** + * Require the jar plugin to build new JAR files even if none of the contents appear to have changed. + * By default, this plugin looks to see if the output JAR files exist and inputs have not changed. + * If these conditions are true, the plugin skips creation of the JAR files. * This does not work when other plugins, like the maven-shade-plugin, are configured to post-process the JAR. * This plugin can not detect the post-processing, and so leaves the post-processed JAR file in place. * This can lead to failures when those plugins do not expect to find their own output as an input. @@ -136,8 +144,11 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * Timestamp for reproducible output archive entries. * This is either formatted as ISO 8601 extended offset date-time * (e.g. in UTC such as '2011-12-03T10:15:30Z' or with an offset '2019-10-05T20:37:42+06:00'), - * or as an integer representing seconds since the epoch - * (like SOURCE_DATE_EPOCH). + * or as an integer representing seconds since the Java epoch (January 1st, 1970). + * If not configured or disabled, + * the SOURCE_DATE_EPOCH + * environment variable is used as a fallback value, + * to ease forcing Reproducible Build externally when the build has not enabled it natively in POM. * * @since 3.2.0 */ @@ -145,9 +156,12 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj protected String outputTimestamp; /** - * Whether to detect multi-release JAR files. - * If the JAR contains the {@code META-INF/versions} directory it will be detected as a multi-release JAR file - * ("MRJAR"), adding the {@code Multi-Release: true} attribute to the main section of the JAR {@code MANIFEST.MF}. + * Whether to detect multi-release JAR files. + * If the JAR contains the {@code META-INF/versions} directory it will be detected as a multi-release JAR file, + * adding the {@code Multi-Release: true} attribute to the main section of the JAR {@code MANIFEST.MF} entry. + * In addition, the class files in {@code META-INF/versions} will be checked for API compatibility + * with the class files in the base version. If this flag is {@code false}, then the {@code META-INF/versions} + * directories are included without processing. * * @since 3.4.0 */ @@ -166,7 +180,7 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj protected AbstractJarMojo() {} /** - * Specifies whether to attach the jar to the project. + * Specifies whether to attach the JAR file(s) to the project. * * @since 4.0.0-beta-2 */ @@ -179,23 +193,7 @@ protected AbstractJarMojo() {} protected abstract Path getClassesDirectory(); /** - * Return the {@linkplain #project Maven project}. - * - * @return the Maven project - */ - protected final Project getProject() { - return project; - } - - /** - * {@return the MOJO logger} - */ - protected final Log getLog() { - return log; - } - - /** - * {@return the classifier of the JAR file to produce} + * {@return the classifier of the JAR file(s) to produce} * This is usually null or empty for the main artifact, or {@code "tests"} for the JAR file of test code. */ protected abstract String getClassifier(); @@ -207,167 +205,199 @@ protected final Log getLog() { protected abstract String getType(); /** - * Returns the JAR file to generate, based on an optional classifier. + * {@return the scope of dependencies} + * It should be {@link PathScope#MAIN_COMPILE} or {@link PathScope#TEST_COMPILE}. + * Note that we use compile scope rather than runtime scope because dependencies + * cannot appear in {@code requires} statement if they didn't had compile scope. + */ + protected abstract PathScope getDependencyScope(); + + /** + * {@return the JAR tool to use for archiving the code} * - * @param basedir the output directory - * @param resultFinalName the name of the JAR file - * @param classifier an optional classifier - * @return the file to generate + * @throws MojoException if no JAR tool was found + * + * @since 4.0.0-beta-2 */ - protected Path getJarFile(Path basedir, String resultFinalName, String classifier) { - Objects.requireNonNull(basedir, "basedir is not allowed to be null"); - Objects.requireNonNull(resultFinalName, "finalName is not allowed to be null"); - String fileName = resultFinalName + (hasClassifier(classifier) ? '-' + classifier : "") + ".jar"; - return basedir.resolve(fileName); + protected ToolProvider getJarTool() throws MojoException { + return ToolProvider.findFirst(toolId).orElseThrow(() -> new MojoException("No such \"" + toolId + "\" tool.")); } /** - * Generates the JAR. + * Returns whether the specified Java version is supported. * - * @return the path to the created archive file - * @throws MojoException in case of an error + * @param release name of an {@link SourceVersion} enumeration constant + * @return whether the current environment support that version */ - public Path createArchive() throws MojoException { - Path basedir = outputDirectory != null - ? outputDirectory - : Path.of(project.getBuild().getDirectory()); - String resultFinalName = - finalName != null ? finalName : project.getBuild().getFinalName(); - Path jarFile = getJarFile(basedir, resultFinalName, getClassifier()); - - FileSetManager fileSetManager = new FileSetManager(); - FileSet jarContentFileSet = new FileSet(); - jarContentFileSet.setDirectory(getClassesDirectory().toAbsolutePath().toString()); - jarContentFileSet.setIncludes(Arrays.asList(getIncludes())); - jarContentFileSet.setExcludes(Arrays.asList(getExcludes())); - - String[] includedFiles = fileSetManager.getIncludedFiles(jarContentFileSet); - - if (detectMultiReleaseJar - && Arrays.stream(includedFiles) - .anyMatch( - p -> p.startsWith("META-INF" + File.separatorChar + "versions" + File.separatorChar))) { - getLog().debug("Adding 'Multi-Release: true' manifest entry."); - archive.addManifestEntry(Attributes.Name.MULTI_RELEASE.toString(), "true"); + private static boolean isSupported(String release) { + try { + return SourceVersion.latestSupported().compareTo(SourceVersion.valueOf(release)) >= 0; + } catch (IllegalArgumentException e) { + return false; } + } - // May give false positives if the files is named as module descriptor - // but is not in the root of the archive or in the versioned area - // (and hence not actually a module descriptor). - // That is fine since the modular Jar archiver will gracefully - // handle such case. - // And also such case is unlikely to happen as file ending - // with "module-info.class" is unlikely to be included in Jar file - // unless it is a module descriptor. - boolean containsModuleDescriptor = - Arrays.stream(includedFiles).anyMatch(p -> p.endsWith(MODULE_DESCRIPTOR_FILE_NAME)); - - String archiverName = containsModuleDescriptor ? "mjar" : "jar"; + /** + * Returns the output time stamp or, as a fallback, the {@code SOURCE_DATE_EPOCH} environment variable. + * If the time stamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is. + * + * @return the time stamp in presumed ISO 8601 format, or {@code null} if none + * + * @since 4.0.0-beta-2 + */ + protected String getOutputTimestamp() { + String time = nullIfAbsent(outputTimestamp); + if (time == null) { + time = nullIfAbsent(System.getenv("SOURCE_DATE_EPOCH")); + if (time == null) { + return null; + } + } + if (!isSupported("RELEASE_19")) { + log.warn("Reproducible build requires Java 19 or later."); + return null; + } + for (int i = time.length(); --i >= 0; ) { + char c = time.charAt(i); + if ((c < '0' || c > '9') && (i != 0 || c != '-')) { + return time; + } + } + return Instant.ofEpochSecond(Long.parseLong(time)).toString(); + } - MavenArchiver archiver = new MavenArchiver(); - archiver.setCreatedBy("Maven JAR Plugin", "org.apache.maven.plugins", "maven-jar-plugin"); - archiver.setBuildJdkSpecDefaultEntry(archive.getManifest().isAddBuildEnvironmentEntries()); - archiver.setArchiver((JarArchiver) archivers.get(archiverName)); - archiver.setOutputFile(jarFile.toFile()); + /** + * {@return the patterns of files to include, or an empty list if no include pattern was specified} + */ + protected List getIncludes() { + return asList(includes); + } - // configure for Reproducible Builds based on outputTimestamp value - archiver.configureReproducibleBuild(outputTimestamp); + /** + * {@return the patterns of files to exclude, or an empty list if no exclude pattern was specified} + */ + protected List getExcludes() { + return asList(excludes); + } - archive.setForced(forceCreation); + /** + * Returns the given elements as a list if non-null. + * + * @param elements the elements, or {@code null} + * @return the elements as a list, or {@code null} if the given array was null + */ + private static List asList(String[] elements) { + return (elements == null) ? List.of() : Arrays.asList(elements); + } - try { - Path contentDirectory = getClassesDirectory(); - if (!Files.exists(contentDirectory)) { - if (!forceCreation) { - getLog().warn("JAR will be empty - no content was marked for inclusion!"); - } - } else { - archiver.getArchiver().addDirectory(contentDirectory.toFile(), getIncludes(), getExcludes()); + /** + * Generates the JAR files. + * Map keys are module names or {@code null} if the project does not use module hierarchy. + * Values are (type, path) pairs associated with each module where + * type is {@code "pom"}, {@code "jar"} or {@code "test-jar"} and path + * is the path to the POM or JAR file. + * + *

Note that a null key does not necessarily means that the JAR is not modular. + * It only means that the project was not compiled with module hierarchy, + * i.e. {@code target/classes/} subdirectories having module names. + * A project can be compiled with package hierarchy and still be modular.

+ * + * @return the paths to the created archive files + * @throws IOException if an error occurred while walking the file tree + * @throws MojoException if an error occurred while writing a JAR file + */ + public Map> createArchives() throws IOException, MojoException { + final Path classesDirectory = getClassesDirectory(); + final boolean notExists = Files.notExists(classesDirectory); + if (notExists) { + if (forceCreation) { + log.warn("No JAR created because no content was marked for inclusion."); + } + if (skipIfEmpty) { + log.info(String.format("Skipping packaging of the %s.", getType())); + return Map.of(); } - - archiver.createArchive(session, project, archive); - - return jarFile; - } catch (Exception e) { - // TODO: improve error handling - throw new MojoException("Error assembling JAR", e); } + archive.setForced(forceCreation); + // TODO: we want a null manifest if there is no configuration. + final var archiver = new MavenArchiver(); + archiver.setCreatedBy("Maven JAR Plugin", "org.apache.maven.plugins", "maven-jar-plugin"); + archiver.setBuildJdkSpecDefaultEntry(archive.getManifest().isAddBuildEnvironmentEntries()); + var executor = new ToolExecutor(this, archiver.getManifest(session, project, archive), archive); + var files = new FileCollector(this, executor, classesDirectory, matcherFactory); + if (!notExists) { + Files.walkFileTree(classesDirectory, files); + } + files.prune(skipIfEmpty); + List moduleRoots = files.getModuleHierarchyRoots(); + if (!moduleRoots.isEmpty()) { + executor.pomDerivation = new PomDerivation(this, moduleRoots); + } + return executor.writeAllJARs(files); } /** - * Generates the JAR. + * Generates the JAR file, then attaches the artifact. * * @throws MojoException in case of an error */ @Override + @SuppressWarnings("UseSpecificCatch") public void execute() throws MojoException { - if (skipIfEmpty && isEmpty(getClassesDirectory())) { - getLog().info(String.format("Skipping packaging of the %s.", getType())); - } else { - Path jarFile = createArchive(); - - if (attach) { - ProducedArtifact artifact; - String classifier = getClassifier(); - if (hasClassifier(classifier)) { - artifact = session.createProducedArtifact( - project.getGroupId(), - project.getArtifactId(), - project.getVersion(), - classifier, - null, - getType()); - } else { - if (projectHasAlreadySetAnArtifact()) { - throw new MojoException("You have to use a classifier " - + "to attach supplemental artifacts to the project instead of replacing them."); + final Map> artifactFiles; + try { + artifactFiles = createArchives(); + } catch (MojoException e) { + throw e; + } catch (Exception e) { + throw new MojoException("Error while assembling the JAR file.", e); + } + if (artifactFiles.isEmpty()) { + // Message already logged by `createArchives()`. + return; + } + if (attach) { + final String classifier = nullIfAbsent(getClassifier()); + for (Map.Entry> entry : artifactFiles.entrySet()) { + String moduleName = entry.getKey(); + for (Map.Entry path : entry.getValue().entrySet()) { + String type = path.getKey(); + ProducedArtifact artifact; + if (moduleName == null && classifier == null && Type.JAR.equals(type)) { + if (projectHasAlreadySetAnArtifact()) { + throw new MojoException("You have to use a classifier " + + "to attach supplemental artifacts to the project instead of replacing them."); + } + artifact = project.getMainArtifact().orElseThrow(); + } else { + artifact = session.createProducedArtifact( + project.getGroupId(), + (moduleName != null) ? moduleName : project.getArtifactId(), + project.getVersion(), + classifier, + null, + type); } - artifact = project.getMainArtifact().get(); + projectManager.attachArtifact(project, artifact, path.getValue()); } - projectManager.attachArtifact(project, artifact, jarFile); - } else { - getLog().debug("Skipping attachment of the " + getType() + " artifact to the project."); } + } else { + log.debug("Skipping attachment of the " + getType() + " artifact to the project."); } } - private static boolean isEmpty(Path directory) { - if (!Files.isDirectory(directory)) { - return true; - } - try (Stream children = Files.list(directory)) { - return children.findAny().isEmpty(); - } catch (IOException e) { - throw new MavenArchiverException("Unable to access directory", e); - } - } - + /** + * Verifies whether the main artifact is already set. + * This verification does not apply for module hierarchy, where more than one artifact is produced. + */ private boolean projectHasAlreadySetAnArtifact() { - Path path = projectManager.getPath(project).orElse(null); - return path != null && Files.isRegularFile(path); + return projectManager.getPath(project).filter(Files::isRegularFile).isPresent(); } /** - * Return {@code true} if the classifier is not {@code null} and contains something other than white spaces. - * - * @param classifier the classifier to verify - * @return {@code true} if the classifier is set + * Returns the given value if non-null, non-empty and non-blank, or {@code null} otherwise. */ - private static boolean hasClassifier(String classifier) { - return classifier != null && !classifier.isBlank(); - } - - private String[] getIncludes() { - if (includes != null && includes.length > 0) { - return includes; - } - return DEFAULT_INCLUDES; - } - - private String[] getExcludes() { - if (excludes != null && excludes.length > 0) { - return excludes; - } - return DEFAULT_EXCLUDES; + static String nullIfAbsent(String value) { + return (value == null || value.isBlank()) ? null : value; } } diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java new file mode 100644 index 00000000..c17812ef --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -0,0 +1,605 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.TreeMap; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.apache.maven.api.Type; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.plugin.MojoException; + +/** + * Files or root directories to archive for a single module. + * A single instance of {@code Archive} may contain many directories for different target Java releases. + * Many instances of {@code Archive} may exist when archiving a multi-modules project. + */ +final class Archive { + /** + * Path to the POM file generated for this archive, or {@code null} if none. + * This is non-null only if module source hierarchy is used, in which case the dependencies + * declared in this file are the intersection of the project dependencies and the content of + * the {@code module-info.class} file. + */ + @Nullable + Path pomFile; + + /** + * The JAR file to create. May be an existing file, + * in which case the file creation may be skipped if the file is still up-to-date. + */ + @Nonnull + final Path jarFile; + + /** + * A helper class for checking whether an existing JAR file is still up-to-date. + * This is null if there is no existing JAR file, or if we determined that the file is outdated. + */ + private TimestampCheck existingJAR; + + /** + * Name of the module being archived when the project is using module hierarchy. + * This is {@code null} if the project is using package hierarchy, either because it is a classical + * class-path project or because it is a single module compiled without using the module hierarchy. + * When using module source hierarchy, {@code javac} guarantees that the module name in the output + * directory is the name of the parent directory of {@code module-info.class}. + */ + @Nullable + final String moduleName; + + /** + * Path to {@code META-INF/MANIFEST.MF}, or {@code null} if none. The manifest file + * should be included by the {@code --manifest} option instead of as an ordinary file. + */ + @Nullable + private Path manifest; + + /** + * The Maven generated {@code pom.xml} and {@code pom.properties} files, or {@code null} if none. + * This first item shall be the base directory where the files are located. + */ + @Nullable + List mavenFiles; + + /** + * Fully-qualified name of the main class, or {@code null} if none. + * This is the value to provide to the {@code --main-class} option. + */ + private String mainClass; + + /** + * Files or root directories to store in the JAR file for each target Java release + * other than the base release. Keys are the target Java release with {@code null} for the base + * release. + */ + @Nonnull + private final NavigableMap filesetForRelease; + + /** + * Files or root directories to archive for a single target Java release of a single module. + * The {@link Archive} enclosing shall contain at least one instance of {@code FileSet} for + * the base release, and an arbitrary amount of other instances for other target releases. + */ + final class FileSet { + /** + * The root directory of all files or directories to archive. + * This is the value to pass to the {@code -C} tool option. + */ + @Nonnull + final Path directory; + + /** + * The files or directories to include in the JAR file. + * May be absolute paths or paths relative to {@link #directory}. + */ + @Nonnull + final List files; + + /** + * Creates an initially empty set of files or directories for a specific target Java release. + * + * @param directory the base directory of the files or directories to archive + */ + private FileSet(Path directory) { + this.directory = directory; + this.files = new ArrayList<>(); + } + + /** + * Discards all files in this file set, normally because those files are not in any module. + * This method returns a common parent directory for all the files that were discarded. + * The caller should use that common directory for logging a warning message. + * + * @param base base directory found by previous invocations of this method, or {@code null} if none + * @return common directory of discarded files + */ + private Path discardAllFiles(Path base) { + for (Path file : files) { + file = directory.resolve(file); + if (base == null) { + base = file.getParent(); + } else { + while (!file.startsWith(base)) { + base = base.getParent(); + if (base == null) { + break; + } + } + } + } + files.clear(); + return base; + } + + /** + * Adds the given path to the list of files or directories to archive. + * This method may store a relative path instead of the absolute path. + * + * @param item a file or directory to archive + * @param attributes the file's basic attributes + * @param isDirectory whether the file is a directory + * @throws IllegalArgumentException if the given path cannot be made relative to the base directory + */ + void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { + TimestampCheck tc = existingJAR; + if (tc != null && tc.isUpdated(item, attributes, isDirectory)) { + existingJAR = null; // Signal that the existing file is outdated. + } + if (files.isEmpty()) { + /* + * In our tests, it seems that the first file after the "-C" option needs to be relative + * to the directory given to "-C" and all other files need to be absolute. This behavior + * does not seem to be documented, but we couldn't get the "jar" tool to work otherwise + * (except by repeating "-C" before each file). + */ + item = directory.relativize(item); + } + files.add(item); + } + + /** + * Adds to the given list the arguments to provide to the "jar" tool for this version. + * Elements added to the list shall be instances of {@link String} or {@link Path}. + * + * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances + * @param version the target Java release, or {@code null} for the base version of the JAR file + */ + private void arguments(List addTo, Runtime.Version version) { + if (!files.isEmpty()) { + if (version != null) { + addTo.add("--release"); + addTo.add(version); + } + addTo.add("-C"); + addTo.add(directory); + addTo.addAll(files); + } + } + + /** + * {@return a string representation for debugging purposes} + */ + @Override + public String toString() { + return getClass().getSimpleName() + '[' + directory.getFileName() + ": " + files.size() + " files]"; + } + } + + /** + * Creates an initially empty set of files or directories. + * + * @param jarFile path to the JAR file to create + * @param moduleName the module name if using module hierarchy, or {@code null} if using package hierarchy + * @param directory the directory of the classes targeting the base Java release + * @param forceCreation whether to force a new JAR file even if the content seems unchanged + * @param logger where to send a warning if an error occurred while checking an existing JAR file + */ + @SuppressWarnings("checkstyle:NeedBraces") + Archive(Path jarFile, String moduleName, Path directory, boolean forceCreation, Log logger) { + this.jarFile = jarFile; + this.moduleName = moduleName; + filesetForRelease = new TreeMap<>((v1, v2) -> { + if (v1 == v2) return 0; + if (v1 == null) return -1; + if (v2 == null) return +1; + return v1.compareTo(v2); + }); + filesetForRelease.put(null, new FileSet(directory)); + if (!forceCreation && Files.isRegularFile(jarFile)) { + try { + existingJAR = new TimestampCheck(jarFile, directory, logger); + } catch (IOException e) { + // Ignore, we will regenerate the JAR file. + logger.warn(e); + } + } + } + + /** + * {@return the files or directories to store in the JAR file for targeting the base Java release} + * + * @throws NoSuchElementException should not happen unless {@link #prune(boolean)} has been invoked + */ + FileSet baseRelease() { + return filesetForRelease.firstEntry().getValue(); + } + + /** + * Returns the {@code module-info.class} files. Conceptually, there is at most once such file per module. + * However, more than one file may exist if additional files are provided for additional Java releases. + * This method returns only the files that exist. + * + * @return all {@code module-info.class} files found for all target Java releases + */ + public List moduleInfoFiles() { + var files = new ArrayList(); + filesetForRelease.values().forEach((release) -> { + Path file = release.directory.resolve(FileCollector.MODULE_DESCRIPTOR_FILE_NAME); + if (Files.isRegularFile(file)) { + files.add(file); + } + }); + return files; + } + + /** + * Discards all files in this archive, normally because those files are not in any module. + * This method returns a common parent directory for all the files that were discarded. + * The caller should use that common directory for logging a warning message. + * + * @return common directory of discarded files, or {@code null} if none + */ + Path discardAllFiles() { + Path base = null; + for (FileSet release : filesetForRelease.values()) { + base = release.discardAllFiles(base); + } + filesetForRelease.clear(); + return base; + } + + /** + * Removes all empty file sets and ensures that the lowest version is declared as the base version. + * This method should be invoked after all output directories to archive have been fully scanned. + * If {@code skipIfEmpty} is {@code false}, then this method ensures that at least one file set + * remains even if that file set is empty. + * + * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} + */ + public void prune(final boolean skipIfEmpty) { + FileSet keep = (skipIfEmpty || isEmpty()) + ? null + : filesetForRelease.firstEntry().getValue(); + filesetForRelease.values().removeIf((fs) -> fs.files.isEmpty()); + Iterator> it = + filesetForRelease.entrySet().iterator(); + if (it.hasNext()) { + Map.Entry first = it.next(); + if (first.getKey() == null) { + return; // Already contains an entry for the base version, nothing to do. + } + keep = first.getValue(); + it.remove(); + } + if (keep != null) { + filesetForRelease.put(null, keep); + } + } + + /** + * {@return whether this archive has nothing to archive} + * Note that this method may return {@code false} even when there is zero file to archive. + * It may happen if {@link AbstractJarMojo#skipIfEmpty} is {@code false}. In such case, the + * "empty" JAR file will still contain at {@code META-INF/MANIFEST.MF} file. + * + *

Prerequisites

+ * The {@link #prune(boolean)} method should be invoked before this method for accurate result. + */ + public boolean isEmpty() { + return filesetForRelease.isEmpty(); + } + + /** + * Checks whether the JAR file already exists and can be reused. + * This method verifies that the JAR file contains all the files to archive, + * contains no extra file, and no file to archive is newer than the JAR file. + * + *

This method can be invoked only once. + * If invoked more often, it returns {@code false} on all subsequent invocations.

+ * + * @return whether the JAR file already exists and can be reused + */ + public boolean isUpToDateJAR() { + final TimestampCheck tc = existingJAR; + if (tc == null) { + return false; + } + existingJAR = null; // Let GC do its job. + return tc.isUpToDateJAR(filesetForRelease.values()); + } + + /** + * Returns an initially empty set of files or directories for the specified target Java release. + * + * @param directory the base directory of the files to archive + * @param version the target Java release, or {@code null} for the base version + * @return container where to declare files and directories to archive + */ + FileSet newTargetRelease(Path directory, Runtime.Version version) { + return filesetForRelease.computeIfAbsent(version, (key) -> new FileSet(directory)); + } + + /** + * Sets the {@code --main-class} option to the value of the {@code Main-Class} entry of the given manifest. + * As an extension, this method accepts the {@code module/classname} syntax (a syntax already used in some + * Java tools). If a module is specified, the main class is kept only if the module match. The intent is to + * allow users to specify on which module the main class applies when they use plugin configuration. + * + * @param content combination of existing {@code MANIFEST.MF} and manifest inferred from configuration, or null + * @return whether the given manifest has been modified by this method + */ + boolean setMainClass(Manifest content) { + if (content == null || mainClass != null) { + return false; + } + // We need to remove the attribute, otherwise it will conflict with `--main-class`. + mainClass = (String) content.getMainAttributes().remove(Attributes.Name.MAIN_CLASS); + if (mainClass != null) { + int s = mainClass.indexOf('/'); + if (s >= 0) { + if (mainClass.substring(0, s).strip().equals(moduleName)) { + mainClass = mainClass.substring(s + 1).strip(); + } else { + mainClass = null; // Main class is defined for another module. + } + } + } + return mainClass != null; + } + + /** + * Sets the {@code --manifest} option to the given value if that option was not already set. + * + * @param file path to the manifest file + * @param force whether to set the manifest even if already set + * @return whether the manifest has been set + */ + boolean setManifest(Path file, boolean force) { + if (manifest == null || force) { + manifest = file; + return true; + } + return false; + } + + /** + * Merges the manifest of this module with the manifest specified in plugin configuration. + * If both {@code file} and {@code content} are non-null, then {@code content} must be the + * result of reading {@code file}. + * + *

This method never modifies the given {@code content} object. If manifest are merged, + * a new {@link Manifest} instance is created. Therefore, caller can check whether this + * method returned a new instance as a way to recognize that a merge occurred.

+ * + *

If a merge occurs, the content specified to {@link #setManifest(Path)} has precedence. + * It should be the {@code target/classes/META-INF/MANIFEST.MF} file (or modular equivalent).

+ * + * @param file an additional manifest file, or {@code null} + * @param content the content of {@code file}, or a standalone manifest produced at runtime + * @return the merged manifest as a new instance if some changes were necessary + * @throws IOException if an error occurred while reading a manifest file + */ + Manifest mergeManifest(Path file, Manifest content) throws IOException { + if (manifest == null) { + manifest = file; + } else if (file != null && Files.isSameFile(file, manifest)) { + // Nothing to merge because of the constraint that `content` must be the content of `file`. + } else { + try (InputStream in = Files.newInputStream(manifest)) { + // No need to wrap in `BufferedInputStream`. + if (content != null) { + content = new Manifest(content); + content.read(in); + } else { + content = new Manifest(in); + } + } + } + return content; + } + + /** + * Adds to the given list the arguments to provide to the "jar" tool for each version. + * Elements added to the list shall be instances of {@link String} or {@link Path}. + * Callers should have added the following options (if applicable) before to invoke this method: + * + *
    + *
  • {@code --create}
  • + *
  • {@code --no-compress}
  • + *
  • {@code --date} followed by the output time stamp
  • + *
  • {@code --module-version} followed by module version
  • + *
  • {@code --hash-modules} followed by patters of module names
  • + *
  • {@code --module-path} followed by module path
  • + *
+ * + * This method adds the following options: + * + *
    + *
  • {@code --file} followed by the path to the JAR file
  • + *
  • {@code --manifest} followed by path to the manifest file
  • + *
  • {@code --main-class} followed by fully qualified name class
  • + *
  • {@code --release} followed by Java target release
  • + *
  • {@code -C} followed by directory
  • + *
  • files or directories to archive
  • + *
+ * + * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances + */ + void arguments(final List addTo) { + addTo.add("--file"); + addTo.add(jarFile); + if (manifest != null) { + addTo.add("--manifest"); + addTo.add(manifest); + } + if (mainClass != null) { + addTo.add("--main-class"); + addTo.add(mainClass); + } + if (mavenFiles != null) { + addTo.add("-C"); + addTo.addAll(mavenFiles); + } + for (Map.Entry entry : filesetForRelease.entrySet()) { + entry.getValue().arguments(addTo, entry.getKey()); + } + } + + /** + * Adds to the given list the arguments to provide to the "jar" tool for validating the JAR file. + * The file is validated only if the validation was not done implicitly at JAR creation time. + * This is the case if no {@code --release} option was used. + * This method adds the following options: + * + *
    + *
  • {@code --validate} operation mode
  • + *
  • {@code --file} followed by the path to the JAR file
  • + *
+ * + * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances + * @return whether a validation should be run + */ + boolean validate(final List addTo) { + if (filesetForRelease.values().stream().allMatch(Objects::isNull)) { + // At least one --release option was used. Validation was implicit. + return false; + } + addTo.add("--validate"); + addTo.add("--file"); + addTo.add(jarFile); + return true; + } + + /** + * Dumps the tool options together with the list of files into a debug file. + * This is invoked in case of compilation failure, or if debug is enabled. + * The arguments can be separated by spaces or by new line characters. + * File name should be between double quotation marks. + * + * @param baseDir project base directory for relativizing the arguments + * @param debugDirectory the directory where to write the debug file + * @param classifier the classifier (e.g. "tests"), or {@code null} if none + * @param arguments the arguments formatted by {@link #arguments(List)} + * @return the debug file where arguments have been written + * @throws IOException if an error occurred while writing the debug file + */ + Path writeDebugFile(Path baseDir, Path debugDirectory, String classifier, List arguments) + throws IOException { + var filename = new StringBuilder("jar"); + if (moduleName != null) { + filename.append('-').append(moduleName); + } + if (classifier != null) { + filename.append('-').append(classifier); + } + Path debugFile = debugDirectory.resolve(filename.append(".args").toString()); + try (BufferedWriter out = Files.newBufferedWriter(debugFile)) { + boolean isNewLine = true; + for (Object argument : arguments) { + if (argument instanceof Path file) { + try { + file = baseDir.relativize(file); + } catch (IllegalArgumentException e) { + // Ignore, keep the absolute path. + } + if (!isNewLine) { + out.write(' '); + } + out.write('"'); + out.write(file.toString()); + out.write('"'); + out.newLine(); + isNewLine = true; + } else { + String option = argument.toString(); + if (!isNewLine) { + if (option.startsWith("--") || option.equals("-C")) { + out.newLine(); + } else { + out.write(' '); + } + } + out.write(option); + isNewLine = false; + } + } + } + return debugFile; + } + + /** + * Stores in the given map the paths to the artifacts produced for this archive. + * + * @param artifactType {@code "jar"} or {@code "test-jar"} + * @param addTo the map where to add the results + */ + void saveArtifactPaths(final String artifactType, final Map> addTo) { + final Map paths; + if (pomFile != null) { + paths = Map.of(artifactType, jarFile, Type.POM, pomFile); + } else { + paths = Map.of(artifactType, jarFile); + } + if (addTo.put(moduleName, paths) != null) { + // Should never happen, but check anyway. + throw new MojoException("Module archived twice: " + moduleName); + } + } + + /** + * {@return a string representation for debugging purposes} + */ + @Override + public String toString() { + var sb = new StringBuilder(getClass().getSimpleName()).append('['); + if (moduleName != null) { + sb.append('"').append(moduleName).append("\": "); + } + int count = filesetForRelease.values().stream() + .mapToInt((release) -> release.files.size()) + .sum(); + return sb.append(count).append(" files]").toString(); + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java b/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java new file mode 100644 index 00000000..ccfbb7b1 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +/** + * Directories that the archiver needs to handle in a special way. + */ +enum DirectoryRole { + /** + * The root directory. This is usually {@code "target/classes"}. + * The next locations can be {@link #META_INF}, {@link #NAMED_MODULE} or {@link #RESOURCES}. + */ + ROOT, + + /** + * The {@code "META-INF"} or {@code "/META-INF"} directory. + * This is part of the JAR specification. + * The next locations can be {@link #VERSIONS} or {@link #VERSIONS_MODULAR}. + */ + META_INF, + + /** + * The {@code "META-INF/versions"} or {@code "/META-INF/versions"} directory. + * This is part of the JAR specification, except the {@code } prefix. + * The sub-directories are named according Java releases such as "21". + * The next location can only be {@link #RESOURCES}. + */ + VERSIONS, + + /** + * The Maven-specific {@code "META-INF/versions-modular"} directory. + * Note that {@code "/META-INF/versions-modular"} is not forbidden, but does not make sense. + * The sub-directories are named according Java releases such as "21". + * The next location can only be {@link #MODULES}. + */ + VERSIONS_MODULAR, + + /** + * The Maven-specific {@code "META-INF/versions-modular"} directory. + * All sub-directories shall have the name of a Java module. + * The next location can only be {@link #NAMED_MODULE}. + */ + MODULES, + + /** + * The root of a single Java module in a module hierarchy. + * The name of this directory is the Java module name. + * The next location can only be {@link #RESOURCES}. + */ + NAMED_MODULE, + + /** + * The classes or other types of resources to include in a single archive. + * May also be other files in the {@code META-INF} directory. + */ + RESOURCES +} diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java new file mode 100644 index 00000000..c464b9e5 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -0,0 +1,464 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.services.PathMatcherFactory; + +/** + * Dispatch the files in the output directory into the JAR files to create. + * Instead of just archiving as-is the content of the output directory, this class separates + * the following subdirectories to the options listed below: + * + *
    + *
  • The {@code META-INF/MANIFEST.MF} file will be given to the {@code --manifest} option.
  • + *
  • Files in the following directories will be given to the {@code --release} option: + *
      + *
    • {@code META-INF/versions/}
    • + *
    • {@code META-INF/versions-modular//}
    • + *
    • {@code /META-INF/versions/}
    • + *
    + *
  • + *
+ * + * The reason for using the options is that they allow the {@code jar} tool to perform additional verifications. + * For example, when using the {@code --release} option, {@code jar} verifies the API compatibility. + */ +final class FileCollector extends SimpleFileVisitor { + /** + * The file to check for deciding whether the JAR is modular. + */ + static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; + + /** + * The {@value} directory. + * This is part of JAR file specification. + */ + private static final String VERSIONS = "versions"; + + /** + * The {@value} directory. + * This is Maven-specific. + */ + private static final String VERSIONS_MODULAR = "versions-modular"; + + /** + * Context (logger, configuration) in which the JAR file are created. + */ + private final ToolExecutor context; + + /** + * Whether to detect multi-release JAR files. + */ + private final boolean detectMultiReleaseJar; + + /** + * Combination of includes and excludes path matcher applied on files. + */ + @Nonnull + private final PathMatcher fileMatcher; + + /** + * Combination of includes and excludes path matcher applied on directories. + */ + @Nonnull + private final PathMatcher directoryMatcher; + + /** + * Whether the matchers accept all files. In such case, we can declare whole directories + * to the {@code jar} tool instead of scaning the directory tree ourselves. + */ + private final boolean acceptsAllFiles; + + /** + * Files found in the output directory when package hierarchy is used. + * At most one of {@code packageHierarchy} and {@link #moduleHierarchy} can be non-empty. + */ + @Nonnull + private final Archive packageHierarchy; + + /** + * Files found in the output directory when module hierarchy is used. Keys are module names. + * At most one of {@link #packageHierarchy} and {@code moduleHierarchy} can be non-empty. + */ + @Nonnull + private final Map moduleHierarchy; + + /** + * The current module being archived. This field is updated every times that {@code FileCollector} + * enters in a new module directory. + */ + @Nonnull + private Archive currentModule; + + /** + * The module and target Java release currently being scanned. This field is updated every times that + * {@code FileCollector} enters in a new module directory or in a new target Java release for a given module. + */ + @Nonnull + private Archive.FileSet currentFilesToArchive; + + /** + * The current target Java release, or {@code null} if none. + */ + @Nullable + private Runtime.Version currentTargetVersion; + + /** + * Identification of the kinds of directories being traversed. + * The length of this list is the depth in the directory hierarchy. + * The last element identifies the type of the current directory. + */ + private final Deque directoryRoles; + + /** + * Whether to check when a file is the {@code MANIFEST.MF} file. + * This is allowed only when scanning the content of a {@code META-INF} directory. + */ + private boolean checkForManifest; + + /** + * Creates a new file collector. + * + * @param mojo the MOJO from which to get the configuration + * @param context context (logger, configuration) in which the JAR file are created + * @param directory the base directory of the files to archive + */ + FileCollector(AbstractJarMojo mojo, ToolExecutor context, Path directory, PathMatcherFactory matcherFactory) { + this.context = context; + detectMultiReleaseJar = mojo.detectMultiReleaseJar; + directoryRoles = new ArrayDeque<>(); + fileMatcher = matcherFactory.createPathMatcher(directory, mojo.getIncludes(), mojo.getExcludes(), false); + directoryMatcher = matcherFactory.deriveDirectoryMatcher(fileMatcher); + acceptsAllFiles = matcherFactory.isIncludesAll(directoryMatcher) && matcherFactory.isIncludesAll(fileMatcher); + packageHierarchy = context.newArchive(null, directory); + moduleHierarchy = new LinkedHashMap<>(); + resetToPackageHierarchy(); + } + + /** + * Resets this {@code FileCollector} to the state where a package hierarchy is presumed. + */ + private void resetToPackageHierarchy() { + currentModule = packageHierarchy; + currentFilesToArchive = currentModule.baseRelease(); + } + + /** + * Declares that the given directory is the base directory of a module. + * For an output generated by {@code javac} from a module source hierarchy, + * the directory name is guaranteed to be the module name. + * + * @param directory a {@code ""} or {@code "META-INF/versions-modular/"} directory + */ + private void enterModuleDirectory(final Path directory) { + String moduleName = directory.getFileName().toString(); + currentModule = moduleHierarchy.computeIfAbsent(moduleName, (name) -> context.newArchive(name, directory)); + currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); + } + + /** + * Declares that the given directory is the base directory of a target Java version. + * The {@code useDirectly} argument tells whether the content of this directory will be specified directly + * as the content to add in the JAR file. This argument should be {@code false} when there is + * another directory level (the module names) to process before to add content. + * + * @param directory a {@code "META-INF/versions/"} or {@code "META-INF/versions-modular/"} directory + * @param useDirectly whether the directory is {@code "META-INF/versions/"} + * @return whether to skip the directory because of invalid version number + */ + private boolean enterVersionDirectory(final Path directory, final boolean useDirectly) { + try { + currentTargetVersion = Runtime.Version.parse(directory.getFileName().toString()); + } catch (IllegalArgumentException e) { + context.warnInvalidVersion(directory, e); + return true; + } + if (useDirectly) { + currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); + } + return false; + } + + /** + * Determines if the given directory should be scanned for files to archive. + * This method may also update {@link #currentFilesToArchive} if it detects + * that we are entering in a new module or a new target Java release. + * + * @param directory the directory which will be traversed + * @param attributes the directory's basic attributes + */ + @Override + @SuppressWarnings("checkstyle:MissingSwitchDefault") + public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAttributes attributes) + throws IOException { + DirectoryRole role; + if (directoryRoles.isEmpty()) { + role = DirectoryRole.ROOT; + } else { + if (!directoryMatcher.matches(directory)) { + return FileVisitResult.SKIP_SUBTREE; + } + checkForManifest = false; + role = directoryRoles.getLast(); + switch (role) { + case ROOT: + /* + * Entering in any subdirectory of `target/classes` (or other directory to archive). + * We need to handle `META-INF` and modules in a special way, and archive the rest. + */ + if (directory.endsWith(MetadataFiles.META_INF)) { + role = DirectoryRole.META_INF; + checkForManifest = true; + } else if (Files.isRegularFile(directory.resolve(MODULE_DESCRIPTOR_FILE_NAME))) { + role = DirectoryRole.NAMED_MODULE; + enterModuleDirectory(directory); + } else { + role = DirectoryRole.RESOURCES; + } + break; + + case META_INF: + /* + * Entering in a subdirectory of `META-INF` or `/META-INF`. We will need to handle + * `MANIFEST.MF`, `versions` and `versions-modular` in a special way, and archive the rest. + */ + if (detectMultiReleaseJar && directory.endsWith(VERSIONS)) { + role = DirectoryRole.VERSIONS; + } else if (directory.endsWith(VERSIONS_MODULAR)) { + if (!detectMultiReleaseJar) { + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.VERSIONS_MODULAR; + } else { + role = DirectoryRole.RESOURCES; + } + break; + + case VERSIONS: + /* + * Entering in a `META-INF/versions//` directory for a specific target Java release. + * May also be a `/META-INF/versions//` directory, even if the latter is not + * the layout generated by Maven Compiler Plugin. + */ + if (enterVersionDirectory(directory, true)) { + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.RESOURCES; + break; + + case VERSIONS_MODULAR: + /* + * Entering in a `META-INF/versions-modular//` directory for a specific target Java release. + * That directory contains all modules for the version. + */ + resetToPackageHierarchy(); // No module in particular yet. + if (enterVersionDirectory(directory, false)) { + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.MODULES; + break; + + case MODULES: + /* + * Entering in a `META-INF/versions-modular//` directory. + */ + enterModuleDirectory(directory); + role = DirectoryRole.NAMED_MODULE; + break; + + case NAMED_MODULE: + /* + * Entering in a `` or `META-INF/versions-modular//` subdirectory. + * A module could have its own `META-INF` subdirectory, so we need to check again. + */ + if (directory.endsWith(MetadataFiles.META_INF)) { + role = DirectoryRole.META_INF; + checkForManifest = true; + } else { + role = DirectoryRole.RESOURCES; + } + break; + } + } + if (acceptsAllFiles && role == DirectoryRole.RESOURCES) { + currentFilesToArchive.add(directory, attributes, true); + return FileVisitResult.SKIP_SUBTREE; + } else { + directoryRoles.addLast(role); + return FileVisitResult.CONTINUE; + } + } + + /** + * Updates the {@code FileCollector} state if we finished to scan the content of a module. + * + * @param directory the directory which has been traversed + * @param error the error that occurred while traversing the directory, or {@code null} if none + */ + @Override + @SuppressWarnings("checkstyle:MissingSwitchDefault") + public FileVisitResult postVisitDirectory(final Path directory, final IOException error) throws IOException { + if (error != null) { + throw error; + } + switch (directoryRoles.removeLast()) { + case NAMED_MODULE: + // Exited the directory of a single module. + resetToPackageHierarchy(); + break; + + case ROOT: + break; + + default: + switch (directoryRoles.getLast()) { + case VERSIONS: + case VERSIONS_MODULAR: + // Exited the directory for one target Java release. + currentFilesToArchive = currentModule.baseRelease(); + currentTargetVersion = null; + break; + + case META_INF: + checkForManifest = true; + break; + } + } + return FileVisitResult.CONTINUE; + } + + /** + * Archives a single file if accepted by the matcher. + * + * @param file the file + * @param attributes the file's basic attributes + */ + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) { + if (fileMatcher.matches(file)) { + if (checkForManifest && file.endsWith(MetadataFiles.MANIFEST) && currentModule.setManifest(file, false)) { + // Do not add `MANIFEST.MF`, it will be handled by the `--manifest` option instead. + } else { + currentFilesToArchive.add(file, attributes, false); + } + } + return FileVisitResult.CONTINUE; + } + + /** + * Removes all empty archives and ensures that the lowest version is declared as the base version. + * This method should be invoked after all output directories to archive have been fully scanned. + * If {@code skipIfEmpty} is {@code false}, then this method ensures that at least one archive + * remains even if that archive is empty. + * + * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} + */ + public void prune(boolean skipIfEmpty) { + boolean isModuleHierarchy = !moduleHierarchy.isEmpty(); + moduleHierarchy.values().forEach((archive) -> archive.prune(skipIfEmpty)); + moduleHierarchy.values().removeIf(Archive::isEmpty); + packageHierarchy.prune(isModuleHierarchy || skipIfEmpty); + } + + /** + * Moves, copies or ignores orphan files. + * An orphan file is a file which is not in any module when module hierarchy is used. + * For example, some Maven plugins may create files such as {@code META-INF/LICENSE}, + * {@code META-INF/NOTICE} or {@code META-INF/DEPENDENCIES}. These files are not in + * the correct directory (they should be in a {@code "/META-INF"} directory) + * because the plugin may not be aware of module hierarchy. + * + *

A possible strategy could be to copy the {@code LICENSE} and {@code NOTICE} files + * in each module, and ignore the {@code DEPENDENCIES} file because its content is not + * correct for a module. For now, we just log a warning an ignore.

+ * + *

Prerequisites

+ * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + * + * @return if this method ignored some files, the root directory of those files + */ + Path handleOrphanFiles() { + if (moduleHierarchy.isEmpty() || packageHierarchy.isEmpty()) { + // Classpath project or module-project without orphan files. Nothing to do. + return null; + } + // TODO: we may want to copy LICENSE and NOTICE files here. + return packageHierarchy.discardAllFiles(); + } + + /** + * Writes all JAR files. + * If the project is multi-module, then this method returns the path to the generated parent POM file. + * + *

Prerequisites

+ * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + * + * @return path to the generated parent POM file, or {@code null} if none + * @throws MojoException if an error occurred during the execution of the "jar" tool + * @throws IOException if an error occurred while reading or writing a manifest file + */ + Path writeAllJARs(final ToolExecutor executor) throws IOException { + for (Archive module : moduleHierarchy.values()) { + executor.writeSingleJAR(this, module); + } + if (executor.pomDerivation != null) { + return executor.pomDerivation.writeParentPOM(packageHierarchy); + } + if (!packageHierarchy.isEmpty()) { + executor.writeSingleJAR(this, packageHierarchy); + } + return null; + } + + /** + * {@return the paths to all root directories of modules in a module hierarchy} + * They are usually {@code target/classes/} directories, but could also + * be sub-directories in the {@code META-INF/modular-versions//} directory. + * If the project does not use module hierarchy, then this method returns an empty list. + * + *

Ignored package hierarchy

+ * Note that an empty list does not necessarily means that the JAR is not modular, + * as a modular JAR can also be built from package hierarchy. But we intentionally + * ignore the latter case because this method is used for deriving POM files, and + * we do not perform such derivation for projects organized in the classical Maven 3 way. + * + *

Prerequisites

+ * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + */ + List getModuleHierarchyRoots() { + return moduleHierarchy.values().stream() + .map((archive) -> archive.baseRelease().directory) + .toList(); + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/JarMojo.java b/src/main/java/org/apache/maven/plugins/jar/JarMojo.java index 9bf9028d..a83c4389 100644 --- a/src/main/java/org/apache/maven/plugins/jar/JarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/JarMojo.java @@ -20,6 +20,7 @@ import java.nio.file.Path; +import org.apache.maven.api.PathScope; import org.apache.maven.api.plugin.annotations.Mojo; import org.apache.maven.api.plugin.annotations.Parameter; @@ -68,4 +69,14 @@ protected String getType() { protected Path getClassesDirectory() { return classesDirectory; } + + /** + * Returns the scope of dependencies for main code. + * + * @return {@link PathScope#MAIN_COMPILE} + */ + @Override + protected PathScope getDependencyScope() { + return PathScope.MAIN_COMPILE; + } } diff --git a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java new file mode 100644 index 00000000..521b4234 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java @@ -0,0 +1,250 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import org.apache.maven.api.ProducedArtifact; +import org.apache.maven.api.Project; +import org.apache.maven.shared.archiver.MavenArchiveConfiguration; + +/** + * Temporary metadata files generated by Maven before inclusion in the archive. + * Those files are created in a temporary {@code META-INF} directory when first needed. + * Those files are deleted after the build, unless the build failed or Maven was run in verbose mode. + */ +final class MetadataFiles implements Closeable { + /** + * The {@value} directory. + * This is part of JAR file specification. + */ + static final String META_INF = "META-INF"; + + /** + * The {@value} file. + * This is part of JAR file specification. + */ + static final String MANIFEST = "MANIFEST.MF"; + + /** + * The subdirectory where to add Maven-specific files. + */ + static final String MAVEN_DIR = "maven"; + + /** + * The project for which to write metadata files. + */ + private final Project project; + + /** + * The output directory (usually {@code ${baseDir}/target/}). + */ + private final Path buildDir; + + /** + * All files and directories in the order that they were created. + * The first element of this list shall be the root temporary directory created by this class. + */ + private final List filesToDelete; + + /** + * The POM file to attach to the artifact. This is initially the project POM, + * but will be replaced by a new file generated by {@link ForModule} if module hierarchy is used. + * That file may be copied verbatim in the {@code META-INF/maven/} directory of the JAR. + */ + private Path attachedPOM; + + /** + * Creates an initially empty set of temporary metadata files. + * + * @param project the project for which to write metadata files + * @param buildDir the (usually) {@code ${baseDir}/target/} directory + */ + MetadataFiles(Project project, Path buildDir) { + this.project = project; + this.buildDir = buildDir; + filesToDelete = new ArrayList<>(); + attachedPOM = project.getPomPath(); + } + + /** + * Derives a POM as the intersection of the given {@code model} and {@code archive}. + * + * @param context the tool executor which is generating all archives + * @param archive the archive for which to generate a POM + * @param manifest manifest to use for deriving project name, or {@code null} if none + * @throws IOException if an error occurred while reading the {@code module-info.class} file + * or while writing the POM file + */ + void deriveModulePOM(ToolExecutor context, Archive archive, Manifest manifest) throws IOException { + var pom = context.pomDerivation.new ForModule(archive, manifest); + pom.writeModulePOM(); + attachedPOM = pom.pomFile; + archive.pomFile = pom.pomFile; + } + + /** + * Adds the given manifest in a temporary file. + * The file will be deleted when {@link #close()} is invoked. + * + * @param manifest the manifest to write + * @return the temporary manifest file + * @throws IOException if an error occurred while writing the file + */ + public Path addManifest(final Manifest manifest) throws IOException { + Path file = baseDirectory().resolve(MANIFEST); + try (OutputStream out = Files.newOutputStream(file)) { + filesToDelete.add(file); + manifest.write(out); + } + return file; + } + + /** + * {@return the root temporary directory for the files created by this class} + * The directory is created the first time that this method is invoked. + * + * @throws IOException if an error occurred while creating the temporary directory + */ + private Path baseDirectory() throws IOException { + if (filesToDelete.isEmpty()) { + filesToDelete.add(Files.createTempDirectory(buildDir, "classes-")); + } + return filesToDelete.get(0); + } + + /** + * Creates a new directory and adds it to the list of files to delete after the build. + * + * @param dir the existing directory where to create a sub-directory + * @param path path to the subdirectory to create + * @return the new directory + * @throws IOException if an error occurred while creating the subdirectory + */ + private Path createDirectories(Path dir, String... path) throws IOException { + for (String subdir : path) { + dir = Files.createDirectory(dir.resolve(subdir)); + filesToDelete.add(dir); + } + return dir; + } + + /** + * Writes the {@code pom.xml} and {@code pom.properties} files. + * This method returns the base temporary directory followed by files that the "jar" tool will need to add + * + * @param archive archive configuration + * @param reproducible whether to enforce reproducible build + * @return arguments for the "jar" tool + * @throws IOException if an error occurred while writing the files + */ + public List addPOM(final MavenArchiveConfiguration archive, final boolean reproducible) throws IOException { + final String groupId = project.getGroupId(); + final String artifactId = project.getArtifactId(); + final String version; + final ProducedArtifact pom = project.getPomArtifact(); + if (pom.isSnapshot()) { + version = pom.getVersion().toString(); + } else { + version = project.getVersion(); + } + final Path baseDir = baseDirectory(); + final Path mavenDir = createDirectories(baseDir, META_INF, MAVEN_DIR, groupId, artifactId); + final Path pomFile = linkOrCopy(attachedPOM, mavenDir.resolve("pom.xml")); + filesToDelete.add(pomFile); // Add soon for deleting this file even if an exception is thrown below. + /* + * Subset of above "pom.xml" file but written as a properties file. + * If reproducible build is enabled, we will need to reformat after + * writing for ensuring a deterministic order of entries. + */ + final var properties = new Properties(); + Path propertiesFile = archive.getPomPropertiesFile(); + if (propertiesFile != null) { + try (InputStream in = Files.newInputStream(propertiesFile)) { + properties.load(in); + } + } + properties.setProperty("groupId", groupId); + properties.setProperty("artifactId", artifactId); + properties.setProperty("version", version); + propertiesFile = mavenDir.resolve("pom.properties"); + try (BufferedWriter out = Files.newBufferedWriter(propertiesFile)) { + filesToDelete.add(propertiesFile); // Add soon for deleting this file even if an exception is thrown below. + properties.store(out, "Subset of pom.xml"); + } + if (reproducible) { + // The encoding can be either UTF-8 or ISO-8859-1, as any non ASCII character + // is transformed into a \\uxxxx sequence anyway. + Files.writeString( + propertiesFile, + Files.lines(propertiesFile) + .filter(line -> !line.startsWith("#")) + .sorted() + .collect(Collectors.joining("\n", "", "\n"))); // system independent new line. + } + return List.of(baseDir, Path.of(META_INF, MAVEN_DIR)); + } + + /** + * Creates a link to the given source if supported, or copies the file otherwise. + * + * @param source the source file to link or copy + * @param target the file to create + * @return the target file which should be deleted after the build + */ + private static Path linkOrCopy(final Path source, final Path target) throws IOException { + try { + return Files.createLink(target, source); + } catch (UnsupportedOperationException e) { + return Files.copy(source, target); + } + } + + /** + * Cancels the deletion of files. The files will stay present after the build. + * This is desired for allowing user to execute {@code jar} on the command-line, + * for example when the build failed. + */ + public void cancelFileDeletion() { + filesToDelete.clear(); + } + + /** + * Deletes all temporary files and directories created by this class. + * + * @throws IOException if an error occurred while deleting a file or directory + */ + @Override + public void close() throws IOException { + for (int i = filesToDelete.size(); --i >= 0; ) { + Files.delete(filesToDelete.get(i)); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java new file mode 100644 index 00000000..27beea43 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -0,0 +1,519 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import javax.xml.stream.XMLStreamException; + +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.module.ModuleDescriptor; +import java.lang.module.ModuleFinder; +import java.lang.module.ModuleReference; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.apache.maven.api.DependencyCoordinates; +import org.apache.maven.api.JavaPathType; +import org.apache.maven.api.Session; +import org.apache.maven.api.Type; +import org.apache.maven.api.model.Dependency; +import org.apache.maven.api.model.Model; +import org.apache.maven.api.model.Parent; +import org.apache.maven.api.plugin.MojoException; +import org.apache.maven.api.services.DependencyCoordinatesFactory; +import org.apache.maven.api.services.DependencyCoordinatesFactoryRequest; +import org.apache.maven.api.services.DependencyResolver; +import org.apache.maven.api.services.DependencyResolverRequest; +import org.apache.maven.api.services.DependencyResolverResult; +import org.apache.maven.api.services.ModelBuilderException; +import org.apache.maven.model.v4.MavenStaxWriter; + +/** + * A mapper from Maven model dependencies to Java module names. + * A single instance of this class is created for a Maven project, + * then shared by all {@link ForModule} instances (one per module to archive). + */ +final class PomDerivation { + /** + * Whether to expand the list of transitive dependencies in the generated POM. + */ + private static final boolean EXPAND_TRANSITIVE = false; + + /** + * Copy of {@link AbstractJarMojo#session}. + */ + private final Session session; + + /** + * The project model, which includes dependencies of all modules. + */ + private final Model projectModel; + + /** + * The factory to use for creating temporary {@link DependencyCoordinates} instances. + */ + private final DependencyCoordinatesFactory coordinateFactory; + + /** + * Provide module descriptors from module names. + */ + private final ModuleFinder moduleFinder; + + /** + * Module references from paths to the JAR file or root directory. + */ + private final Map fromURI; + + /** + * Module names associated to Maven dependencies. + * This map contains {@link DependencyCoordinates#getId()} as keys and module references as values. + * This is used for detecting which dependencies are really used according {@code module-info.class}. + * + * @todo The keys should be instances of {@link DependencyCoordinates}. Unfortunately, as of Maven 4.0.0-rc-5 + * that interface does not define the {@code equals} and {@code hashCode} contracts. + */ + private final Map fromDependency; + + /** + * Modules that are built by the project. Keys are module names. + */ + private final Map builtModules; + + /** + * Creates a new mapper from Maven dependency to module name. + * + * @param mojo the enclosing MOJO + * @param moduleRoots paths to root directories of each module to archive in a module hierarchy + * @throws IOException if an I/O error occurred while fetching dependencies + * @throws MavenException if an error occurred while fetching dependencies for a reason other than I/O. + */ + PomDerivation(final AbstractJarMojo mojo, final List moduleRoots) throws IOException { + this.session = mojo.session; + projectModel = mojo.project.getModel(); + coordinateFactory = session.getService(DependencyCoordinatesFactory.class); + DependencyResolver resolver = session.getService(DependencyResolver.class); + DependencyResolverResult result = resolver.resolve(DependencyResolverRequest.builder() + .session(session) + .project(mojo.project) + .requestType(DependencyResolverRequest.RequestType.RESOLVE) + .pathScope(mojo.getDependencyScope()) + .pathTypeFilter(Set.of(JavaPathType.MODULES, JavaPathType.CLASSES)) + .build()); + + rethrow(result); + final Map dependencies = result.getDependencies(); + final Path[] allModulePaths = toRealPaths(moduleRoots, dependencies.values()); + fromURI = new HashMap<>(allModulePaths.length); // TODO: use newHashMap with JDK19. + moduleFinder = ModuleFinder.of(allModulePaths); + for (ModuleReference reference : moduleFinder.findAll()) { + reference.location().ifPresent((location) -> fromURI.put(location, reference)); + } + fromDependency = new HashMap<>(dependencies.size()); // TODO: use newHashMap with JDK19. + for (Map.Entry entry : dependencies.entrySet()) { + Path modulePath = entry.getValue().toRealPath(); + ModuleReference reference = fromURI.get(modulePath.toUri()); + if (reference != null) { + DependencyCoordinates coordinates = entry.getKey().toCoordinates(); + String id = coordinates.getId(); + ModuleReference old = fromDependency.putIfAbsent(id, reference); + if (old == null) { + coordinates = withoutVersion(coordinates); + id = coordinates.getId(); + old = fromDependency.putIfAbsent(id, reference); + } + if (old != null && !old.equals(reference)) { + mojo.log.warn("The \"" + id + "\" dependency is declared twice with different module names: \"" + + old.descriptor().name() + "\" and \"" + + reference.descriptor().name() + "\"."); + } + } + } + builtModules = new HashMap<>(moduleRoots.size()); // TODO: use newHashMap with JDK19. + for (Path root : moduleRoots) { + ModuleDescriptor descriptor = fromURI.get(root.toUri()).descriptor(); + builtModules.put( + descriptor.name(), + Dependency.newBuilder() + .groupId(projectModel.getGroupId()) + .artifactId(descriptor.name()) + .version(projectModel.getVersion()) + .type(Type.MODULAR_JAR) + .build()); + } + } + + /** + * Rebuilds the given dependency coordinates without version. + * + * Note: I'm not sure if it is necessary. This is done in case version numbers are not resolved + * in the dependencies of the model returned by {@code Project.getModel()}, or are not resolved + * in the same way as what we get from {@link DependencyResolver}. + */ + private DependencyCoordinates withoutVersion(DependencyCoordinates coordinates) { + return coordinateFactory.create(DependencyCoordinatesFactoryRequest.builder() + .session(session) + .groupId(coordinates.getGroupId()) + .artifactId(coordinates.getArtifactId()) + .extension(coordinates.getExtension()) + .classifier(coordinates.getClassifier()) + .build()); + } + + /** + * If the resolver failed, propagates its exception. + * + * @param result the resolver result + * @throws IOException if the result contains an I/O error + */ + private static void rethrow(DependencyResolverResult result) throws IOException { + Exception exception = null; + for (Exception cause : result.getExceptions()) { + if (cause instanceof UncheckedIOException e) { + cause = e.getCause(); + } + if (exception != null) { + exception.addSuppressed(cause); + } else if (cause instanceof RuntimeException || cause instanceof IOException) { + exception = cause; + } else { + exception = new MojoException("Cannot collect the runtime dependencies.", cause); + } + } + if (exception != null) { + if (exception instanceof IOException e) { + throw e; + } else { + throw (RuntimeException) exception; // A ClassCastException here would be a bug in above loop. + } + } + } + + /** + * Returns the real paths of the given collections, in iteration order and without duplicated values. + */ + private static Path[] toRealPaths(Collection moduleRoots, Collection dependencies) throws IOException { + // TODO: use newLinkedHashSet(int) after we are allowed to compile for JDK19. + final var paths = new LinkedHashSet(moduleRoots.size() + dependencies.size()); + for (Path path : moduleRoots) { + paths.add(path.toRealPath()); + } + for (Path path : dependencies) { + paths.add(path.toRealPath()); + } + return paths.toArray(Path[]::new); + } + + /** + * Returns the module descriptor for the {@code module-info.class} at the given path. + * + * @param moduleInfo path to a {@code module-info.class} file + * @return module descriptor for the specified file + * @throws IOException if an error occurred while reading the file + */ + private ModuleDescriptor findModuleDescriptor(Path moduleInfo) throws IOException { + Path directory = moduleInfo.toRealPath().getParent(); + ModuleReference reference = fromURI.get(directory.toUri()); + if (reference != null) { + return reference.descriptor(); + } + try (InputStream in = new BufferedInputStream(Files.newInputStream(moduleInfo))) { + return ModuleDescriptor.read(in); + } + } + + /** + * Returns the module descriptor for the specified Maven dependency. + * + * @param dependency dependency for which to get the module descriptor + * @return Java module descriptor for the given Maven dependency + */ + private Optional findModuleDescriptor(Dependency dependency) { + DependencyCoordinates coordinates = coordinateFactory.create(session, dependency); + ModuleReference reference = fromDependency.get(coordinates.getId()); + if (reference == null) { + coordinates = withoutVersion(coordinates); + reference = fromDependency.get(coordinates.getId()); + if (reference == null) { + return Optional.empty(); + } + } + return Optional.of(reference.descriptor()); + } + + /** + * Returns the module descriptor for the specified module name. + * + * @param moduleName name of the module for which to get the descriptor + * @return module descriptor for the specified module name + */ + private Optional findModuleDescriptor(String moduleName) { + return moduleFinder.find(moduleName).map(ModuleReference::descriptor); + } + + /** + * Derives a POM as the intersection of the consumer POM + * and the dependencies required by {@code module-info}. + */ + final class ForModule { + /** + * Value of the {@code } element in the derived POM, or {@code null} if none. + */ + private String name; + + /** + * Name of the module for which to derive a POM file. + */ + private final String moduleName; + + /** + * Whether a dependency is optional or required only at runtime. + */ + private enum Modifier { + OPTIONAL, + RUNTIME + } + + /** + * The required dependencies as Java module names, including transitive dependencies. + * Values tell whether the dependency is optional or should have runtime scope. + */ + private final Map> requires; + + /** + * Path to the POM file written by this class. + */ + final Path pomFile; + + /** + * Creates a new POM generator for the given archive. + * + * @param archive the archive for which to generate a POM + * @param manifest manifest to use for deriving project name, or {@code null} if none + * @throws IOException if an error occurred while reading the {@code module-info.class} file + */ + ForModule(final Archive archive, final Manifest manifest) throws IOException { + moduleName = archive.moduleName; + pomFile = derivePathToPOM(archive.jarFile); + requires = new LinkedHashMap<>(); + for (Path file : archive.moduleInfoFiles()) { + addDependencies(findModuleDescriptor(file), EnumSet.noneOf(Modifier.class)); + } + if (manifest != null) { + name = (String) manifest.getMainAttributes().get(Attributes.Name.IMPLEMENTATION_TITLE); + if (name == null) { + name = (String) manifest.getMainAttributes().get(Attributes.Name.SPECIFICATION_TITLE); + } + } + } + + /** + * Add the dependencies of the given module, including transitive dependencies. + * If the same dependency is added twice with different optional flags, + * the {@code false} value (i.e., mandatory dependency) has precedence. + * + * @param descriptor description of the module for which to add dependencies + * @param parentModifiers modifiers of the parent module for which this method adds dependencies + */ + private void addDependencies(final ModuleDescriptor descriptor, final EnumSet parentModifiers) { + for (ModuleDescriptor.Requires r : descriptor.requires()) { + final EnumSet modifiers = parentModifiers.clone(); + if (r.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC)) { + modifiers.add(Modifier.OPTIONAL); + } + if (!r.modifiers().contains(ModuleDescriptor.Requires.Modifier.TRANSITIVE)) { + modifiers.add(Modifier.RUNTIME); + } + EnumSet current = requires.computeIfAbsent(r.name(), (key) -> modifiers); + if (EXPAND_TRANSITIVE && (current == modifiers || current.retainAll(modifiers))) { + // Transitive dependencies if not already added or if it needs to update modifiers. + findModuleDescriptor(r.name()).ifPresent((td) -> addDependencies(td, modifiers)); + } + } + } + + /** + * Derives a POM file for the archive specified ad construction time. + * + * @throws IOException if an error occurred while writing the POM file + * + * @see #writeParentPOM(Archive) + */ + void writeModulePOM() throws IOException { + try { + writePOM(deriveModulePOM(), pomFile); + } catch (ModelBuilderException | XMLStreamException e) { + throw new MojoException("Cannot derive a POM file for the \"" + moduleName + "\" module.", e); + } + } + + /** + * Derives the module POM file as the intersection of the project POM and the archive. + * + * @return intersection of {@link #projectModel} and {@code module-info.class} + * @throws ModelBuilderException if an error occurred while building the model + */ + private Model deriveModulePOM() throws ModelBuilderException { + Model.Builder builder = Model.newBuilder() + .modelVersion("4.0.0") + .groupId(projectModel.getGroupId()) + .artifactId(moduleName) + .version(projectModel.getVersion()) + .parent(Parent.newBuilder() + .groupId(projectModel.getGroupId()) + .artifactId(projectModel.getArtifactId()) + .version(projectModel.getVersion()) + .build()); + if (name != null) { + builder = builder.name(name); + } + /* + * Filter the dependencies by keeping only the one declared in a `requires` statement of the + * `module-info.class` of the module that we are archiving. Also adjust the `` and + * `` values. The dependencies that we found are removed from the `requires` map as a + * way to make sure that we do not add them twice. In principle, the map should become empty + * at the end of this loop. + */ + final List dependencies = projectModel.getDependencies(); + if (dependencies != null) { + final var filteredDependencies = new ArrayList(dependencies.size()); + for (var iterator = requires.entrySet().iterator(); iterator.hasNext(); ) { + Map.Entry> entry = iterator.next(); + Dependency dependency = builtModules.get(entry.getKey()); + if (dependency != null) { + filteredDependencies.add(amend(dependency, entry.getValue())); + iterator.remove(); + } + } + for (Dependency dependency : dependencies) { + String dependencyModuleName = findModuleDescriptor(dependency) + .map(ModuleDescriptor::name) + .orElse(null); + /* + * If `dependencyModuleName` is null, then the dependency scope is "test" or some other scope + * that resolver has chosen to exclude. Note that this is true even for JAR on the classpath, + * because we stored the automatic module name in `PomDerivation`. Next, if `modifiers` is null, + * then the dependency has compile or runtime scope but is not used by the module to archive. + */ + if (dependencyModuleName != null) { + EnumSet modifiers = requires.remove(dependencyModuleName); + if (modifiers != null) { + filteredDependencies.add(amend(dependency, modifiers)); + } + } + } + builder.dependencies(filteredDependencies); + } + /* + * Replace the `` element by the equivalent value defined in MANIFEST.MF. + * We do this replacement because the `` of the project model applies to all modules, + * while the MANIFEST.MF has more chances to be specific to the module that we are archiving. + */ + return builder.build(); + } + + /** + * Modifies the optional and scope elements of the given dependency according the given modifiers. + * + * @param dependency the dependency to amend + * @param modifiers the modifiers to apply + * @return the amended dependency + */ + private static Dependency amend(Dependency dependency, EnumSet modifiers) { + String scope = modifiers.contains(Modifier.RUNTIME) ? "runtime" : null; + if (!Objects.equals(scope, dependency.getScope())) { + dependency = dependency.withScope(scope); + } + boolean optional = modifiers.contains(Modifier.OPTIONAL); + if (Boolean.parseBoolean(dependency.getOptional()) != optional) { + dependency = dependency.withOptional(Boolean.toString(optional)); + } + return dependency; + } + } + + /** + * Writes the parent POM file as the project file but without the dependencies. + * The dependencies are removed because they will declared on a module-by-module basis. + * + * @param packageHierarchy value of {@link FileCollector#packageHierarchy} + * @return path to the file that has been written + * @throws IOException if an I/O error occurred while writing the model + * + * @see ForModule#writeModulePOM() + */ + Path writeParentPOM(final Archive packageHierarchy) throws IOException { + Path pomFile = derivePathToPOM(packageHierarchy.jarFile); + Model.Builder builder = Model.newBuilder(projectModel, true); + builder = builder.root(false).modules(null).subprojects(null); + builder = builder.dependencies(null).build(null).reporting(null).distributionManagement(null); + builder = builder.preserveModelVersion(false).modelVersion("4.0.0"); + try { + writePOM(builder.build(), pomFile); + } catch (ModelBuilderException | XMLStreamException e) { + throw new MojoException("Cannot write the parent POM.", e); + } + return pomFile; + } + + /** + * Derives the path to the POM file to generate. + * + * @param jarFile path to the JAR file (the file does not need to exist) + * @return path to the POM file to generate + */ + private static Path derivePathToPOM(final Path jarFile) { + String filename = jarFile.getFileName().toString(); + filename = filename.substring(0, filename.lastIndexOf('.') + 1) + "pom"; + return jarFile.resolveSibling(filename); + } + + /** + * Writes the given model in the given file. + * + * @param model the model to write + * @param file the destination file + * @throws IOException if an I/O error occurred while writing the model + * @throws XMLStreamException if a XML error occurred while writing the model + */ + private static void writePOM(Model model, Path file) throws IOException, XMLStreamException { + try (BufferedWriter out = Files.newBufferedWriter(file)) { + var sw = new MavenStaxWriter(); + sw.setAddLocationInformation(false); + sw.write(out, model); + out.newLine(); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/Providers.java b/src/main/java/org/apache/maven/plugins/jar/Providers.java index b9a4acb0..8befcf53 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Providers.java +++ b/src/main/java/org/apache/maven/plugins/jar/Providers.java @@ -22,25 +22,13 @@ import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Provides; import org.apache.maven.api.services.ProjectManager; -import org.codehaus.plexus.archiver.Archiver; -import org.codehaus.plexus.archiver.jar.JarArchiver; -import org.codehaus.plexus.archiver.jar.JarToolModularJarArchiver; +/** + * For providing instances to fields annotated with {@code @Inject} if the MOJO. + */ @Named class Providers { - @Named("jar") - @Provides - static Archiver jarArchiver() { - return new JarArchiver(); - } - - @Named("mjar") - @Provides - static Archiver mjarArchiver() { - return new JarToolModularJarArchiver(); - } - @Provides static ProjectManager projectManager(Session session) { return session.getService(ProjectManager.class); diff --git a/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java index 84de519e..5eff8fd0 100644 --- a/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/TestJarMojo.java @@ -20,6 +20,7 @@ import java.nio.file.Path; +import org.apache.maven.api.PathScope; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.plugin.annotations.Mojo; import org.apache.maven.api.plugin.annotations.Parameter; @@ -81,9 +82,19 @@ protected Path getClassesDirectory() { @Override public void execute() throws MojoException { if (skip) { - getLog().info("Skipping packaging of the test-jar."); + log.info("Skipping packaging of the test-jar."); } else { super.execute(); } } + + /** + * Returns the scope of dependencies for test code. + * + * @return {@link PathScope#TEST_COMPILE} + */ + @Override + protected PathScope getDependencyScope() { + return PathScope.TEST_COMPILE; + } } diff --git a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java new file mode 100644 index 00000000..59287cae --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.Collection; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.apache.maven.api.plugin.Log; + +/** + * Checks file timestamps in order to determine if anything changed compared to an existing JAR file. + * This class may scan directories, but only if they have not already been visited by {@link FileCollector}. + * Note that the latter can occur only if {@link FileCollector} has no {@code PathMatcher}. + * Therefore, this class uses no {@code PathMatcher} neither. + * + *

Ignore files

+ * The {@code META-INF/MANIFEST.MF} file and the {@code META-INF/maven/} directory are ignored. + * See {@link #isIgnored(Path)} for the rational. + */ +final class TimestampCheck extends SimpleFileVisitor { + /** + * The base directory of archived files. + */ + private final Path classesDir; + + /** + * Path to the existing JAR file. + */ + private final Path jarFile; + + /** + * The last modified time of the JAR file. + */ + private final FileTime jarFileTime; + + /** + * Where to send non-fatal error messages. + */ + private final Log logger; + + /** + * Entries of the JAR file. Note that getting elements from this enumeration can be costly. + * Therefore, we do not fetch all elements in advance but only when needed. + */ + private Enumeration entries; + + /** + * Files found in the JAR file but not yet traversed by the file visitor. + * Files are added lazily only when needed, and removed as soon as they have been traversed. + * Path are absolute (resolved with {@link #classesDir}). + */ + private final Set filesInJAR; + + /** + * Some of the files in the build directory. This list contains only the files for which we have already + * verified the timestamp. We store them in a separated list for avoiding to check the timestamp twice. + * We need this list because we still need to verify if the files are in the {@link #jarFile}. + */ + private final Set filesInBuild; + + /** + * Whether at least one file is more recent than the JAR file. + * The scan of files will stop quickly after this flag become {@code true}. + */ + private boolean hasUpdates; + + /** + * Creates a new visitor for checking the validity of a JAR file. + * + * @param jarFile the existing JAR file + * @param jarFileTime the last modified time of the JAR file + * @param classesDir base directory of archived files + * @param logger where to send a warning if an error occurred while checking an existing JAR file + * @throws IOException if an error occurred while fetching the JAR file modification time + */ + TimestampCheck(final Path jarFile, final Path classesDir, final Log logger) throws IOException { + this.classesDir = classesDir; + this.jarFile = jarFile; + this.logger = logger; + jarFileTime = Files.getLastModifiedTime(jarFile); + filesInJAR = new HashSet<>(); + filesInBuild = new HashSet<>(); + } + + /** + * Returns {@code true} if the given file is more recent that the JAR file. + * + * @param file the file to check + * @param attributes the file's basic attributes + * @param isDirectory whether the file is a directory + * @return whether the modification time is more recent that the JAR file + */ + boolean isUpdated(final Path file, final BasicFileAttributes attributes, final boolean isDirectory) { + if (jarFileTime.compareTo(attributes.lastModifiedTime()) < 0) { + return true; + } + if (!isDirectory) { + filesInBuild.add(file); + } + return false; + } + + /** + * Checks if the JAR file contains all the given files, no extra entry, and no outdated entry. + * + * @param fileSets pairs of base directory and files potentially relative to the base directory + * @return whether the JAR file is up-to-date + */ + boolean isUpToDateJAR(final Collection fileSets) { + // No need to use JarFile because no need to handle META-INF in a special way. + try (ZipFile jar = new ZipFile(jarFile.toFile())) { + entries = jar.entries(); + for (Path file : filesInBuild) { + if (!isFoundInJAR(file)) { + return false; + } + } + for (Archive.FileSet fileSet : fileSets) { + final Path baseDir = fileSet.directory; + for (Path file : fileSet.files) { + file = baseDir.resolve(file); + if (!filesInBuild.remove(file)) { // For skipping the files already verified by above loop. + Files.walkFileTree(file, this); + if (hasUpdates) { + return false; + } + } + } + } + // Check for remaining files in the JAR which were not in the build directory. + for (Path file : filesInJAR) { + if (!isIgnored(classesDir.relativize(file))) { + return false; + } + } + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!(entry.isDirectory() || isIgnored(Path.of(entry.getName())))) { + return false; + } + } + } catch (IOException e) { + logger.warn(e); + return false; + } finally { + entries = null; + } + return true; + } + + /** + * Returns whether the given file in a JAR file should be ignored. + * We have to ignore the files that are generated in a temporary directory + * because they do not exist yet when the directory is traversed. Furthermore, + * their timestamp would always be newer then the JAR file anyway. + * + * @param file path to a file relative to the root of the JAR file + */ + private static boolean isIgnored(Path file) { + if (file.startsWith(MetadataFiles.META_INF)) { + file = file.subpath(1, file.getNameCount()); + if (file.startsWith(MetadataFiles.MANIFEST)) { + return file.getNameCount() == 1; + } else if (file.startsWith(MetadataFiles.MAVEN_DIR)) { + return true; // Ignore all subdirectories. + } + } + return false; + } + + /** + * Checks if the given file is new or more recent than the JAR file. + * Checks also if the file exists in the JAR file. + * + * @param file the traversed file + * @param attributes the file's basic attributes + */ + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) { + if (jarFileTime.compareTo(attributes.lastModifiedTime()) >= 0 && isFoundInJAR(file)) { + return FileVisitResult.CONTINUE; + } else { + hasUpdates = true; + return FileVisitResult.TERMINATE; + } + } + + /** + * Returns whether the given file is found in the JAR file. + * + * @param file the file to check + * @return whether the given file was found in the JAR file + */ + private boolean isFoundInJAR(final Path file) { + if (filesInJAR.remove(file)) { + return true; + } + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + Path p = classesDir.resolve(entry.getName()); + if (p.equals(file)) { + return true; + } + filesInJAR.add(p); + } + } + return false; + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java new file mode 100644 index 00000000..e0fac269 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -0,0 +1,474 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import javax.lang.model.SourceVersion; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.Manifest; +import java.util.spi.ToolProvider; + +import org.apache.maven.api.Project; +import org.apache.maven.api.Type; +import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.plugin.MojoException; +import org.apache.maven.shared.archiver.MavenArchiveConfiguration; + +/** + * Writer of JAR files using the information collected by {@link FileCollector}. + * This class uses the {@code "jar"} tool provided with the JDK. + */ +final class ToolExecutor { + /** + * The Maven project for which to create an archive. + */ + final Project project; + + /** + * {@code "jar"} or {@link "test-jar"}. + */ + private final String artifactType; + + /** + * The output directory where to write the JAR file. + * This is usually {@code ${baseDir}/target/}. + */ + private final Path outputDirectory; + + /** + * The JAR file name when package hierarchy is used. + * This is usually a file placed in the {@link ToolExecutor#outputDirectory} directory. + */ + private final String finalName; + + /** + * The classifier (e.g. "test"), or {@code null} if none. + */ + private final String classifier; + + /** + * The tool to use for creating the JAR files. + */ + private final ToolProvider tool; + + /** + * Where to send messages emitted by the "jar" tool. + */ + private final PrintWriter messageWriter; + + /** + * Where to send error messages emitted by the "jar" tool. + */ + private final PrintWriter errorWriter; + + /** + * Where the messages sent to {@link #messageWriter} are stored. + */ + private final StringBuffer messages; + + /** + * Where the messages sent to {@link #errorWriter} are stored. + */ + private final StringBuffer errors; + + /** + * A buffer for the arguments given to the "jar" tool, reused for each module. + * Each element of the list shall be instances of either {@link String} or {@link Path}. + */ + private final List arguments; + + /** + * The paths to the created archive files. + * Map keys are module names or {@code null} if the project does not use module hierarchy. + * Values are (type, path) pairs associated with each module where + * type is {@code "pom"}, {@code "jar"} or {@code "test-jar"} and path + * is the path to the POM or JAR file. + */ + private final Map> result; + + /** + * Mapper from Maven dependencies to Java modules, or {@code null} if the project does not use module hierarchy. + * This mapper is created only once for a Maven project and reused for each Java module to archive. + * + *

This field is not used directly by {@code ToolExecutor}. It is defined in this class for transferring + * this information from {@link AbstractJarMojo} to {@link PomDerivation.ForModule}. + * This is an internal mechanism that should not be public or protected.

+ */ + PomDerivation pomDerivation; + + /** + * Manifest to merge with the manifest found in the files to archive. + * This is a manifest built from the {@code } plugin configuration. + * Can be {@code null} if there is noting to add to the existing manifests. + */ + private final Manifest manifestFromPlugin; + + /** + * The file from which {@link #manifestFromPlugin} has been read, or {@code null} if none. + * If non-null, reading that file shall produce the same manifest as {@link #manifestFromPlugin}. + * It implies that this field shall be {@code null} if {@link #manifestFromPlugin} is the result + * of merging elements specified in {@code } with a file specified in the plugin configuration. + */ + private final Path manifestFile; + + /** + * The archive configuration to use. + */ + private final MavenArchiveConfiguration archiveConfiguration; + + /** + * The timestamp in ISO-8601 extended offset date-time, or {@code null} if none. + * If user provided a value in seconds, it shall have been converted to ISO-8601. + * This is used for reproducible builds. + */ + private final String outputTimestamp; + + /** + * Whether to force to build new JAR files even if none of the contents appear to have changed. + */ + private final boolean forceCreation; + + /** + * Where to send informative or error messages. + */ + private final Log logger; + + /** + * Creates a new writer. + * + * @param mojo the MOJO from which to get the configuration + * @param manifest manifest built from plugin configuration, or {@code null} if none + * @param archive the archive configuration + * @throws IOException if an error occurred while reading the manifest file + */ + ToolExecutor(AbstractJarMojo mojo, Manifest manifest, MavenArchiveConfiguration archive) throws IOException { + project = mojo.project; + artifactType = mojo.getType(); + outputDirectory = (mojo.outputDirectory != null) + ? mojo.outputDirectory + : Path.of(project.getBuild().getDirectory()); + classifier = AbstractJarMojo.nullIfAbsent(mojo.getClassifier()); + finalName = + (mojo.finalName != null) ? mojo.finalName : project.getBuild().getFinalName(); + forceCreation = mojo.forceCreation; + outputTimestamp = mojo.getOutputTimestamp(); + logger = mojo.log; + tool = mojo.getJarTool(); + + var buffer = new StringWriter(); + messages = buffer.getBuffer(); + messageWriter = new PrintWriter(buffer); + + buffer = new StringWriter(); + errors = buffer.getBuffer(); + errorWriter = new PrintWriter(buffer); + + arguments = new ArrayList<>(); + result = new LinkedHashMap<>(); + archiveConfiguration = archive; + + Path file = archive.getManifestFile(); + if (file != null) { + try (InputStream in = Files.newInputStream(file)) { + // No need to wrap in `BufferedInputStream`. + if (manifest != null) { + manifest.read(in); + file = null; // Because the manifest is the result of a merge. + } else { + manifest = new Manifest(in); + } + } + } + if (manifest != null) { + if (mojo.detectMultiReleaseJar) { + manifest.getMainAttributes().remove(Attributes.Name.MULTI_RELEASE); + } + if (!isReproducible()) { + // If reproducible build was not requested, let the tool declares itself. + // This is a workaround until we port Maven archiver to this JAR plugin. + manifest.getMainAttributes().remove("Created-By"); + } + } + manifestFromPlugin = manifest; + manifestFile = file; + } + + /** + * Whether reproducible build was requested. + * In current version, the output time stamp is used as a sentinel value. + */ + public boolean isReproducible() { + return outputTimestamp != null; + } + + /** + * Creates an initially empty archive for JAR file to generate. + * This method does not create the JAR file immediately, + * but collect information for creating the file later. + * + * @param moduleName the module name if using module hierarchy, or {@code null} if using package hierarchy + * @param directory the directory of the classes targeting the base Java release + */ + Archive newArchive(final String moduleName, final Path directory) { + var sb = new StringBuilder(60); + if (moduleName != null) { + sb.append(moduleName).append('-').append(project.getVersion()); + } else { + sb.append(finalName); + } + if (classifier != null) { + sb.append('-').append(classifier); + } + String filename = sb.append(".jar").toString(); + return new Archive(outputDirectory.resolve(filename), moduleName, directory, forceCreation, logger); + } + + /** + * Writes all JAR files, together with their derived POM files if applicable. + * The derived POM files are the intersections of the project POM with the + * content of {@code module-info.class} files. + * + *

Prerequisites

+ * The {@link FileCollector#prune(boolean)} method should have been invoked once before to invoke this method. + * + * @param files the result of scanning the build directory for listing the files or directories to archive + * @return the paths to the created archive files + * @throws MojoException if an error occurred during the execution of the "jar" tool + * @throws IOException if an error occurred while reading or writing a manifest file + */ + @SuppressWarnings("ReturnOfCollectionOrArrayField") + public Map> writeAllJARs(final FileCollector files) throws IOException { + Path ignored = files.handleOrphanFiles(); + Path parentPOM = files.writeAllJARs(this); + if (ignored != null) { + logger.warn("Some files in \"" + relativize(outputDirectory, ignored) + + "\" were ignored because they belong to no module."); + } + if (parentPOM != null) { + if (result.put(null, Map.of(Type.POM, parentPOM)) != null) { + throw new MojoException("Internal error."); // Should never happen. + } + } + return result; + } + + /** + * Creates the JAR files for the specified set of files. + * If the operation fails, an error message may be available in the {@link #errors} buffer. + * + * @param files the result of scanning the build directory for listing the files or directories to archive + * @throws MojoException if an error occurred during the execution of the "jar" tool + * @throws IOException if an error occurred while reading or writing a manifest file + */ + void writeSingleJAR(final FileCollector files, final Archive archive) throws IOException { + final Path relativePath = relativize(project.getRootDirectory(), archive.jarFile); + if (archive.isUpToDateJAR()) { + logger.info("Keep up-to-date JAR: \"" + relativePath + "\"."); + archive.saveArtifactPaths(artifactType, result); + return; + } + logger.info("Building JAR: \"" + relativePath + "\"."); + /* + * If `MANIFEST.MF` entries were specified by JAR plugin configuration, + * merge those entries with the content of `MANIFEST.MF` file found in + * the files to archive. + */ + boolean writeTemporaryManifest = (manifestFromPlugin != null && manifestFile == null); // Check . + final Manifest manifest = archive.mergeManifest(manifestFile, manifestFromPlugin); + if (manifest != manifestFromPlugin) { + writeTemporaryManifest |= (manifestFromPlugin != null); // Check if a merge of two manifests. + } + writeTemporaryManifest |= archive.setMainClass(manifest); + if (manifest != null) { + String name = manifest.getMainAttributes().getValue("Automatic-Module-Name"); + if (name != null && !SourceVersion.isName(name)) { + throw new MojoException("Invalid automatic module name: \"" + name + "\"."); + } + } + /* + * Creates temporary files for META-INF (if the existing file cannot be used directly) + * and for the Maven metadata (if requested). The temporary files are in the `target` + * directory and will be deleted, unless the build fails or is run in verbose mode. + */ + try (MetadataFiles metadata = new MetadataFiles(project, outputDirectory)) { + if (writeTemporaryManifest) { + archive.setManifest(metadata.addManifest(manifest), true); + } + if (archive.moduleName != null) { + metadata.deriveModulePOM(this, archive, manifest); + } + if (archiveConfiguration.isAddMavenDescriptor()) { + archive.mavenFiles = metadata.addPOM(archiveConfiguration, isReproducible()); + } + /* + * Prepare the arguments to send to the `jar` tool and log a message. + */ + arguments.add("--create"); + if (!archiveConfiguration.isCompress()) { + arguments.add("--no-compress"); + } + if (outputTimestamp != null) { + arguments.add("--date"); + arguments.add(outputTimestamp); + } + archive.arguments(arguments); + /* + * Execute the `jar` tool with arguments determined by the values dispatched + * in the various fields of the `Archive`. Information and error essages are logged. + */ + int status = executeJarTool(); + if (status != 0 || logger.isDebugEnabled()) { + Path debugFile = archive.writeDebugFile(project.getBasedir(), outputDirectory, classifier, arguments); + metadata.cancelFileDeletion(); + if (status != 0) { + logCommandLineTip(project.getBasedir(), debugFile); + String error = errors.toString().strip(); + if (error.isEmpty()) { + error = "unspecified error."; + } + throw new MojoException("Cannot create the \"" + relativePath + "\" archive file: " + error); + } + } + } + clear(); + if (archive.validate(arguments)) { + int status = executeJarTool(); + if (status != 0) { + var message = new StringBuilder() + .append("The \"") + .append(relativePath) + .append("\" archive file is invalid"); + String error = errors.toString().strip(); + if (error.isEmpty()) { + message.append('.'); + } else { + message.append(": ").append(error); + } + throw new MojoException(message.toString()); + } + clear(); + } + archive.saveArtifactPaths(artifactType, result); + } + + /** + * Executes the {@code jar} tools with the argument currently in the {@link #arguments} list. + * Warning and error messages are logged but not cleared. + * + * @return the result of executing the tool: 0 on success, non-zero on failure. + */ + private int executeJarTool() { + String[] options = new String[arguments.size()]; + Arrays.setAll(options, (i) -> arguments.get(i).toString()); + int status = tool.run(messageWriter, errorWriter, options); + if (!messages.isEmpty()) { + logger.info(messages); + } + if (!errors.isEmpty()) { + logger.error(errors); + } + return status; + } + + /** + * Clears the list of arguments and the buffer where warnings and errors are written. + * After invoking this method, {@code ToolExecutor} is ready to archive another module. + */ + private void clear() { + arguments.clear(); + errors.setLength(0); + messages.setLength(0); + } + + /** + * Sends an error message to the logger if non-blank, then log a tip for testing from the command-line. + * + * @param baseDir the project base directory, or {@code null} + * @param debugFile the file containing the "jar" tool arguments + */ + private void logCommandLineTip(Path baseDir, Path debugFile) { + final var commandLine = new StringBuilder("For trying to archive from the command-line, use:"); + if (baseDir != null) { + debugFile = relativize(baseDir, debugFile); + baseDir = relativize(Path.of(System.getProperty("user.dir")), baseDir); + String chdir = baseDir.toString(); + if (!chdir.isEmpty()) { + boolean isWindows = (File.separatorChar == '\\'); + commandLine + .append(System.lineSeparator()) + .append(" ") + .append(isWindows ? "chdir " : "cd ") + .append(chdir); + } + } + commandLine + .append(System.lineSeparator()) + .append(" ") + .append(tool.name()) + .append(" @") + .append(debugFile); + logger.info(commandLine); + } + + /** + * Logs a warning saying that a {@code META-INF/versions/} directory cannot be parsed as a version number. + * + * @param path the directory + * @param e the exception that occurred while trying to parse the directory name + */ + void warnInvalidVersion(Path path, IllegalArgumentException e) { + var message = new StringBuilder(160) + .append("The \"") + .append(relativize(outputDirectory, path)) + .append("\" directory cannot be parsed as a version number."); + String cause = e.getMessage(); + if (cause != null) { + message.append(System.lineSeparator()).append("Caused by: ").append(cause); + } + logger.warn(message, e); + } + + /** + * Tries to return the given directory relative to the given base. + * If any directory is null, or if the directory cannot be relativized, + * returns the directory unchanged (usually as an absolute path). + */ + private static Path relativize(Path base, Path dir) { + if (base != null && dir != null) { + try { + return base.relativize(dir); + } catch (IllegalArgumentException e) { + // Ignore, keep the absolute path. + } + } + return dir; + } +} diff --git a/src/test/java/org/apache/maven/plugins/jar/JarMojoTest.java b/src/test/java/org/apache/maven/plugins/jar/JarMojoTest.java index 73b8692d..222a3282 100644 --- a/src/test/java/org/apache/maven/plugins/jar/JarMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/JarMojoTest.java @@ -27,24 +27,19 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; /** - * Test for {@link JarMojo} - * - * @version $Id$ + * Test for {@link JarMojo}. */ @MojoTest class JarMojoTest { /** * Tests the discovery and configuration of the mojo. - * - * @throws Exception in case of an error */ @Test @Basedir("${basedir}/src/test/resources/unit/jar-basic-test") @InjectMojo(goal = "jar") - void jarTestEnvironment(JarMojo mojo) throws Exception { + void jarTestEnvironment(JarMojo mojo) { assertNotNull(mojo); - - assertEquals("foo", mojo.getProject().getGroupId()); + assertEquals("foo", mojo.project.getGroupId()); } } From 4127022257d81387989aa53ffef76169ade9d276 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Wed, 12 Aug 2026 12:26:28 +0200 Subject: [PATCH 02/25] Skip jar --validate on JDK 17/18 (JDK-8282446) The plugin runs `jar --validate` after creating each archive. With the `jar` tool bundled in JDK 17 and 18, that validation crashes on any class compiled as a record (JDK-8282446, "This feature requires ASM8"). The issue was fixed in JDK 19 via the ASM 9.2 upgrade (JDK-8282508), but the fix was not backported to JDK 17u/18u. Since Maven 4 runs on JDK 17+, packaging a module that contains a record may fail in project with default configuration if using a JDK older than 19. Guard the validation pass on Runtime.version().feature() >= 19 and log an INFO line naming the skipped archive. The archive was already created by the `--create` pass. Behaviour on JDK 19+ is unchanged. Add an IT which tests a modular JAR containing a record and expects the build to succeed, so it turns the jdk-17 CI leg red until the validation pass is guarded for JDK 17/18. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- .../records-jar-validate/invoker.properties | 18 ++++++ src/it/records-jar-validate/pom.xml | 44 ++++++++++++++ .../src/main/java/it/records/Point.java | 30 ++++++++++ .../src/main/java/module-info.java | 22 +++++++ src/it/records-jar-validate/verify.bsh | 60 +++++++++++++++++++ .../maven/plugins/jar/ToolExecutor.java | 48 +++++++++++---- 6 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 src/it/records-jar-validate/invoker.properties create mode 100644 src/it/records-jar-validate/pom.xml create mode 100644 src/it/records-jar-validate/src/main/java/it/records/Point.java create mode 100644 src/it/records-jar-validate/src/main/java/module-info.java create mode 100644 src/it/records-jar-validate/verify.bsh diff --git a/src/it/records-jar-validate/invoker.properties b/src/it/records-jar-validate/invoker.properties new file mode 100644 index 00000000..a644cb7e --- /dev/null +++ b/src/it/records-jar-validate/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = clean package diff --git a/src/it/records-jar-validate/pom.xml b/src/it/records-jar-validate/pom.xml new file mode 100644 index 00000000..42e05dec --- /dev/null +++ b/src/it/records-jar-validate/pom.xml @@ -0,0 +1,44 @@ + + + + 4.0.0 + org.apache.maven.plugins + records-jar-validate + 1.0-SNAPSHOT + jar + records-jar-validate + Verifies that a modular JAR containing a record can be packaged. The post-creation + "jar --validate" pass of the JDK 17 and 18 jar tool crashes on record classes (JDK-8282446, + fixed in JDK 19), so this build must not fail on those JDKs. + + + 17 + + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + diff --git a/src/it/records-jar-validate/src/main/java/it/records/Point.java b/src/it/records-jar-validate/src/main/java/it/records/Point.java new file mode 100644 index 00000000..e42b2382 --- /dev/null +++ b/src/it/records-jar-validate/src/main/java/it/records/Point.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package it.records; + +/** + * A record, whose class file carries a {@code Record} attribute with record components. + * The JDK 17/18 {@code jar --validate} tool crashes on such attributes (JDK-8282446). + */ +public record Point(int x, int y) { + + public int sum() { + return x + y; + } +} diff --git a/src/it/records-jar-validate/src/main/java/module-info.java b/src/it/records-jar-validate/src/main/java/module-info.java new file mode 100644 index 00000000..962b6424 --- /dev/null +++ b/src/it/records-jar-validate/src/main/java/module-info.java @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module it.records { + exports it.records; +} diff --git a/src/it/records-jar-validate/verify.bsh b/src/it/records-jar-validate/verify.bsh new file mode 100644 index 00000000..99e8d886 --- /dev/null +++ b/src/it/records-jar-validate/verify.bsh @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.io.*; +import java.util.jar.*; + +boolean result = true; +JarFile jar = null; + +try +{ + File artifact = new File( basedir, "target/records-jar-validate-1.0-SNAPSHOT.jar" ); + if ( !artifact.isFile() ) + { + System.err.println( "Expected JAR is missing: " + artifact ); + return false; + } + + jar = new JarFile( artifact ); + if ( jar.getEntry( "it/records/Point.class" ) == null ) + { + System.err.println( "JAR does not contain the record class it/records/Point.class" ); + return false; + } + if ( jar.getEntry( "module-info.class" ) == null ) + { + System.err.println( "JAR does not contain module-info.class" ); + return false; + } +} +catch ( Throwable e ) +{ + e.printStackTrace(); + result = false; +} +finally +{ + if ( jar != null ) + { + jar.close(); + } +} + +return result; diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index e0fac269..86c08e21 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -47,6 +47,16 @@ * This class uses the {@code "jar"} tool provided with the JDK. */ final class ToolExecutor { + /** + * First JDK feature version whose {@code jar} tool can run {@code --validate} on archives + * that contain records. Earlier {@code jar} tools failed with "This feature requires ASM8" + * (JDK-8282446, fixed by JDK-8282508 in JDK 19 by not backported to a 17u or 18u update). + * + * @see JDK-8282446 + * @see JDK-8282508 + */ + private static final int JDK_FIXING_JAR_VALIDATE = 19; + /** * The Maven project for which to create an archive. */ @@ -359,20 +369,34 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE } } clear(); + /* + * The `jar --validate` operation of the JDK 17 and 18 `jar` tools fails with + * "This feature requires ASM8" on any class compiled as a record (JDK-8282446). + * The `jar` tool was fixed in JDK 19. Maven 4 runs on JDK 17+, so on JDK 17/18 + * we skip the post-creation validation pass; the archive was already created + * successfully by the `--create` pass above. + */ if (archive.validate(arguments)) { - int status = executeJarTool(); - if (status != 0) { - var message = new StringBuilder() - .append("The \"") - .append(relativePath) - .append("\" archive file is invalid"); - String error = errors.toString().strip(); - if (error.isEmpty()) { - message.append('.'); - } else { - message.append(": ").append(error); + final int version = Runtime.version().feature(); + if (version >= JDK_FIXING_JAR_VALIDATE) { + int status = executeJarTool(); + if (status != 0) { + var message = new StringBuilder() + .append("The \"") + .append(relativePath) + .append("\" archive file is invalid"); + String error = errors.toString().strip(); + if (error.isEmpty()) { + message.append('.'); + } else { + message.append(": ").append(error); + } + throw new MojoException(message.toString()); } - throw new MojoException(message.toString()); + } else { + logger.info("Skipping the `jar --validate` pass on JDK " + version + + ": that JDK's jar tool cannot validate archives containing records" + + " (fixed in JDK " + JDK_FIXING_JAR_VALIDATE + ")."); } clear(); } From 768b9a5e0e665bb6aaa744fe208ce7dfbc6d8764 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Wed, 12 Aug 2026 15:34:15 +0200 Subject: [PATCH 03/25] Fix base-release dir for modular multi-release JARs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Archive` seeded its base (version-less) `FileSet` from whichever directory first created the `Archive` via `computeIfAbsent(…)` in `FileCollector.enterModuleDirectory(…)`. Directory iteration order is unspecified, so when the walk enters "META-INF/versions-modular//" before the base "" dir, the base `FileSet` was bound to a version-specific directory with no module-info.class. The problem was not really the version-specific directory, but its association to the `null` key in `filesetForRelease`, which makes it the first map entry. Consequently `baseRelease().directory` pointed there, `ModuleFinder.of(...)` found no module, and `PomDerivation` dereferenced a null `ModuleReference` -> NPE. The "green on JDK 19" was accidental walk order, not a version gate. Add a `version` argument to the `Archive` constructor, which keep the `null` key free for the real base version when the walk will reach that version. Bug description partially from Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- .../org/apache/maven/plugins/jar/Archive.java | 22 ++++++++++++++++--- .../maven/plugins/jar/FileCollector.java | 5 +++-- .../maven/plugins/jar/PomDerivation.java | 10 +++++++-- .../maven/plugins/jar/ToolExecutor.java | 5 +++-- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index c17812ef..29e210eb 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -222,12 +222,19 @@ public String toString() { * * @param jarFile path to the JAR file to create * @param moduleName the module name if using module hierarchy, or {@code null} if using package hierarchy + * @param version the target Java release, or {@code null} for the base version * @param directory the directory of the classes targeting the base Java release * @param forceCreation whether to force a new JAR file even if the content seems unchanged * @param logger where to send a warning if an error occurred while checking an existing JAR file */ @SuppressWarnings("checkstyle:NeedBraces") - Archive(Path jarFile, String moduleName, Path directory, boolean forceCreation, Log logger) { + Archive( + final Path jarFile, + final String moduleName, + final Runtime.Version version, + final Path directory, + final boolean forceCreation, + final Log logger) { this.jarFile = jarFile; this.moduleName = moduleName; filesetForRelease = new TreeMap<>((v1, v2) -> { @@ -236,7 +243,7 @@ public String toString() { if (v2 == null) return +1; return v1.compareTo(v2); }); - filesetForRelease.put(null, new FileSet(directory)); + filesetForRelease.put(version, new FileSet(directory)); if (!forceCreation && Files.isRegularFile(jarFile)) { try { existingJAR = new TimestampCheck(jarFile, directory, logger); @@ -253,7 +260,16 @@ public String toString() { * @throws NoSuchElementException should not happen unless {@link #prune(boolean)} has been invoked */ FileSet baseRelease() { - return filesetForRelease.firstEntry().getValue(); + Map.Entry entry = filesetForRelease.firstEntry(); + String message = null; + if (entry != null) { + Runtime.Version version = entry.getKey(); + if (version == null) { + return entry.getValue(); + } + message = "Expected base version but found version " + version; + } + throw new NoSuchElementException(message); } /** diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index c464b9e5..4e4f2b7f 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -161,7 +161,7 @@ final class FileCollector extends SimpleFileVisitor { fileMatcher = matcherFactory.createPathMatcher(directory, mojo.getIncludes(), mojo.getExcludes(), false); directoryMatcher = matcherFactory.deriveDirectoryMatcher(fileMatcher); acceptsAllFiles = matcherFactory.isIncludesAll(directoryMatcher) && matcherFactory.isIncludesAll(fileMatcher); - packageHierarchy = context.newArchive(null, directory); + packageHierarchy = context.newArchive(null, null, directory); moduleHierarchy = new LinkedHashMap<>(); resetToPackageHierarchy(); } @@ -183,7 +183,8 @@ private void resetToPackageHierarchy() { */ private void enterModuleDirectory(final Path directory) { String moduleName = directory.getFileName().toString(); - currentModule = moduleHierarchy.computeIfAbsent(moduleName, (name) -> context.newArchive(name, directory)); + currentModule = moduleHierarchy.computeIfAbsent( + moduleName, (name) -> context.newArchive(name, currentTargetVersion, directory)); currentFilesToArchive = currentModule.newTargetRelease(directory, currentTargetVersion); } diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java index 27beea43..de78233e 100644 --- a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -89,6 +89,7 @@ final class PomDerivation { /** * Provide module descriptors from module names. + * May be {@code null} if no {@code module-info} was found. */ private final ModuleFinder moduleFinder; @@ -138,8 +139,10 @@ final class PomDerivation { final Path[] allModulePaths = toRealPaths(moduleRoots, dependencies.values()); fromURI = new HashMap<>(allModulePaths.length); // TODO: use newHashMap with JDK19. moduleFinder = ModuleFinder.of(allModulePaths); - for (ModuleReference reference : moduleFinder.findAll()) { - reference.location().ifPresent((location) -> fromURI.put(location, reference)); + if (moduleFinder != null) { + for (ModuleReference reference : moduleFinder.findAll()) { + reference.location().ifPresent((location) -> fromURI.put(location, reference)); + } } fromDependency = new HashMap<>(dependencies.size()); // TODO: use newHashMap with JDK19. for (Map.Entry entry : dependencies.entrySet()) { @@ -280,6 +283,9 @@ private Optional findModuleDescriptor(Dependency dependency) { * @return module descriptor for the specified module name */ private Optional findModuleDescriptor(String moduleName) { + if (moduleFinder == null) { + return Optional.empty(); + } return moduleFinder.find(moduleName).map(ModuleReference::descriptor); } diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 86c08e21..5c01ca12 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -245,9 +245,10 @@ public boolean isReproducible() { * but collect information for creating the file later. * * @param moduleName the module name if using module hierarchy, or {@code null} if using package hierarchy + * @param version the target Java release, or {@code null} for the base version * @param directory the directory of the classes targeting the base Java release */ - Archive newArchive(final String moduleName, final Path directory) { + Archive newArchive(final String moduleName, final Runtime.Version version, final Path directory) { var sb = new StringBuilder(60); if (moduleName != null) { sb.append(moduleName).append('-').append(project.getVersion()); @@ -258,7 +259,7 @@ Archive newArchive(final String moduleName, final Path directory) { sb.append('-').append(classifier); } String filename = sb.append(".jar").toString(); - return new Archive(outputDirectory.resolve(filename), moduleName, directory, forceCreation, logger); + return new Archive(outputDirectory.resolve(filename), moduleName, version, directory, forceCreation, logger); } /** From ede2c9d456a65e9cbc97074cf5be6b4339453f17 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 13 Aug 2026 01:01:44 +0200 Subject: [PATCH 04/25] Fix order-dependent main class in modular JARs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setMainClass(…)` unconditionally removes the `Main-Class` attribute from the plugin manifest (to either pass it via `--main-class` instead or to ignore it), but that manifest is shared across every module of a module hierarchy. The first module processed consumed the attribute, so when a non-owning module was processed first (directory iteration order is unspecified), the owning module never received its main class. Give each module a copy of the shared manifest in `ToolExecutor.writeSingleJAR` so processing order no longer decides the outcome. Add `ArchiveTest` with deterministic unit tests for this and for the base-release binding (both previously exercised only by ITs whose result depends on filesystem walk order). Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- .../org/apache/maven/plugins/jar/Archive.java | 11 +- .../maven/plugins/jar/ToolExecutor.java | 11 +- .../apache/maven/plugins/jar/ArchiveTest.java | 160 ++++++++++++++++++ 3 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 29e210eb..8ade19c5 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -80,8 +80,12 @@ final class Archive { final String moduleName; /** - * Path to {@code META-INF/MANIFEST.MF}, or {@code null} if none. The manifest file - * should be included by the {@code --manifest} option instead of as an ordinary file. + * Path to {@code META-INF/MANIFEST.MF}, or {@code null} if none. + * If non-null, this value will be given to the {@code --manifest} option. + * The use of this option is preferable to adding {@code MANIFEST.MF} as an ordinary file. + * + * @see #setManifest(Path, boolean) + * @see #mergeManifest(Path, Manifest) */ @Nullable private Path manifest; @@ -383,6 +387,9 @@ FileSet newTargetRelease(Path directory, Runtime.Version version) { * Java tools). If a module is specified, the main class is kept only if the module match. The intent is to * allow users to specify on which module the main class applies when they use plugin configuration. * + *

This method may modify the {@code content} manifest. Caller shall ensure that the given manifest + * is not a shared instance. This method returns whether a change has actually been done.

+ * * @param content combination of existing {@code MANIFEST.MF} and manifest inferred from configuration, or null * @return whether the given manifest has been modified by this method */ diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 5c01ca12..edc1f0c4 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -313,9 +313,18 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE * the files to archive. */ boolean writeTemporaryManifest = (manifestFromPlugin != null && manifestFile == null); // Check . - final Manifest manifest = archive.mergeManifest(manifestFile, manifestFromPlugin); + Manifest manifest = archive.mergeManifest(manifestFile, manifestFromPlugin); if (manifest != manifestFromPlugin) { writeTemporaryManifest |= (manifestFromPlugin != null); // Check if a merge of two manifests. + } else if (manifest != null) { + /* + * `setMainClass` below removes the Main-Class attribute, and `manifestFromPlugin` is + * shared across every module of a module hierarchy. Work on a per-module copy so that + * the (unspecified) directory iteration order does not decide which module keeps the + * main class: otherwise a non-owning module processed first consumes the attribute and + * the owning module never receives it. + */ + manifest = new Manifest(manifest); } writeTemporaryManifest |= archive.setMainClass(manifest); if (manifest != null) { diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java new file mode 100644 index 00000000..a9b263c8 --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import java.nio.file.Path; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link Archive}, focused on the two behaviours that are otherwise only + * exercised by integration tests whose outcome depends on the (unspecified) filesystem + * directory-iteration order, and therefore pass on some platforms while failing on others. + */ +class ArchiveTest { + + /** + * Creates an {@code Archive} suitable for a unit test. {@code forceCreation = true} skips the + * existing-JAR timestamp check, so the (here {@code null}) logger is never dereferenced. + */ + private static Archive archive(String moduleName, Runtime.Version version, Path directory) { + return new Archive(directory.resolve("out.jar"), moduleName, version, directory, true, null); + } + + /** + * Creates a manifest with the main class attribute set to the given value. + * + * @param value value of the main class attribute + * @return a new manifest with the given attribute value + */ + private static Manifest manifestWithMainClass(String value) { + Manifest m = new Manifest(); + Attributes attributes = m.getMainAttributes(); + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + attributes.put(Attributes.Name.MAIN_CLASS, value); + return m; + } + + /** + * Returns the value of the main class attribute. + * + * @param m the manifest from which to get the value + * @return the main class attribute value, or {@code null} if none + */ + private static Object mainClassOf(Manifest m) { + return m.getMainAttributes().get(Attributes.Name.MAIN_CLASS); + } + + /** + * Verifies that {@link Archive#setMainClass(Manifest)} takes ownership for a module-qualified + * {@code "module/Class"} main class. + * + * Note that the {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. + * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. + */ + @Test + void owningModuleClaimsMainClassAndRemovesItFromManifest() { + Archive owner = archive("foo.bar", null, Path.of(".")); + Manifest m = manifestWithMainClass("foo.bar/foo.MainFile"); + // The owner keeps the main class (emitted via --main-class) ... + assertTrue(owner.setMainClass(m)); + // ... and the raw `module/Class` value is removed from the written manifest. + assertNull(mainClassOf(m)); + } + + /** + * Verifies that {@link Archive#setMainClass(Manifest)} does not take ownership fo + * a module-qualified {@code "module/Class"} main class when the module name does not match. + * Note that the {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. + * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. + */ + @Test + void nonOwningModuleRejectsMainClass() { + Archive nonOwner = archive("foo.bar.more", null, Path.of(".")); + Manifest m = manifestWithMainClass("foo.bar/foo.MainFile"); + assertFalse(nonOwner.setMainClass(m)); + assertNull(mainClassOf(m)); + } + + /** + * Tests that which module keeps the main class must not depend on processing order. + * {@link ToolExecutor} gives each module a copy of the shared plugin manifest; + * this pins that the owning module (and only it) keeps the main class in either order, + * and that the shared manifest is never consumed. + */ + @Test + void mainClassAssignmentIsIndependentOfModuleOrder() { + assertOwnership("foo.bar", "foo.bar.more", true); // owner processed first + assertOwnership("foo.bar.more", "foo.bar", false); // non-owner processed first + } + + /** + * Helper method for {@link #mainClassAssignmentIsIndependentOfModuleOrder()}. + * Asserts that {@link Archive#setMainClass(Manifest)} returns {@code true} + * for the owner and {@code false} for the other module. + * + *

Note that the {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. + * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. + * This extension is used by the plugin for identifying in which JAR file to add this + * {@code Main-Class} manifest entry.

+ */ + private static void assertOwnership(String first, String second, boolean ownerIsFirst) { + final Path path = Path.of("."); + final Manifest shared = manifestWithMainClass("foo.bar/foo.MainFile"); + final Manifest m1 = new Manifest(shared); + final Manifest m2 = new Manifest(shared); + assertEquals(ownerIsFirst, archive(first, null, path).setMainClass(m1)); + assertEquals(!ownerIsFirst, archive(second, null, path).setMainClass(m2)); + // Per-module copies must leave the shared plugin manifest untouched. + assertEquals("foo.bar/foo.MainFile", mainClassOf(shared)); + assertNull(mainClassOf(m1)); + assertNull(mainClassOf(m2)); + } + + /** + * Tests that {@code FileSet} association to target release is consistent regardless creation order. + * Verifies that the base (version-less) release binds to the true {@code } directory even + * when the {@link Archive} was first created from a {@code META-INF/versions-modular//} + * directory (which happens when the file-tree walk visits the version directory first). + */ + @Test + void baseReleaseBindingIsIndependentOfDirectoryOrder() { + final Path base = Path.of("classes", "foo.bar"); + final Path v16 = Path.of("classes", "META-INF", "versions-modular", "16", "foo.bar"); + final Runtime.Version r16 = Runtime.Version.parse("16"); + + // Version-directory first (the failing order): Archive seeded from v16, base registered later. + final Archive a = archive("foo.bar", r16, v16); + assertNotSame(a.newTargetRelease(v16, r16), a.newTargetRelease(base, null)); + assertEquals(base, a.baseRelease().directory, "base must rebind to the version-less directory"); + + // Base-directory first: still correct. + final Archive b = archive("foo.bar", null, base); + assertNotSame(b.newTargetRelease(base, null), b.newTargetRelease(v16, r16)); + assertEquals(base, b.baseRelease().directory); + } +} From 5bb8540c5a18688b3853209bcf626d4bf6ee360d Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 13 Aug 2026 12:09:12 +0200 Subject: [PATCH 05/25] Make deterministic which file is first after the -C option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FileSet.add(…)` relativized only the first file added and stored the rest as absolute paths. This heuristic rule is based on the observation that is seems to be a `jar` tool requirement. However, which file is first depends on the unspecified directory iteration order. On some platforms, the tests fail with "names do not match" error message. This make the first file deterministic by choosing the shortest one. Issue identified by: Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- .../org/apache/maven/plugins/jar/Archive.java | 54 +++++++++++++++---- .../apache/maven/plugins/jar/ArchiveTest.java | 51 ++++++++++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 8ade19c5..6703c778 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -181,18 +181,53 @@ void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { if (tc != null && tc.isUpdated(item, attributes, isDirectory)) { existingJAR = null; // Signal that the existing file is outdated. } - if (files.isEmpty()) { - /* - * In our tests, it seems that the first file after the "-C" option needs to be relative - * to the directory given to "-C" and all other files need to be absolute. This behavior - * does not seem to be documented, but we couldn't get the "jar" tool to work otherwise - * (except by repeating "-C" before each file). - */ - item = directory.relativize(item); - } + /* + * Current version does not relativize paths here, because it seems that we cannot relativize all files + * (see `relativizePaths()` for more information). But we may change this behavior in a future version + * of the Maven JAR plugin if future a JDK version changes the way that the JAR tool handles paths. + */ files.add(item); } + /** + * Relativizes paths in the {@link #files} list according apparently necessary heuristic rules. + * In our tests, it seems that the first file after the {@code -C} option needs to be relative + * to the directory given to {@code -C} and all other files need to be absolute. This behavior + * does not seem to be documented, but we couldn't get the {@code jar} tool to work otherwise + * (except by repeating {@code -C} before each file). + * + * Furthermore, it seems that the relativized file needs to be the shortest one, + * otherwise the {@code jar} tool rejects files after the first one with "names do not match". + * Which file is first depends on the unspecified directory-iteration order, + * so this method needs to search for the shortest path. + */ + private void heuristicallyRelativizePaths() { + Path shortest = null; + int indexOfShortest = 0; + int shortestCount = Integer.MAX_VALUE; + final int size = files.size(); + for (int i = 0; i < size; i++) { + Path item = directory.relativize(files.get(i)); + int count = item.getNameCount(); + if (count < shortestCount) { + shortestCount = count; + indexOfShortest = i; + shortest = item; + if (count <= 1) { + break; // We will not find better. + } + } + } + if (shortest != null) { + if (indexOfShortest != 0) { + files.remove(indexOfShortest); + files.add(0, shortest); + } else { + files.set(0, shortest); + } + } + } + /** * Adds to the given list the arguments to provide to the "jar" tool for this version. * Elements added to the list shall be instances of {@link String} or {@link Path}. @@ -201,6 +236,7 @@ void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { * @param version the target Java release, or {@code null} for the base version of the JAR file */ private void arguments(List addTo, Runtime.Version version) { + heuristicallyRelativizePaths(); if (!files.isEmpty()) { if (version != null) { addTo.add("--release"); diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index a9b263c8..99d6f6a2 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -19,6 +19,8 @@ package org.apache.maven.plugins.jar; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.jar.Attributes; import java.util.jar.Manifest; @@ -157,4 +159,53 @@ void baseReleaseBindingIsIndependentOfDirectoryOrder() { assertNotSame(b.newTargetRelease(base, null), b.newTargetRelease(v16, r16)); assertEquals(base, b.baseRelease().directory); } + + /** + * Ensures that the shortest path is first and relative. + * In our tests, it seems that the first JAR entry after the {@code -C} option + * shall be relative, and only that file. Furthermore, it seems that this file shall be the + * shortest. + */ + @Test + void shortestPathIsRelativeRegardlessOfFileOrder() { + Path classes = Path.of("p/target/classes").toAbsolutePath(); // Absolute, as usual in Maven builds. + Path a = classes.resolve("myproject/HelloWorld.class"); + Path b = classes.resolve("myproject/foo/Utils.class"); + assertFirstEntryRelative(argsAfterAdding(classes, a, b)); + assertFirstEntryRelative(argsAfterAdding(classes, b, a)); // Reverse order. + } + + /** + * Creates arguments for the {@code jar} tool with the given files in order. + * + * @param directory the root directory of the files + * @param files files in the given root directory or sub-directories + * @return arguments for the {@code jar} tool + */ + private static List argsAfterAdding(Path directory, Path... filesInOrder) { + Archive archive = archive("myproject", null, directory); + var base = archive.baseRelease(); + for (Path f : filesInOrder) { + base.add(f, null, false); + } + var args = new ArrayList(); + archive.arguments(args); + return args; + } + + /** + * Asserts that all paths in the argument list are absolute except the first path after the {@code -C} option. + * This assertion methods expect the first path to be always ending with {@code "HelloWorld.class"}. + */ + private static void assertFirstEntryRelative(List args) { + for (Object token : args) { + if (token instanceof Path p) { + if (p.endsWith("HelloWorld.class")) { + assertFalse(p.isAbsolute(), p.toString()); + } else { + assertTrue(p.isAbsolute(), p.toString()); + } + } + } + } } From a59d9fe52f9e94050121e5287b463df35af85398 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Tue, 11 Aug 2026 18:57:00 +0200 Subject: [PATCH 06/25] Fix empty jar file argument for version releases `FileCollector` adds a `META-INF/versions/` directory to a `FileSet` whose `-C` directory is that same directory, so `relativize(dir, dir)` produced an empty path and `arguments()` emitted `-C ""`. An empty file argument is invalid: zulu's jar tool rejects it ("Error parsing file arguments") while temurin's silently tolerated it, so a package-hierarchy multi-release JAR failed only on some JDKs. Emit "." (archive the whole directory) instead of an empty path. Add a unit test asserting the whole-directory entry is "." and no file argument is ever empty. Issue identified by: Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- .../org/apache/maven/plugins/jar/Archive.java | 9 +++++++ .../apache/maven/plugins/jar/ArchiveTest.java | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 6703c778..98902b2e 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -214,6 +214,15 @@ private void heuristicallyRelativizePaths() { indexOfShortest = i; shortest = item; if (count <= 1) { + if (shortest.toString().isEmpty()) { + /* + * The item is the `-C` directory itself (e.g. a `META-INF/versions/` directory + * added as a whole). An empty file argument is invalid for the `jar` tool + * (some implementations reject it, others silently misbehave), + * so archive the whole directory content with ".". + */ + shortest = Path.of("."); + } break; // We will not find better. } } diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index 99d6f6a2..6cebddd7 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -175,6 +175,30 @@ void shortestPathIsRelativeRegardlessOfFileOrder() { assertFirstEntryRelative(argsAfterAdding(classes, b, a)); // Reverse order. } + /** + * Ensures that the arguments given to the {@code jar} tool contain no empty directory. + * A {@code META-INF/versions/} directory is added to a {@link FileSet} whose {@code -C} directory + * is that same directory. Therefore, {@code relativize(dir, dir)} is the empty path. + * An empty jar file argument is invalid, so it must become "." (archive the whole directory content). + */ + @Test + void archivingADirectoryAsAWholeYieldsDotNotEmptyEntry() { + Path versionDir = Path.of("p/target/classes/META-INF/versions/9").toAbsolutePath(); + List args = argsAfterAdding(versionDir, versionDir); + Path jar = versionDir.resolve("out.jar"); + int sawDot = 0; + for (Object o : args) { + if (o instanceof Path p && !p.equals(jar) && !p.equals(versionDir)) { + String s = p.toString(); + assertFalse(s.isEmpty(), "jar file argument must never be empty"); + if (s.equals(".")) { + sawDot++; + } + } + } + assertEquals(1, sawDot, "the whole -C directory must be archived with \".\" exactly once"); + } + /** * Creates arguments for the {@code jar} tool with the given files in order. * From 4617a498d33fd1490c2a88a3a760839862d1420f Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Thu, 13 Aug 2026 15:43:10 +0200 Subject: [PATCH 07/25] Reset base release after skipping a version dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `META-INF/versions/` directory is archived as a whole and its subtree skipped (SKIP_SUBTREE). Files.walkFileTree(…) does not call postVisitDirectory for a skipped subtree, so the reset that normally restores the base release on exiting a version directory was missed. Base files visited afterwards were then added to the last version's file set (as `../../../`), so a package-hierarchy multi-release JAR was mis-assembled on directory-iteration orders where the version directories are walked before the base classes. The jar tool then failed with "unspecified error". Reset the base release inline after archiving a whole version directory. The modular multi-release path descends into module directories instead of skipping, so it is unaffected. Issue identified by: Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- .../maven/plugins/jar/FileCollector.java | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index 4e4f2b7f..50e46ee2 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -79,6 +79,9 @@ final class FileCollector extends SimpleFileVisitor { /** * Whether to detect multi-release JAR files. + * The default value is {@code true}. + * + * @see AbstractJarMojo#detectMultiReleaseJar */ private final boolean detectMultiReleaseJar; @@ -258,6 +261,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt role = DirectoryRole.VERSIONS; } else if (directory.endsWith(VERSIONS_MODULAR)) { if (!detectMultiReleaseJar) { + // Used asked for no multi-release JAR. return FileVisitResult.SKIP_SUBTREE; } role = DirectoryRole.VERSIONS_MODULAR; @@ -273,6 +277,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt * the layout generated by Maven Compiler Plugin. */ if (enterVersionDirectory(directory, true)) { + // An error occurred while parsing the version number. return FileVisitResult.SKIP_SUBTREE; } role = DirectoryRole.RESOURCES; @@ -285,6 +290,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt */ resetToPackageHierarchy(); // No module in particular yet. if (enterVersionDirectory(directory, false)) { + // An error occurred while parsing the version number. return FileVisitResult.SKIP_SUBTREE; } role = DirectoryRole.MODULES; @@ -312,8 +318,20 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt break; } } + /* + * Do not move this condition inside the `switch` block because `role` may have been modified. + * The `role` value is now the role of `directory`, not anymore the role of parent directory. + */ if (acceptsAllFiles && role == DirectoryRole.RESOURCES) { currentFilesToArchive.add(directory, attributes, true); + /* + * Since we are skipping the whole directory, `postVisitDirectory(…)` will not be invoked. + * We must reset `currentFilesToArchive` and `currentTargetVersion` by an explicit call. + * This is important mostly after we added a whole `META-INF/versions/` directory, + * otherwise base files visited afterwards (directory iteration order is unspecified) + * would be added to this version's file set instead of the base release. + */ + resetToParentDirectoryState(); return FileVisitResult.SKIP_SUBTREE; } else { directoryRoles.addLast(role); @@ -322,13 +340,13 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt } /** - * Updates the {@code FileCollector} state if we finished to scan the content of a module. + * Updates the {@code FileCollector} state after we finished to scan the content of a directory. + * The fields to update depend on which directory has been visited (module, version, etc.). * * @param directory the directory which has been traversed * @param error the error that occurred while traversing the directory, or {@code null} if none */ @Override - @SuppressWarnings("checkstyle:MissingSwitchDefault") public FileVisitResult postVisitDirectory(final Path directory, final IOException error) throws IOException { if (error != null) { throw error; @@ -343,22 +361,32 @@ public FileVisitResult postVisitDirectory(final Path directory, final IOExceptio break; default: - switch (directoryRoles.getLast()) { - case VERSIONS: - case VERSIONS_MODULAR: - // Exited the directory for one target Java release. - currentFilesToArchive = currentModule.baseRelease(); - currentTargetVersion = null; - break; - - case META_INF: - checkForManifest = true; - break; - } + resetToParentDirectoryState(); } return FileVisitResult.CONTINUE; } + /** + * Updates {@code FileCollector} to a state suitable to the parent of the directory that we finished to scan. + * Contrarily to {@link #postVisitDirectory(Path, IOException)}, this method expects the last element of + * {@link #directoryRoles} to describe the parent directory, not the directory that we finished to visit. + */ + @SuppressWarnings("checkstyle:MissingSwitchDefault") + private void resetToParentDirectoryState() { + switch (directoryRoles.getLast()) { + case VERSIONS: + case VERSIONS_MODULAR: + // Exited the directory for one target Java release. + currentFilesToArchive = currentModule.baseRelease(); + currentTargetVersion = null; + break; + + case META_INF: + checkForManifest = true; + break; + } + } + /** * Archives a single file if accepted by the matcher. * From 00b5e534619b1e63fdebdc9a3575ac4b23efd614 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sat, 15 Aug 2026 11:53:28 +0200 Subject: [PATCH 08/25] Record all jar entries relative to -C directory FileSet.add() relativized only the first file added and stored the rest as absolute paths; arguments() then emitted a single "-C dir" for all of them. However, this approach was sensitive to iteration order and required the shortest path to be first, otherwise `jar` rejected next paths with "names do not match" error. A less heuristic alternative is to repeat the `-C` option before all files. This alternative was initially avoided because repeating the same directory thousands of times was supposed costly. However, with the current approach, the directory is repeated anyway in the absolute paths. Therefore, the heuristic approach actually did not saved much space. The alternative implemented in this commit (repeat `-C`) is the approach initially provided by Gerd Aschemann in his correction of file order problems. --- .../org/apache/maven/plugins/jar/Archive.java | 101 ++++++++---------- .../apache/maven/plugins/jar/ArchiveTest.java | 38 +++++-- 2 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 98902b2e..d048789d 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -47,6 +47,29 @@ * Many instances of {@code Archive} may exist when archiving a multi-modules project. */ final class Archive { + /** + * Whether to repeat the {@code -C} option before each file. + * Doing so makes the command-line very verbose while the documentation of + * The jar Command + * gives the impression that this option can be provided only once. + * However, our tests suggest that the first file after the directory specified by the {@code -C} option + * must be relative to that directory and all files after the first one must be prefixed by the directory + * which was specified in the {@code -C} option. This behavior is not documented, but we couldn't get the + * {@code jar} tool to work otherwise (except by repeating {@code -C} before each file). + * Furthermore, it seems that the relativized file needs to be the shortest one, + * otherwise the {@code jar} tool rejects files after the first one with "names do not match". + * Which file is first depends on the unspecified directory-iteration order. + * + *

If this flag is {@code true}, the plugin repeats {@code -C} before each file. + * This flag should be set to {@code false} if a future version of the {@code jar} + * tool allows to specify {@code -C} only once.

+ * + *

Historical note: we also tried to relativize only the first file after {@code -C} + * and keep all subsequent files as absolute. It works, but because we have to repeat the directory + * in the file name, it saves only 3 or 4 characters per file compared to repeating {@code -C}.

+ */ + private static final boolean REPEAT_C = true; + /** * Path to the POM file generated for this archive, or {@code null} if none. * This is non-null only if module source hierarchy is used, in which case the dependencies @@ -181,60 +204,17 @@ void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { if (tc != null && tc.isUpdated(item, attributes, isDirectory)) { existingJAR = null; // Signal that the existing file is outdated. } - /* - * Current version does not relativize paths here, because it seems that we cannot relativize all files - * (see `relativizePaths()` for more information). But we may change this behavior in a future version - * of the Maven JAR plugin if future a JDK version changes the way that the JAR tool handles paths. - */ - files.add(item); - } - - /** - * Relativizes paths in the {@link #files} list according apparently necessary heuristic rules. - * In our tests, it seems that the first file after the {@code -C} option needs to be relative - * to the directory given to {@code -C} and all other files need to be absolute. This behavior - * does not seem to be documented, but we couldn't get the {@code jar} tool to work otherwise - * (except by repeating {@code -C} before each file). - * - * Furthermore, it seems that the relativized file needs to be the shortest one, - * otherwise the {@code jar} tool rejects files after the first one with "names do not match". - * Which file is first depends on the unspecified directory-iteration order, - * so this method needs to search for the shortest path. - */ - private void heuristicallyRelativizePaths() { - Path shortest = null; - int indexOfShortest = 0; - int shortestCount = Integer.MAX_VALUE; - final int size = files.size(); - for (int i = 0; i < size; i++) { - Path item = directory.relativize(files.get(i)); - int count = item.getNameCount(); - if (count < shortestCount) { - shortestCount = count; - indexOfShortest = i; - shortest = item; - if (count <= 1) { - if (shortest.toString().isEmpty()) { - /* - * The item is the `-C` directory itself (e.g. a `META-INF/versions/` directory - * added as a whole). An empty file argument is invalid for the `jar` tool - * (some implementations reject it, others silently misbehave), - * so archive the whole directory content with ".". - */ - shortest = Path.of("."); - } - break; // We will not find better. - } - } - } - if (shortest != null) { - if (indexOfShortest != 0) { - files.remove(indexOfShortest); - files.add(0, shortest); - } else { - files.set(0, shortest); - } + item = directory.relativize(item); + if (item.getNameCount() <= 1 && item.toString().isEmpty()) { + /* + * The item is the `-C` directory itself (e.g. a `META-INF/versions/` directory + * added as a whole). An empty file argument is invalid for the `jar` tool + * (some implementations reject it, others silently misbehave), + * so archive the whole directory content with ".". + */ + item = Path.of("."); } + files.add(item); } /** @@ -245,15 +225,22 @@ private void heuristicallyRelativizePaths() { * @param version the target Java release, or {@code null} for the base version of the JAR file */ private void arguments(List addTo, Runtime.Version version) { - heuristicallyRelativizePaths(); if (!files.isEmpty()) { if (version != null) { addTo.add("--release"); addTo.add(version); } - addTo.add("-C"); - addTo.add(directory); - addTo.addAll(files); + if (REPEAT_C) { + for (Path file : files) { + addTo.add("-C"); + addTo.add(directory); + addTo.add(file); + } + } else { + addTo.add("-C"); + addTo.add(directory); + addTo.addAll(files); + } } } diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index 6cebddd7..f59a1e4f 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -161,18 +161,21 @@ void baseReleaseBindingIsIndependentOfDirectoryOrder() { } /** - * Ensures that the shortest path is first and relative. + * Ensures that all path are relative. + * + *

Historical note

* In our tests, it seems that the first JAR entry after the {@code -C} option * shall be relative, and only that file. Furthermore, it seems that this file shall be the - * shortest. + * shortest. We tried to apply this heuristic rules in a branch, but it does not save a lot + * of characters compared to repeating {@code -C}. */ @Test - void shortestPathIsRelativeRegardlessOfFileOrder() { + void jarEntriesAreRelativeRegardlessOfFileOrder() { Path classes = Path.of("p/target/classes").toAbsolutePath(); // Absolute, as usual in Maven builds. Path a = classes.resolve("myproject/HelloWorld.class"); Path b = classes.resolve("myproject/foo/Utils.class"); - assertFirstEntryRelative(argsAfterAdding(classes, a, b)); - assertFirstEntryRelative(argsAfterAdding(classes, b, a)); // Reverse order. + assertAllEntriesRelative(argsAfterAdding(classes, a, b)); + assertAllEntriesRelative(argsAfterAdding(classes, b, a)); // Reverse order. } /** @@ -218,16 +221,29 @@ private static List argsAfterAdding(Path directory, Path... filesInOrder } /** - * Asserts that all paths in the argument list are absolute except the first path after the {@code -C} option. - * This assertion methods expect the first path to be always ending with {@code "HelloWorld.class"}. + * Asserts that every {@link Path} in the jar-tool argument list, other than the values of + * {@code --file}/{@code --manifest}/{@code --main-class} and the {@code -C} directories, + * is a relative path. The later are jar entry name relative to its {@code -C} directory. + * This method shall reflect the policy adopted in the {@code Archive.FileSet.add(…)} implementation. + * Different strategies have been tried in the past, such as making only the first file relative and + * all other files absolute. */ - private static void assertFirstEntryRelative(List args) { + private static void assertAllEntriesRelative(List args) { + boolean foundOptionC = false; + boolean expectDirectory = true; for (Object token : args) { + if ("-C".equals(token)) { + foundOptionC = true; + expectDirectory = true; + } if (token instanceof Path p) { - if (p.endsWith("HelloWorld.class")) { - assertFalse(p.isAbsolute(), p.toString()); - } else { + if (expectDirectory) { assertTrue(p.isAbsolute(), p.toString()); + if (foundOptionC) { + expectDirectory = false; + } + } else { + assertFalse(p.isAbsolute(), p.toString()); } } } From 27199e689c5de3544f117330aa6cd4ffe11cf872 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sat, 15 Aug 2026 13:25:06 +0200 Subject: [PATCH 09/25] Sort the JAR entries in platform-independent order when reproducible build is enabled. In the latter case, we need to enumerate files instead of just providing the directory. --- .../org/apache/maven/plugins/jar/Archive.java | 32 +++++++++++++++ .../maven/plugins/jar/FileCollector.java | 13 +++--- .../maven/plugins/jar/ToolExecutor.java | 9 +++- .../apache/maven/plugins/jar/ArchiveTest.java | 41 ++++++++++++------- 4 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index d048789d..81c5c1e5 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -25,6 +25,7 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -140,6 +141,26 @@ final class Archive { * the base release, and an arbitrary amount of other instances for other target releases. */ final class FileSet { + /** + * A comparator for sorting paths in a reproducible order. + * This comparator assumes that all paths are relative to the same base directory (this is not verified). + * Note: we do not use {@link Path#compareTo(Path)} because the Javadoc said that it is platform dependent. + */ + private static final Comparator REPRODUCIBLE_ORDER = (p1, p2) -> { + final int c1 = p1.getNameCount(); + final int c2 = p2.getNameCount(); + final int c = Math.min(c1, c2); + for (int i = 0; i < c; i++) { + String n1 = p1.getName(i).toString(); + String n2 = p2.getName(i).toString(); + int r = n1.compareTo(n2); // Case-sensitive comparison on all platforms. + if (r != 0) { + return r; + } + } + return c1 - c2; + }; + /** * The root directory of all files or directories to archive. * This is the value to pass to the {@code -C} tool option. @@ -230,6 +251,9 @@ private void arguments(List addTo, Runtime.Version version) { addTo.add("--release"); addTo.add(version); } + if (isReproducible) { + files.sort(REPRODUCIBLE_ORDER); + } if (REPEAT_C) { for (Path file : files) { addTo.add("-C"); @@ -253,6 +277,11 @@ public String toString() { } } + /** + * Whether reproducible build was requested. + */ + private final boolean isReproducible; + /** * Creates an initially empty set of files or directories. * @@ -261,6 +290,7 @@ public String toString() { * @param version the target Java release, or {@code null} for the base version * @param directory the directory of the classes targeting the base Java release * @param forceCreation whether to force a new JAR file even if the content seems unchanged + * @param isReproducible whether reproducible build was requested * @param logger where to send a warning if an error occurred while checking an existing JAR file */ @SuppressWarnings("checkstyle:NeedBraces") @@ -270,9 +300,11 @@ public String toString() { final Runtime.Version version, final Path directory, final boolean forceCreation, + final boolean isReproducible, final Log logger) { this.jarFile = jarFile; this.moduleName = moduleName; + this.isReproducible = isReproducible; filesetForRelease = new TreeMap<>((v1, v2) -> { if (v1 == v2) return 0; if (v1 == null) return -1; diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index 50e46ee2..d9706a23 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -98,10 +98,11 @@ final class FileCollector extends SimpleFileVisitor { private final PathMatcher directoryMatcher; /** - * Whether the matchers accept all files. In such case, we can declare whole directories - * to the {@code jar} tool instead of scaning the directory tree ourselves. + * Whether the matchers accept all files and there is no need to sort the files. + * In such case, we can declare whole directories to the {@code jar} tool instead + * of scanning the directory tree ourselves. */ - private final boolean acceptsAllFiles; + private final boolean addDirectories; /** * Files found in the output directory when package hierarchy is used. @@ -163,7 +164,9 @@ final class FileCollector extends SimpleFileVisitor { directoryRoles = new ArrayDeque<>(); fileMatcher = matcherFactory.createPathMatcher(directory, mojo.getIncludes(), mojo.getExcludes(), false); directoryMatcher = matcherFactory.deriveDirectoryMatcher(fileMatcher); - acceptsAllFiles = matcherFactory.isIncludesAll(directoryMatcher) && matcherFactory.isIncludesAll(fileMatcher); + addDirectories = !context.isReproducible() + && matcherFactory.isIncludesAll(fileMatcher) + && matcherFactory.isIncludesAll(directoryMatcher); packageHierarchy = context.newArchive(null, null, directory); moduleHierarchy = new LinkedHashMap<>(); resetToPackageHierarchy(); @@ -322,7 +325,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt * Do not move this condition inside the `switch` block because `role` may have been modified. * The `role` value is now the role of `directory`, not anymore the role of parent directory. */ - if (acceptsAllFiles && role == DirectoryRole.RESOURCES) { + if (addDirectories && role == DirectoryRole.RESOURCES) { currentFilesToArchive.add(directory, attributes, true); /* * Since we are skipping the whole directory, `postVisitDirectory(…)` will not be invoked. diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index edc1f0c4..eae575cd 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -259,7 +259,14 @@ Archive newArchive(final String moduleName, final Runtime.Version version, final sb.append('-').append(classifier); } String filename = sb.append(".jar").toString(); - return new Archive(outputDirectory.resolve(filename), moduleName, version, directory, forceCreation, logger); + return new Archive( + outputDirectory.resolve(filename), + moduleName, + version, + directory, + forceCreation, + isReproducible(), + logger); } /** diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index f59a1e4f..cab14d1d 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; +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.assertNotSame; @@ -43,8 +44,8 @@ class ArchiveTest { * Creates an {@code Archive} suitable for a unit test. {@code forceCreation = true} skips the * existing-JAR timestamp check, so the (here {@code null}) logger is never dereferenced. */ - private static Archive archive(String moduleName, Runtime.Version version, Path directory) { - return new Archive(directory.resolve("out.jar"), moduleName, version, directory, true, null); + private static Archive archive(String moduleName, Runtime.Version version, Path directory, boolean isReproducible) { + return new Archive(directory.resolve("out.jar"), moduleName, version, directory, true, isReproducible, null); } /** @@ -80,7 +81,7 @@ private static Object mainClassOf(Manifest m) { */ @Test void owningModuleClaimsMainClassAndRemovesItFromManifest() { - Archive owner = archive("foo.bar", null, Path.of(".")); + Archive owner = archive("foo.bar", null, Path.of("."), false); Manifest m = manifestWithMainClass("foo.bar/foo.MainFile"); // The owner keeps the main class (emitted via --main-class) ... assertTrue(owner.setMainClass(m)); @@ -96,7 +97,7 @@ void owningModuleClaimsMainClassAndRemovesItFromManifest() { */ @Test void nonOwningModuleRejectsMainClass() { - Archive nonOwner = archive("foo.bar.more", null, Path.of(".")); + Archive nonOwner = archive("foo.bar.more", null, Path.of("."), false); Manifest m = manifestWithMainClass("foo.bar/foo.MainFile"); assertFalse(nonOwner.setMainClass(m)); assertNull(mainClassOf(m)); @@ -129,8 +130,8 @@ private static void assertOwnership(String first, String second, boolean ownerIs final Manifest shared = manifestWithMainClass("foo.bar/foo.MainFile"); final Manifest m1 = new Manifest(shared); final Manifest m2 = new Manifest(shared); - assertEquals(ownerIsFirst, archive(first, null, path).setMainClass(m1)); - assertEquals(!ownerIsFirst, archive(second, null, path).setMainClass(m2)); + assertEquals(ownerIsFirst, archive(first, null, path, false).setMainClass(m1)); + assertEquals(!ownerIsFirst, archive(second, null, path, false).setMainClass(m2)); // Per-module copies must leave the shared plugin manifest untouched. assertEquals("foo.bar/foo.MainFile", mainClassOf(shared)); assertNull(mainClassOf(m1)); @@ -150,18 +151,19 @@ void baseReleaseBindingIsIndependentOfDirectoryOrder() { final Runtime.Version r16 = Runtime.Version.parse("16"); // Version-directory first (the failing order): Archive seeded from v16, base registered later. - final Archive a = archive("foo.bar", r16, v16); + final Archive a = archive("foo.bar", r16, v16, false); assertNotSame(a.newTargetRelease(v16, r16), a.newTargetRelease(base, null)); assertEquals(base, a.baseRelease().directory, "base must rebind to the version-less directory"); // Base-directory first: still correct. - final Archive b = archive("foo.bar", null, base); + final Archive b = archive("foo.bar", null, base, false); assertNotSame(b.newTargetRelease(base, null), b.newTargetRelease(v16, r16)); assertEquals(base, b.baseRelease().directory); } /** * Ensures that all path are relative. + * Opportunistically verifies that reproducible build sorts the files. * *

Historical note

* In our tests, it seems that the first JAR entry after the {@code -C} option @@ -174,8 +176,12 @@ void jarEntriesAreRelativeRegardlessOfFileOrder() { Path classes = Path.of("p/target/classes").toAbsolutePath(); // Absolute, as usual in Maven builds. Path a = classes.resolve("myproject/HelloWorld.class"); Path b = classes.resolve("myproject/foo/Utils.class"); - assertAllEntriesRelative(argsAfterAdding(classes, a, b)); - assertAllEntriesRelative(argsAfterAdding(classes, b, a)); // Reverse order. + String[] ordered = {"HelloWorld.class", "Utils.class"}; + String[] reverse = {"Utils.class", "HelloWorld.class"}; + assertArrayEquals(ordered, assertAllEntriesRelative(argsAfterAdding(classes, false, a, b))); + assertArrayEquals(reverse, assertAllEntriesRelative(argsAfterAdding(classes, false, b, a))); + assertArrayEquals(ordered, assertAllEntriesRelative(argsAfterAdding(classes, true, a, b))); + assertArrayEquals(ordered, assertAllEntriesRelative(argsAfterAdding(classes, true, b, a))); } /** @@ -187,7 +193,7 @@ void jarEntriesAreRelativeRegardlessOfFileOrder() { @Test void archivingADirectoryAsAWholeYieldsDotNotEmptyEntry() { Path versionDir = Path.of("p/target/classes/META-INF/versions/9").toAbsolutePath(); - List args = argsAfterAdding(versionDir, versionDir); + List args = argsAfterAdding(versionDir, false, versionDir); Path jar = versionDir.resolve("out.jar"); int sawDot = 0; for (Object o : args) { @@ -206,11 +212,12 @@ void archivingADirectoryAsAWholeYieldsDotNotEmptyEntry() { * Creates arguments for the {@code jar} tool with the given files in order. * * @param directory the root directory of the files + * @param isReproducible whether reproducible build was requested * @param files files in the given root directory or sub-directories * @return arguments for the {@code jar} tool */ - private static List argsAfterAdding(Path directory, Path... filesInOrder) { - Archive archive = archive("myproject", null, directory); + private static List argsAfterAdding(Path directory, boolean isReproducible, Path... filesInOrder) { + Archive archive = archive("myproject", null, directory, isReproducible); var base = archive.baseRelease(); for (Path f : filesInOrder) { base.add(f, null, false); @@ -227,8 +234,12 @@ private static List argsAfterAdding(Path directory, Path... filesInOrder * This method shall reflect the policy adopted in the {@code Archive.FileSet.add(…)} implementation. * Different strategies have been tried in the past, such as making only the first file relative and * all other files absolute. + * + * @param args the arguments as a mix of {@link String} and {@link Path} elements + * @return the filenames of the relative paths */ - private static void assertAllEntriesRelative(List args) { + private static String[] assertAllEntriesRelative(final List args) { + final var filenames = new ArrayList(2); boolean foundOptionC = false; boolean expectDirectory = true; for (Object token : args) { @@ -244,8 +255,10 @@ private static void assertAllEntriesRelative(List args) { } } else { assertFalse(p.isAbsolute(), p.toString()); + filenames.add(p.getFileName().toString()); } } } + return filenames.toArray(String[]::new); } } From 16ff48a99f4f078f68caf23a76ec66a9c85640f9 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sun, 16 Aug 2026 19:10:47 +0200 Subject: [PATCH 10/25] Document the fact that the `--date` option requires JDK 19 or later. Version detecting in `AbstractMojo` done in the same way as in `ToolExecutor`. --- .../maven/plugins/jar/AbstractJarMojo.java | 22 ++++--------------- .../maven/plugins/jar/ToolExecutor.java | 5 +++++ 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index df93deff..5b8f23a8 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -18,8 +18,6 @@ */ package org.apache.maven.plugins.jar; -import javax.lang.model.SourceVersion; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -150,6 +148,8 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * environment variable is used as a fallback value, * to ease forcing Reproducible Build externally when the build has not enabled it natively in POM. * + *

This property is supported only with Java Development Kit (JDK) version 19 or later.

+ * * @since 3.2.0 */ @Parameter(defaultValue = "${project.build.outputTimestamp}") @@ -223,20 +223,6 @@ protected ToolProvider getJarTool() throws MojoException { return ToolProvider.findFirst(toolId).orElseThrow(() -> new MojoException("No such \"" + toolId + "\" tool.")); } - /** - * Returns whether the specified Java version is supported. - * - * @param release name of an {@link SourceVersion} enumeration constant - * @return whether the current environment support that version - */ - private static boolean isSupported(String release) { - try { - return SourceVersion.latestSupported().compareTo(SourceVersion.valueOf(release)) >= 0; - } catch (IllegalArgumentException e) { - return false; - } - } - /** * Returns the output time stamp or, as a fallback, the {@code SOURCE_DATE_EPOCH} environment variable. * If the time stamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is. @@ -253,8 +239,8 @@ protected String getOutputTimestamp() { return null; } } - if (!isSupported("RELEASE_19")) { - log.warn("Reproducible build requires Java 19 or later."); + if (Runtime.version().feature() < ToolExecutor.JDK_SUPPORT_DATE) { + log.warn("Reproducible build requires Java " + ToolExecutor.JDK_SUPPORT_DATE + " or later."); return null; } for (int i = time.length(); --i >= 0; ) { diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index eae575cd..460e12c1 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -57,6 +57,11 @@ final class ToolExecutor { */ private static final int JDK_FIXING_JAR_VALIDATE = 19; + /** + * First JDK feature version whose {@code jar} tool support the {@code --date} option. + */ + static final int JDK_SUPPORT_DATE = 19; + /** * The Maven project for which to create an archive. */ From d928d41420a3f29b740d3c038549b15cf687d1d9 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 17 Aug 2026 11:39:52 +0200 Subject: [PATCH 11/25] Automatically create the `target` directory if it does not exist. Co-Authored-By: Gerd Aschemann --- src/it/jar-without-sources/invoker.properties | 18 ++++++++ src/it/jar-without-sources/pom.xml | 44 ++++++++++++++++++ src/it/jar-without-sources/verify.groovy | 46 +++++++++++++++++++ .../maven/plugins/jar/AbstractJarMojo.java | 23 ++++++++++ .../maven/plugins/jar/ToolExecutor.java | 4 +- 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/it/jar-without-sources/invoker.properties create mode 100644 src/it/jar-without-sources/pom.xml create mode 100644 src/it/jar-without-sources/verify.groovy diff --git a/src/it/jar-without-sources/invoker.properties b/src/it/jar-without-sources/invoker.properties new file mode 100644 index 00000000..a644cb7e --- /dev/null +++ b/src/it/jar-without-sources/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = clean package diff --git a/src/it/jar-without-sources/pom.xml b/src/it/jar-without-sources/pom.xml new file mode 100644 index 00000000..3b990ad5 --- /dev/null +++ b/src/it/jar-without-sources/pom.xml @@ -0,0 +1,44 @@ + + + + 4.0.0 + org.apache.maven.plugins + jar-without-sources + 1.0-SNAPSHOT + jar + jar-without-sources-it + This project has neither `src/main` nor `src/test`, so nothing is compiled + and the `target` directory is never created by an earlier lifecycle phase. + Versions 3.x of the JAR plugin still produced an empty, manifest-only JAR in that case. + This IT verifies that versions 4.x of the JAR plugin has the same behavior for compatibility purposes. + + NOTE: it does not mean that it is a good idea to produce an empty JAR in such case. + It would also be a reasonable choice if a future version decide to fail the build instead. + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + diff --git a/src/it/jar-without-sources/verify.groovy b/src/it/jar-without-sources/verify.groovy new file mode 100644 index 00000000..eff05af6 --- /dev/null +++ b/src/it/jar-without-sources/verify.groovy @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.util.jar.JarFile + +File artifact = new File(basedir, "target/jar-without-sources-1.0-SNAPSHOT.jar") +if (!artifact.isFile()) { + System.err.println("Expected JAR is missing: " + artifact) + return false +} + +JarFile jar = new JarFile(artifact) +try { + // The manifest must be present. + if (jar.getEntry("META-INF/MANIFEST.MF") == null) { + System.err.println("JAR does not contain META-INF/MANIFEST.MF") + return false + } + // As there is no source directory, the JAR must not contain any compiled class. + for (entry in jar.entries()) { + if (entry.getName().endsWith(".class")) { + System.err.println("JAR unexpectedly contains a class entry: " + entry.getName()) + return false + } + } +} finally { + jar.close() +} + +return true diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 5b8f23a8..1c6e75a8 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -266,6 +266,29 @@ protected List getExcludes() { return asList(excludes); } + /** + * Returns the output directory and ensures that the directory exists. + * The returned directory will be either {@link #outputDirectory} if non-null, + * or {@link org.apache.maven.api.model.Build#getDirectory()} otherwise. + * + *

The directory is usually {@code target} and should always exist since it is usually the output + * of the compiler plugin. If nevertheless the directory does not exist, then this method creates it. + * However, this method does not try to create the parent directory, which should be the Maven sub-project.

+ * + * @return the directory containing the generated JAR files. + * @throws IOException if the output directory did not existed and could not be created + */ + protected Path getOutputDirectory() throws IOException { + Path dir = outputDirectory; + if (dir == null) { + dir = Path.of(project.getBuild().getDirectory()); + } + if (Files.notExists(dir)) { + dir = Files.createDirectory(dir); + } + return dir; + } + /** * Returns the given elements as a list if non-null. * diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 460e12c1..5e1ac270 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -187,9 +187,7 @@ final class ToolExecutor { ToolExecutor(AbstractJarMojo mojo, Manifest manifest, MavenArchiveConfiguration archive) throws IOException { project = mojo.project; artifactType = mojo.getType(); - outputDirectory = (mojo.outputDirectory != null) - ? mojo.outputDirectory - : Path.of(project.getBuild().getDirectory()); + outputDirectory = mojo.getOutputDirectory(); classifier = AbstractJarMojo.nullIfAbsent(mojo.getClassifier()); finalName = (mojo.finalName != null) ? mojo.finalName : project.getBuild().getFinalName(); From 27191bab727bb1ea904f5deddf51d0ba7fb5365c Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 24 Aug 2026 01:12:03 +0200 Subject: [PATCH 12/25] Change strategy in the way to exclude files. Instead of enumerating all included files, move excluded files in a temporary directory then add the original directory as a whole. The intend is to let the `jar` plugin generates directory entries. --- .../org/apache/maven/plugins/jar/Archive.java | 123 ++++++----------- .../maven/plugins/jar/ExcludedFiles.java | 129 ++++++++++++++++++ .../maven/plugins/jar/FileCollector.java | 90 +++++++----- .../maven/plugins/jar/TimestampCheck.java | 74 ++++++---- .../maven/plugins/jar/ToolExecutor.java | 9 +- .../apache/maven/plugins/jar/ArchiveTest.java | 41 +++--- 6 files changed, 294 insertions(+), 172 deletions(-) create mode 100644 src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 81c5c1e5..a6dca26a 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -25,7 +25,6 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; -import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -48,29 +47,6 @@ * Many instances of {@code Archive} may exist when archiving a multi-modules project. */ final class Archive { - /** - * Whether to repeat the {@code -C} option before each file. - * Doing so makes the command-line very verbose while the documentation of - * The jar Command - * gives the impression that this option can be provided only once. - * However, our tests suggest that the first file after the directory specified by the {@code -C} option - * must be relative to that directory and all files after the first one must be prefixed by the directory - * which was specified in the {@code -C} option. This behavior is not documented, but we couldn't get the - * {@code jar} tool to work otherwise (except by repeating {@code -C} before each file). - * Furthermore, it seems that the relativized file needs to be the shortest one, - * otherwise the {@code jar} tool rejects files after the first one with "names do not match". - * Which file is first depends on the unspecified directory-iteration order. - * - *

If this flag is {@code true}, the plugin repeats {@code -C} before each file. - * This flag should be set to {@code false} if a future version of the {@code jar} - * tool allows to specify {@code -C} only once.

- * - *

Historical note: we also tried to relativize only the first file after {@code -C} - * and keep all subsequent files as absolute. It works, but because we have to repeat the directory - * in the file name, it saves only 3 or 4 characters per file compared to repeating {@code -C}.

- */ - private static final boolean REPEAT_C = true; - /** * Path to the POM file generated for this archive, or {@code null} if none. * This is non-null only if module source hierarchy is used, in which case the dependencies @@ -141,26 +117,6 @@ final class Archive { * the base release, and an arbitrary amount of other instances for other target releases. */ final class FileSet { - /** - * A comparator for sorting paths in a reproducible order. - * This comparator assumes that all paths are relative to the same base directory (this is not verified). - * Note: we do not use {@link Path#compareTo(Path)} because the Javadoc said that it is platform dependent. - */ - private static final Comparator REPRODUCIBLE_ORDER = (p1, p2) -> { - final int c1 = p1.getNameCount(); - final int c2 = p2.getNameCount(); - final int c = Math.min(c1, c2); - for (int i = 0; i < c; i++) { - String n1 = p1.getName(i).toString(); - String n2 = p2.getName(i).toString(); - int r = n1.compareTo(n2); // Case-sensitive comparison on all platforms. - if (r != 0) { - return r; - } - } - return c1 - c2; - }; - /** * The root directory of all files or directories to archive. * This is the value to pass to the {@code -C} tool option. @@ -171,6 +127,8 @@ final class FileSet { /** * The files or directories to include in the JAR file. * May be absolute paths or paths relative to {@link #directory}. + * It usually contains only the files or directories directly in + * the root {@linkplain #directory}, not in sub-directories. */ @Nonnull final List files; @@ -195,7 +153,6 @@ private FileSet(Path directory) { */ private Path discardAllFiles(Path base) { for (Path file : files) { - file = directory.resolve(file); if (base == null) { base = file.getParent(); } else { @@ -213,58 +170,66 @@ private Path discardAllFiles(Path base) { /** * Adds the given path to the list of files or directories to archive. - * This method may store a relative path instead of the absolute path. + * If the given path is a directory, then all children will be included. + * Children to exclude, if any, should be managed by {@link ExcludedFiles}. * * @param item a file or directory to archive * @param attributes the file's basic attributes * @param isDirectory whether the file is a directory - * @throws IllegalArgumentException if the given path cannot be made relative to the base directory */ void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { TimestampCheck tc = existingJAR; if (tc != null && tc.isUpdated(item, attributes, isDirectory)) { existingJAR = null; // Signal that the existing file is outdated. } - item = directory.relativize(item); - if (item.getNameCount() <= 1 && item.toString().isEmpty()) { - /* - * The item is the `-C` directory itself (e.g. a `META-INF/versions/` directory - * added as a whole). An empty file argument is invalid for the `jar` tool - * (some implementations reject it, others silently misbehave), - * so archive the whole directory content with ".". - */ - item = Path.of("."); - } files.add(item); } /** * Adds to the given list the arguments to provide to the "jar" tool for this version. - * Elements added to the list shall be instances of {@link String} or {@link Path}. + * The elements added to the list will be instances of {@link String} or {@link Path}. + * + *

Note about the {@code -C} option

+ * This method repeats the {@code -C} option before each file. + * Our tests suggest that the first file after the directory specified by the {@code -C} option must + * be relative to that directory and all files after the first one must be prefixed by the directory + * which was specified in the {@code -C} option. This behavior is not very intuitive and replying on + * it may be fragile. Furthermore, it seems that the relativized file needs to be the shortest one, + * otherwise the {@code jar} tool rejects files after the first one with "names do not match". + * Which file is first depends on the unspecified directory-iteration order. + * Repeating the {@code -C} option for each file seems safer. * * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances * @param version the target Java release, or {@code null} for the base version of the JAR file + * @throws IllegalArgumentException if a path cannot be made relative to the base directory */ private void arguments(List addTo, Runtime.Version version) { - if (!files.isEmpty()) { - if (version != null) { - addTo.add("--release"); - addTo.add(version); - } - if (isReproducible) { - files.sort(REPRODUCIBLE_ORDER); + if (files.isEmpty()) { + return; + } + if (version != null) { + addTo.add("--release"); + addTo.add(version); + } + Path previous = null; + for (Path file : files) { + if (previous != null && file.startsWith(previous)) { + // Already added a parent directory. + continue; } - if (REPEAT_C) { - for (Path file : files) { - addTo.add("-C"); - addTo.add(directory); - addTo.add(file); - } - } else { - addTo.add("-C"); - addTo.add(directory); - addTo.addAll(files); + previous = file; + file = directory.relativize(file); + if (file.getNameCount() <= 1 && file.toString().isEmpty()) { + /* + * The `-C` directory itself (e.g. a "META-INF/versions/" directory added as a whole). + * An empty file argument is invalid for the `jar` tool (some implementations reject it, + * others silently misbehave), so archive the whole directory content with ".". + */ + file = Path.of("."); } + addTo.add("-C"); + addTo.add(directory); + addTo.add(file); } } @@ -277,11 +242,6 @@ public String toString() { } } - /** - * Whether reproducible build was requested. - */ - private final boolean isReproducible; - /** * Creates an initially empty set of files or directories. * @@ -290,7 +250,6 @@ public String toString() { * @param version the target Java release, or {@code null} for the base version * @param directory the directory of the classes targeting the base Java release * @param forceCreation whether to force a new JAR file even if the content seems unchanged - * @param isReproducible whether reproducible build was requested * @param logger where to send a warning if an error occurred while checking an existing JAR file */ @SuppressWarnings("checkstyle:NeedBraces") @@ -300,11 +259,9 @@ public String toString() { final Runtime.Version version, final Path directory, final boolean forceCreation, - final boolean isReproducible, final Log logger) { this.jarFile = jarFile; this.moduleName = moduleName; - this.isReproducible = isReproducible; filesetForRelease = new TreeMap<>((v1, v2) -> { if (v1 == v2) return 0; if (v1 == null) return -1; diff --git a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java new file mode 100644 index 00000000..5ccddf3f --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugins.jar; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; + +/** + * A list of files to temporarily move outside the directory to package in a JAR archive. + * This is used for excluding files from the JAR archive according include/exclude filters. + * We move these files for making possible to specify the whole directory to the {@code jar} tool. + * This approach is used instead of enumerating files in arguments given to the {@code jar} tool because + * such enumeration can not contain directory entries (otherwise the whole directory would be included). + * Some software such as Spring applications component scan relies on the presence of directory entries. + */ +final class ExcludedFiles implements Closeable { + /** + * The paths of files or directories to temporarily move in another directory. + */ + private final Path[] original; + + /** + * The paths where files or directories were moved. + * For each index i, the original path of {@code moved[i]} was {@code original[i]}. + */ + private final Path[] moved; + + /** + * Temporary directory which will contain the {@code moved} files. + * It should be a parent directory of {@link #original} files for + * increasing the chances that it is on the same file system. + */ + private final Path temporaryDirectory; + + /** + * Creates a new list of files to move in a temporary directory. + * + * @param directory the directory which was scanned for files to include in the JAR + * @param files paths of files or directories to temporarily move in another directory + * @throws IOException if an error occurred while creating the temporary directory. + */ + ExcludedFiles(final Path directory, final List files) throws IOException { + original = files.toArray(Path[]::new); + moved = new Path[original.length]; + temporaryDirectory = Files.createTempDirectory(directory, "excluded-"); + } + + /** + * Moves the files now. This method should be invoked inside the "try with resource" block. + * + * @throws IOException if an error occurred while moving a file. + */ + public void move() throws IOException { + for (int i = 0; i < original.length; i++) { + final Path source = original[i]; + String prefix = source.getFileName().toString(); + String suffix = null; + int s = prefix.lastIndexOf('.'); + if (s > 0) { + suffix = prefix.substring(s); + prefix = prefix.substring(0, s); + } + Path target = Files.createTempFile(temporaryDirectory, prefix, suffix); + try { + moved[i] = Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + try { + Files.delete(target); + } catch (IOException e2) { + e.addSuppressed(e2); + } + throw e; + } + } + } + + /** + * Moves the temporary files back to their original locations. + * This method can be invoked even if only a subset of the files were moved. + * The latter may happen if an error occurred in the middle of {@link #move()} execution. + * + * @throws IOException if an error occurred while moving a temporary files or deleting the temporary directory. + */ + @Override + public void close() throws IOException { + IOException e = null; + for (int i = moved.length; --i >= 0; ) { + final Path source = moved[i]; + if (source != null) { + final Path target = original[i]; + try { + Files.move(source, target); + moved[i] = null; + } catch (IOException s) { + if (e != null) { + e.addSuppressed(s); + } else { + e = s; + } + } + } + } + if (e != null) { + throw e; + // Do not try to delete the temporary directory because it is non-empty. + } + Files.delete(temporaryDirectory); + } +} diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index d9706a23..d45273ca 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -26,6 +26,7 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; import java.util.LinkedHashMap; import java.util.List; @@ -36,7 +37,7 @@ import org.apache.maven.api.services.PathMatcherFactory; /** - * Dispatch the files in the output directory into the JAR files to create. + * Dispatch the files from the output directory into the JAR files to create. * Instead of just archiving as-is the content of the output directory, this class separates * the following subdirectories to the options listed below: * @@ -85,6 +86,11 @@ final class FileCollector extends SimpleFileVisitor { */ private final boolean detectMultiReleaseJar; + /** + * The root directory to traverse. It will be used for temporarily moving excluded files. + */ + private final Path rootDirectory; + /** * Combination of includes and excludes path matcher applied on files. */ @@ -98,11 +104,17 @@ final class FileCollector extends SimpleFileVisitor { private final PathMatcher directoryMatcher; /** - * Whether the matchers accept all files and there is no need to sort the files. - * In such case, we can declare whole directories to the {@code jar} tool instead - * of scanning the directory tree ourselves. + * Files or directories to exclude. These files will be moved to a temporary location + * for allowing {@link ToolExecutor} to specify whole directories to the {@code jar} tool. + * Specifying whole directories is preferable to enumerating the files because otherwise, + * the generated JAR file contains only entries for the files and is missing + * entries for the directories. + * + *

This field is {@code null} if it is not possible to have any excluded file + * (because there is no include/exclude filters).

*/ - private final boolean addDirectories; + @Nullable + private final List exclusion; /** * Files found in the output directory when package hierarchy is used. @@ -160,13 +172,16 @@ final class FileCollector extends SimpleFileVisitor { */ FileCollector(AbstractJarMojo mojo, ToolExecutor context, Path directory, PathMatcherFactory matcherFactory) { this.context = context; + rootDirectory = directory; detectMultiReleaseJar = mojo.detectMultiReleaseJar; directoryRoles = new ArrayDeque<>(); fileMatcher = matcherFactory.createPathMatcher(directory, mojo.getIncludes(), mojo.getExcludes(), false); directoryMatcher = matcherFactory.deriveDirectoryMatcher(fileMatcher); - addDirectories = !context.isReproducible() - && matcherFactory.isIncludesAll(fileMatcher) - && matcherFactory.isIncludesAll(directoryMatcher); + if (matcherFactory.isIncludesAll(fileMatcher) && matcherFactory.isIncludesAll(directoryMatcher)) { + exclusion = null; + } else { + exclusion = new ArrayList<>(); + } packageHierarchy = context.newArchive(null, null, directory); moduleHierarchy = new LinkedHashMap<>(); resetToPackageHierarchy(); @@ -234,6 +249,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt role = DirectoryRole.ROOT; } else { if (!directoryMatcher.matches(directory)) { + exclusion.add(directory); // Cannot be null if excluded directories may exist. return FileVisitResult.SKIP_SUBTREE; } checkForManifest = false; @@ -325,21 +341,22 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt * Do not move this condition inside the `switch` block because `role` may have been modified. * The `role` value is now the role of `directory`, not anymore the role of parent directory. */ - if (addDirectories && role == DirectoryRole.RESOURCES) { + if (role == DirectoryRole.RESOURCES) { currentFilesToArchive.add(directory, attributes, true); - /* - * Since we are skipping the whole directory, `postVisitDirectory(…)` will not be invoked. - * We must reset `currentFilesToArchive` and `currentTargetVersion` by an explicit call. - * This is important mostly after we added a whole `META-INF/versions/` directory, - * otherwise base files visited afterwards (directory iteration order is unspecified) - * would be added to this version's file set instead of the base release. - */ - resetToParentDirectoryState(); - return FileVisitResult.SKIP_SUBTREE; - } else { - directoryRoles.addLast(role); - return FileVisitResult.CONTINUE; + if (exclusion == null) { + /* + * Since we are skipping the whole directory, `postVisitDirectory(…)` will not be invoked. + * We must reset `currentFilesToArchive` and `currentTargetVersion` by an explicit call. + * This is important mostly after we added a whole `META-INF/versions/` directory, + * otherwise base files visited afterwards (directory iteration order is unspecified) + * would be added to this version's file set instead of the base release. + */ + resetToParentDirectoryState(); + return FileVisitResult.SKIP_SUBTREE; + } } + directoryRoles.addLast(role); + return FileVisitResult.CONTINUE; } /** @@ -355,16 +372,17 @@ public FileVisitResult postVisitDirectory(final Path directory, final IOExceptio throw error; } switch (directoryRoles.removeLast()) { + case ROOT: + break; + case NAMED_MODULE: // Exited the directory of a single module. resetToPackageHierarchy(); break; - case ROOT: - break; - default: resetToParentDirectoryState(); + break; } return FileVisitResult.CONTINUE; } @@ -404,6 +422,8 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr } else { currentFilesToArchive.add(file, attributes, false); } + } else { + exclusion.add(file); // Cannot be null if excluded files may exist. } return FileVisitResult.CONTINUE; } @@ -461,14 +481,20 @@ Path handleOrphanFiles() { * @throws IOException if an error occurred while reading or writing a manifest file */ Path writeAllJARs(final ToolExecutor executor) throws IOException { - for (Archive module : moduleHierarchy.values()) { - executor.writeSingleJAR(this, module); - } - if (executor.pomDerivation != null) { - return executor.pomDerivation.writeParentPOM(packageHierarchy); - } - if (!packageHierarchy.isEmpty()) { - executor.writeSingleJAR(this, packageHierarchy); + try (ExcludedFiles moved = + (exclusion == null || exclusion.isEmpty()) ? null : new ExcludedFiles(rootDirectory, exclusion)) { + if (moved != null) { + moved.move(); + } + for (Archive module : moduleHierarchy.values()) { + executor.writeSingleJAR(this, module); + } + if (executor.pomDerivation != null) { + return executor.pomDerivation.writeParentPOM(packageHierarchy); + } + if (!packageHierarchy.isEmpty()) { + executor.writeSingleJAR(this, packageHierarchy); + } } return null; } diff --git a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java index 59287cae..003ba88c 100644 --- a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -27,8 +27,8 @@ import java.nio.file.attribute.FileTime; import java.util.Collection; import java.util.Enumeration; -import java.util.HashSet; -import java.util.Set; +import java.util.HashMap; +import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -75,15 +75,17 @@ final class TimestampCheck extends SimpleFileVisitor { * Files found in the JAR file but not yet traversed by the file visitor. * Files are added lazily only when needed, and removed as soon as they have been traversed. * Path are absolute (resolved with {@link #classesDir}). + * For each entry, the associated value is whether the path is a directory. */ - private final Set filesInJAR; + private final Map filesInJAR; /** * Some of the files in the build directory. This list contains only the files for which we have already * verified the timestamp. We store them in a separated list for avoiding to check the timestamp twice. * We need this list because we still need to verify if the files are in the {@link #jarFile}. + * For each entry, the associated value is whether the path is a directory. */ - private final Set filesInBuild; + private final Map filesInBuild; /** * Whether at least one file is more recent than the JAR file. @@ -105,8 +107,8 @@ final class TimestampCheck extends SimpleFileVisitor { this.jarFile = jarFile; this.logger = logger; jarFileTime = Files.getLastModifiedTime(jarFile); - filesInJAR = new HashSet<>(); - filesInBuild = new HashSet<>(); + filesInJAR = new HashMap<>(); + filesInBuild = new HashMap<>(); } /** @@ -115,15 +117,13 @@ final class TimestampCheck extends SimpleFileVisitor { * @param file the file to check * @param attributes the file's basic attributes * @param isDirectory whether the file is a directory - * @return whether the modification time is more recent that the JAR file + * @return whether the modification time is more recent than the JAR file */ boolean isUpdated(final Path file, final BasicFileAttributes attributes, final boolean isDirectory) { if (jarFileTime.compareTo(attributes.lastModifiedTime()) < 0) { return true; } - if (!isDirectory) { - filesInBuild.add(file); - } + filesInBuild.put(file, isDirectory); return false; } @@ -137,29 +137,37 @@ boolean isUpToDateJAR(final Collection fileSets) { // No need to use JarFile because no need to handle META-INF in a special way. try (ZipFile jar = new ZipFile(jarFile.toFile())) { entries = jar.entries(); - for (Path file : filesInBuild) { + for (Path file : filesInBuild.keySet()) { if (!isFoundInJAR(file)) { return false; } } + // Verify the timestamps of files that were not verified by `isUpdate(…)`. for (Archive.FileSet fileSet : fileSets) { - final Path baseDir = fileSet.directory; + Path directory = null; for (Path file : fileSet.files) { - file = baseDir.resolve(file); - if (!filesInBuild.remove(file)) { // For skipping the files already verified by above loop. - Files.walkFileTree(file, this); - if (hasUpdates) { + final Boolean isDirectory = filesInBuild.remove(file); + if (isDirectory == null) { // For skipping the files already verified by the first loop. + if (hasUpdatedInSubdir(directory)) { return false; } + directory = null; + } else if (isDirectory) { + // Because of files order, it is sufficient to remember only the last directory. + directory = file; } } + if (hasUpdatedInSubdir(directory)) { + return false; + } } // Check for remaining files in the JAR which were not in the build directory. - for (Path file : filesInJAR) { - if (!isIgnored(classesDir.relativize(file))) { + for (Map.Entry entry : filesInJAR.entrySet()) { + if (!(entry.getValue() || isIgnored(classesDir.relativize(entry.getKey())))) { return false; } } + filesInJAR.clear(); while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); if (!(entry.isDirectory() || isIgnored(Path.of(entry.getName())))) { @@ -195,6 +203,23 @@ private static boolean isIgnored(Path file) { return false; } + /** + * Returns whether a file in the given directory has been updated. + * + * @param directory the directory to traverse, or {@code null} + * @return whether the given directory contains at least one updated file + * @throws IOException if an error occurred during the directory traversal + */ + private boolean hasUpdatedInSubdir(final Path directory) throws IOException { + if (directory != null) { + Files.walkFileTree(directory, this); + if (hasUpdates) { + return true; + } + } + return false; + } + /** * Checks if the given file is new or more recent than the JAR file. * Checks also if the file exists in the JAR file. @@ -214,23 +239,22 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr /** * Returns whether the given file is found in the JAR file. + * If the file is found, it is removed from the {@link #filesInJAR} map. * * @param file the file to check * @return whether the given file was found in the JAR file */ private boolean isFoundInJAR(final Path file) { - if (filesInJAR.remove(file)) { + if (filesInJAR.remove(file) != null) { return true; } while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); - if (!entry.isDirectory()) { - Path p = classesDir.resolve(entry.getName()); - if (p.equals(file)) { - return true; - } - filesInJAR.add(p); + Path p = classesDir.resolve(entry.getName()); + if (p.equals(file)) { + return true; } + filesInJAR.put(p, entry.isDirectory()); } return false; } diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 5e1ac270..858df8d9 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -262,14 +262,7 @@ Archive newArchive(final String moduleName, final Runtime.Version version, final sb.append('-').append(classifier); } String filename = sb.append(".jar").toString(); - return new Archive( - outputDirectory.resolve(filename), - moduleName, - version, - directory, - forceCreation, - isReproducible(), - logger); + return new Archive(outputDirectory.resolve(filename), moduleName, version, directory, forceCreation, logger); } /** diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index cab14d1d..38580060 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -44,8 +44,8 @@ class ArchiveTest { * Creates an {@code Archive} suitable for a unit test. {@code forceCreation = true} skips the * existing-JAR timestamp check, so the (here {@code null}) logger is never dereferenced. */ - private static Archive archive(String moduleName, Runtime.Version version, Path directory, boolean isReproducible) { - return new Archive(directory.resolve("out.jar"), moduleName, version, directory, true, isReproducible, null); + private static Archive archive(String moduleName, Runtime.Version version, Path directory) { + return new Archive(directory.resolve("out.jar"), moduleName, version, directory, true, null); } /** @@ -81,7 +81,7 @@ private static Object mainClassOf(Manifest m) { */ @Test void owningModuleClaimsMainClassAndRemovesItFromManifest() { - Archive owner = archive("foo.bar", null, Path.of("."), false); + Archive owner = archive("foo.bar", null, Path.of(".")); Manifest m = manifestWithMainClass("foo.bar/foo.MainFile"); // The owner keeps the main class (emitted via --main-class) ... assertTrue(owner.setMainClass(m)); @@ -97,7 +97,7 @@ void owningModuleClaimsMainClassAndRemovesItFromManifest() { */ @Test void nonOwningModuleRejectsMainClass() { - Archive nonOwner = archive("foo.bar.more", null, Path.of("."), false); + Archive nonOwner = archive("foo.bar.more", null, Path.of(".")); Manifest m = manifestWithMainClass("foo.bar/foo.MainFile"); assertFalse(nonOwner.setMainClass(m)); assertNull(mainClassOf(m)); @@ -130,8 +130,8 @@ private static void assertOwnership(String first, String second, boolean ownerIs final Manifest shared = manifestWithMainClass("foo.bar/foo.MainFile"); final Manifest m1 = new Manifest(shared); final Manifest m2 = new Manifest(shared); - assertEquals(ownerIsFirst, archive(first, null, path, false).setMainClass(m1)); - assertEquals(!ownerIsFirst, archive(second, null, path, false).setMainClass(m2)); + assertEquals(ownerIsFirst, archive(first, null, path).setMainClass(m1)); + assertEquals(!ownerIsFirst, archive(second, null, path).setMainClass(m2)); // Per-module copies must leave the shared plugin manifest untouched. assertEquals("foo.bar/foo.MainFile", mainClassOf(shared)); assertNull(mainClassOf(m1)); @@ -151,19 +151,18 @@ void baseReleaseBindingIsIndependentOfDirectoryOrder() { final Runtime.Version r16 = Runtime.Version.parse("16"); // Version-directory first (the failing order): Archive seeded from v16, base registered later. - final Archive a = archive("foo.bar", r16, v16, false); + final Archive a = archive("foo.bar", r16, v16); assertNotSame(a.newTargetRelease(v16, r16), a.newTargetRelease(base, null)); assertEquals(base, a.baseRelease().directory, "base must rebind to the version-less directory"); // Base-directory first: still correct. - final Archive b = archive("foo.bar", null, base, false); + final Archive b = archive("foo.bar", null, base); assertNotSame(b.newTargetRelease(base, null), b.newTargetRelease(v16, r16)); assertEquals(base, b.baseRelease().directory); } /** * Ensures that all path are relative. - * Opportunistically verifies that reproducible build sorts the files. * *

Historical note

* In our tests, it seems that the first JAR entry after the {@code -C} option @@ -178,10 +177,8 @@ void jarEntriesAreRelativeRegardlessOfFileOrder() { Path b = classes.resolve("myproject/foo/Utils.class"); String[] ordered = {"HelloWorld.class", "Utils.class"}; String[] reverse = {"Utils.class", "HelloWorld.class"}; - assertArrayEquals(ordered, assertAllEntriesRelative(argsAfterAdding(classes, false, a, b))); - assertArrayEquals(reverse, assertAllEntriesRelative(argsAfterAdding(classes, false, b, a))); - assertArrayEquals(ordered, assertAllEntriesRelative(argsAfterAdding(classes, true, a, b))); - assertArrayEquals(ordered, assertAllEntriesRelative(argsAfterAdding(classes, true, b, a))); + assertArrayEquals(ordered, assertRelativeArguments(argsAfterAdding(classes, a, b))); + assertArrayEquals(reverse, assertRelativeArguments(argsAfterAdding(classes, b, a))); } /** @@ -193,7 +190,7 @@ void jarEntriesAreRelativeRegardlessOfFileOrder() { @Test void archivingADirectoryAsAWholeYieldsDotNotEmptyEntry() { Path versionDir = Path.of("p/target/classes/META-INF/versions/9").toAbsolutePath(); - List args = argsAfterAdding(versionDir, false, versionDir); + List args = argsAfterAdding(versionDir, versionDir); Path jar = versionDir.resolve("out.jar"); int sawDot = 0; for (Object o : args) { @@ -212,12 +209,11 @@ void archivingADirectoryAsAWholeYieldsDotNotEmptyEntry() { * Creates arguments for the {@code jar} tool with the given files in order. * * @param directory the root directory of the files - * @param isReproducible whether reproducible build was requested * @param files files in the given root directory or sub-directories * @return arguments for the {@code jar} tool */ - private static List argsAfterAdding(Path directory, boolean isReproducible, Path... filesInOrder) { - Archive archive = archive("myproject", null, directory, isReproducible); + private static List argsAfterAdding(Path directory, Path... filesInOrder) { + Archive archive = archive("myproject", null, directory); var base = archive.baseRelease(); for (Path f : filesInOrder) { base.add(f, null, false); @@ -228,17 +224,14 @@ private static List argsAfterAdding(Path directory, boolean isReproducib } /** - * Asserts that every {@link Path} in the jar-tool argument list, other than the values of - * {@code --file}/{@code --manifest}/{@code --main-class} and the {@code -C} directories, - * is a relative path. The later are jar entry name relative to its {@code -C} directory. - * This method shall reflect the policy adopted in the {@code Archive.FileSet.add(…)} implementation. - * Different strategies have been tried in the past, such as making only the first file relative and - * all other files absolute. + * Asserts that all paths after the {@code -C} directory are relative. + * Asserts also that all other paths are absolute. Those other paths are the values of + * {@code --file}/{@code --manifest}/{@code --main-class} and the {@code -C} directories. * * @param args the arguments as a mix of {@link String} and {@link Path} elements * @return the filenames of the relative paths */ - private static String[] assertAllEntriesRelative(final List args) { + private static String[] assertRelativeArguments(final List args) { final var filenames = new ArrayList(2); boolean foundOptionC = false; boolean expectDirectory = true; From 2dc7d98b03157108cf4d1c811e778655b4f4367e Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Mon, 24 Aug 2026 01:15:11 +0200 Subject: [PATCH 13/25] Add integration tests for ensuring that directory entries are present. Co-Authored-By: Claude Opus 4.8 --- .../directory-entries-mr/invoker.properties | 22 ++++++ src/it/directory-entries-mr/pom.xml | 76 +++++++++++++++++++ .../src/main/java/com/acme/App.java | 25 ++++++ .../src/main/java/com/acme/sub/Helper.java | 25 ++++++ .../src/main/java17/com/acme/App.java | 29 +++++++ src/it/directory-entries-mr/verify.groovy | 68 +++++++++++++++++ src/it/directory-entries/invoker.properties | 18 +++++ src/it/directory-entries/pom.xml | 45 +++++++++++ .../src/main/java/com/acme/App.java | 25 ++++++ .../src/main/java/com/acme/sub/Helper.java | 25 ++++++ src/it/directory-entries/verify.groovy | 57 ++++++++++++++ 11 files changed, 415 insertions(+) create mode 100644 src/it/directory-entries-mr/invoker.properties create mode 100644 src/it/directory-entries-mr/pom.xml create mode 100644 src/it/directory-entries-mr/src/main/java/com/acme/App.java create mode 100644 src/it/directory-entries-mr/src/main/java/com/acme/sub/Helper.java create mode 100644 src/it/directory-entries-mr/src/main/java17/com/acme/App.java create mode 100644 src/it/directory-entries-mr/verify.groovy create mode 100644 src/it/directory-entries/invoker.properties create mode 100644 src/it/directory-entries/pom.xml create mode 100644 src/it/directory-entries/src/main/java/com/acme/App.java create mode 100644 src/it/directory-entries/src/main/java/com/acme/sub/Helper.java create mode 100644 src/it/directory-entries/verify.groovy diff --git a/src/it/directory-entries-mr/invoker.properties b/src/it/directory-entries-mr/invoker.properties new file mode 100644 index 00000000..59942dc6 --- /dev/null +++ b/src/it/directory-entries-mr/invoker.properties @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = clean package + +# Reproducible mode (and thus the enumeration-vs-directory difference that this IT guards) +# only activates on JDK 19 or later; the `jar --date` option requires that JDK. +invoker.java.version = 19+ diff --git a/src/it/directory-entries-mr/pom.xml b/src/it/directory-entries-mr/pom.xml new file mode 100644 index 00000000..a4ad982d --- /dev/null +++ b/src/it/directory-entries-mr/pom.xml @@ -0,0 +1,76 @@ + + + + 4.0.0 + org.apache.maven.plugins + directory-entries-mr + 1.0-SNAPSHOT + jar + directory-entries-mr-it + Verifies that a non-modular multi-release JAR preserves the intermediate directory + entries (com/, com/acme/, com/acme/sub/) in the base area and in the versioned area + (META-INF/versions/17/, .../com/, .../com/acme/). Consumers relying on JAR directory + traversal (e.g. Spring @ComponentScan) depend on those entries being present. + + This is the same test as `directory-entries` but with multi-release. + + + UTF-8 + + 2020-02-02T00:00:00Z + + + + + + + maven-compiler-plugin + 3.10.1 + + 11 + + + + java17 + + compile + + + 17 + + ${project.basedir}/src/main/java17 + + true + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + diff --git a/src/it/directory-entries-mr/src/main/java/com/acme/App.java b/src/it/directory-entries-mr/src/main/java/com/acme/App.java new file mode 100644 index 00000000..79f451f2 --- /dev/null +++ b/src/it/directory-entries-mr/src/main/java/com/acme/App.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.acme; + +public class App { + public String greet() { + return "Hello (base) from " + new com.acme.sub.Helper().name(); + } +} diff --git a/src/it/directory-entries-mr/src/main/java/com/acme/sub/Helper.java b/src/it/directory-entries-mr/src/main/java/com/acme/sub/Helper.java new file mode 100644 index 00000000..d1ea9fd4 --- /dev/null +++ b/src/it/directory-entries-mr/src/main/java/com/acme/sub/Helper.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.acme.sub; + +public class Helper { + public String name() { + return "helper"; + } +} diff --git a/src/it/directory-entries-mr/src/main/java17/com/acme/App.java b/src/it/directory-entries-mr/src/main/java17/com/acme/App.java new file mode 100644 index 00000000..7df19a34 --- /dev/null +++ b/src/it/directory-entries-mr/src/main/java17/com/acme/App.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.acme; + +/** + * JDK-17 specific override of {@link App}. Placed under {@code src/main/java17} and compiled with + * {@code multiReleaseOutput=true}, so it lands in {@code META-INF/versions/17/com/acme/App.class}. + */ +public class App { + public String greet() { + return "Hello (java17) from " + new com.acme.sub.Helper().name(); + } +} diff --git a/src/it/directory-entries-mr/verify.groovy b/src/it/directory-entries-mr/verify.groovy new file mode 100644 index 00000000..e1aec4e7 --- /dev/null +++ b/src/it/directory-entries-mr/verify.groovy @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.util.jar.Attributes +import java.util.jar.JarFile + +File artifact = new File(basedir, "target/directory-entries-mr-1.0-SNAPSHOT.jar") +assert artifact.isFile() : "JAR artifact is missing: " + artifact + +JarFile jar = new JarFile(artifact) +try { + // Sanity: this must really be a multi-release JAR. + assert jar.manifest.mainAttributes.getValue(Attributes.Name.MULTI_RELEASE) == "true" : + "Expected Multi-Release: true in the manifest" + + // Regression guard for maven-jar-plugin #508, extended to non-modular multi-release JARs. + // if the JAR is built by enumerating individual files, the intermediate directory entries + // can not be specified (if they were, they would be traversed), which breaks consumers + // relying on JAR directory traversal (e.g. Spring Boot @ComponentScan). Assert that the + // directory entries are present in both the base area and the "META-INF/versions/17" area. + def requiredDirectoryEntries = [ + // base area + "com/", + "com/acme/", + "com/acme/sub/", + // versioned area + "META-INF/versions/17/", + "META-INF/versions/17/com/", + "META-INF/versions/17/com/acme/" + ] + for (String name : requiredDirectoryEntries) { + def entry = jar.getEntry(name) + assert entry != null : "Missing directory entry: " + name + assert entry.isDirectory() : "Entry is not a directory: " + name + } + + // The class files must of course still be present. + def requiredFileEntries = [ + "com/acme/App.class", + "com/acme/sub/Helper.class", + "META-INF/versions/17/com/acme/App.class" + ] + for (String name : requiredFileEntries) { + def entry = jar.getEntry(name) + assert entry != null : "Missing class entry: " + name + assert !entry.isDirectory() : "Class entry unexpectedly a directory: " + name + } +} finally { + jar.close() +} + +return true diff --git a/src/it/directory-entries/invoker.properties b/src/it/directory-entries/invoker.properties new file mode 100644 index 00000000..a644cb7e --- /dev/null +++ b/src/it/directory-entries/invoker.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = clean package diff --git a/src/it/directory-entries/pom.xml b/src/it/directory-entries/pom.xml new file mode 100644 index 00000000..a114f0e6 --- /dev/null +++ b/src/it/directory-entries/pom.xml @@ -0,0 +1,45 @@ + + + + 4.0.0 + org.apache.maven.plugins + directory-entries + 1.0-SNAPSHOT + jar + directory-entries-it + Verifies that an unfiltered JAR preserves the intermediate directory entries + ("com/", "com/acme/", "com/acme/sub/"), which consumers relying on JAR directory traversal + (e.g. Spring @ComponentScan) depend on. + + This is the same test as `directory-entries-mr` but without multi-release. + + 11 + 2020-02-02T00:00:00Z + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + + diff --git a/src/it/directory-entries/src/main/java/com/acme/App.java b/src/it/directory-entries/src/main/java/com/acme/App.java new file mode 100644 index 00000000..903772e6 --- /dev/null +++ b/src/it/directory-entries/src/main/java/com/acme/App.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.acme; + +public class App { + public String greet() { + return "Hello from " + new com.acme.sub.Helper().name(); + } +} diff --git a/src/it/directory-entries/src/main/java/com/acme/sub/Helper.java b/src/it/directory-entries/src/main/java/com/acme/sub/Helper.java new file mode 100644 index 00000000..2523651e --- /dev/null +++ b/src/it/directory-entries/src/main/java/com/acme/sub/Helper.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.acme.sub; + +public class Helper { + public String name() { + return "Helper"; + } +} diff --git a/src/it/directory-entries/verify.groovy b/src/it/directory-entries/verify.groovy new file mode 100644 index 00000000..ccab2aa7 --- /dev/null +++ b/src/it/directory-entries/verify.groovy @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.util.jar.JarFile + +File artifact = new File(basedir, "target/directory-entries-1.0-SNAPSHOT.jar") +assert artifact.isFile() : "JAR artifact is missing: " + artifact + +JarFile jar = new JarFile(artifact) +try { + // Regression guard for maven-jar-plugin #508 (Sergey Chernov, dev list): + // if the JAR is built by enumerating individual files, the intermediate directory entries + // can not be specified (if they were, they would be traversed), which breaks consumers + // relying on JAR directory traversal (e.g. Spring Boot @ComponentScan). + // Assert that the directory entries are present. + def requiredDirectoryEntries = [ + "com/", + "com/acme/", + "com/acme/sub/" + ] + for (String name : requiredDirectoryEntries) { + def entry = jar.getEntry(name) + assert entry != null : "Missing directory entry: " + name + assert entry.isDirectory() : "Entry is not a directory: " + name + } + + // The class files must of course still be present. + def requiredFileEntries = [ + "com/acme/App.class", + "com/acme/sub/Helper.class" + ] + for (String name : requiredFileEntries) { + def entry = jar.getEntry(name) + assert entry != null : "Missing class entry: " + name + assert !entry.isDirectory() : "Class entry unexpectedly a directory: " + name + } +} finally { + jar.close() +} + +return true From 556426982b4c45e044f5ca1f526994c72f84fe3a Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Mon, 24 Aug 2026 10:41:53 +0200 Subject: [PATCH 14/25] Add IT for filtered multi-release modular JAR Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- .../pom.xml | 106 ++++++++++++++++++ .../foo.bar.more/main/java/module-info.java | 19 ++++ .../foo.bar.more/main/java/more/MainFile.java | 25 +++++ .../main/java/more/OtherFile.java | 25 +++++ .../main/java_16/more/OtherFile.java | 26 +++++ .../main/java/foo/ExcludedByFilter.java | 25 +++++ .../src/foo.bar/main/java/foo/MainFile.java | 25 +++++ .../src/foo.bar/main/java/foo/OtherFile.java | 25 +++++ .../src/foo.bar/main/java/module-info.java | 19 ++++ .../foo.bar/main/java_16/foo/OtherFile.java | 30 +++++ .../verify.groovy | 84 ++++++++++++++ 11 files changed, 409 insertions(+) create mode 100644 src/it/multimodule-multirelease-with-excludes/pom.xml create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/module-info.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/MainFile.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/OtherFile.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java_16/more/OtherFile.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/ExcludedByFilter.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/MainFile.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/OtherFile.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/module-info.java create mode 100644 src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java_16/foo/OtherFile.java create mode 100644 src/it/multimodule-multirelease-with-excludes/verify.groovy diff --git a/src/it/multimodule-multirelease-with-excludes/pom.xml b/src/it/multimodule-multirelease-with-excludes/pom.xml new file mode 100644 index 00000000..8155e6d2 --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/pom.xml @@ -0,0 +1,106 @@ + + + + 4.1.0 + + org.apache.maven.plugins + multirelease-modules-filtered + 1.0-SNAPSHOT + jar + Multirelease with modules and filters + + + + + org.apache.maven.plugins + maven-compiler-plugin + 4.0.0-beta-3 + + + + + 17 + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + move + + run + + prepare-package + + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + **/ExcludedByFilter.class + + + + true + true + foo.bar/foo.MainFile + + + + + + + + + foo.bar + src/foo.bar/main/java + 15 + + + foo.bar + src/foo.bar/main/java_16 + 16 + + + foo.bar.more + src/foo.bar.more/main/java + 15 + + + foo.bar.more + src/foo.bar.more/main/java_16 + 16 + + + + diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/module-info.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/module-info.java new file mode 100644 index 00000000..778a3a4a --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +module foo.bar.more {} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/MainFile.java new file mode 100644 index 00000000..03a35bf9 --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/MainFile.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package more; + +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile of more"); + } +} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/OtherFile.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/OtherFile.java new file mode 100644 index 00000000..b3f782d8 --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java/more/OtherFile.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package more; + +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile of more"); + } +} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java_16/more/OtherFile.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java_16/more/OtherFile.java new file mode 100644 index 00000000..7b80d07b --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar.more/main/java_16/more/OtherFile.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package more; + +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile of more on Java 16"); + MainFile.main(args); // Verify that we have access to the base version. + } +} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/ExcludedByFilter.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/ExcludedByFilter.java new file mode 100644 index 00000000..15ef8f42 --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/ExcludedByFilter.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +public class ExcludedByFilter { + public static void main(String[] args) { + System.out.println("ExcludedByFilter"); + } +} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/MainFile.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/MainFile.java new file mode 100644 index 00000000..9c18f957 --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/MainFile.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +public class MainFile { + public static void main(String[] args) { + System.out.println("MainFile"); + } +} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/OtherFile.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/OtherFile.java new file mode 100644 index 00000000..b6ea5abf --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/foo/OtherFile.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile"); + } +} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/module-info.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/module-info.java new file mode 100644 index 00000000..38f61c0e --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java/module-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +module foo.bar {} diff --git a/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java_16/foo/OtherFile.java b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java_16/foo/OtherFile.java new file mode 100644 index 00000000..a859c9df --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/src/foo.bar/main/java_16/foo/OtherFile.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package foo; + +public class OtherFile { + public static void main(String[] args) { + System.out.println("OtherFile on Java 16"); + MainFile.main(args); // Verify that we have access to the base version. + } + + static void requireJava16() { + System.out.println("Method available only on Java 16+"); + } +} diff --git a/src/it/multimodule-multirelease-with-excludes/verify.groovy b/src/it/multimodule-multirelease-with-excludes/verify.groovy new file mode 100644 index 00000000..210693ab --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/verify.groovy @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.util.jar.*; + +/* + * Multi-module (therefore two JAR files), multi-release with at least one file excluded. + * Asserts the exact set of file entries per JAR: every file in the JAR is listed below. + */ +File target = new File(basedir, "target"); + +// Module foo.bar: base classes, the Java 16 override, the manifest and the Maven descriptor. +Set content = new HashSet<>(); +content.add("module-info.class") // this is a modular JAR +content.add("foo/") // directory entry +content.add("foo/MainFile.class") // base class, also the declared Main-Class +content.add("foo/OtherFile.class") // base class +content.add("META-INF/") +content.add("META-INF/versions/16/foo/") +content.add("META-INF/versions/16/foo/OtherFile.class") // multi-release override for Java 16 +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/") +content.add("META-INF/maven/org.apache.maven.plugins/") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-modules-filtered/") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-modules-filtered/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-modules-filtered/pom.properties") +// Note: foo/ExcludedByFilter.class is deliberately absent -- removed by the filter. +verify(new File(target, "foo.bar-1.0-SNAPSHOT.jar"), content, "foo.MainFile") + +content.clear() +// Module foo.bar.more: the filter does not match anything here, so the full set is expected. +content.add("module-info.class") +content.add("more/") +content.add("more/MainFile.class") +content.add("more/OtherFile.class") +content.add("META-INF/") +content.add("META-INF/versions/16/more/") +content.add("META-INF/versions/16/more/OtherFile.class") +content.add("META-INF/MANIFEST.MF") +content.add("META-INF/maven/") +content.add("META-INF/maven/org.apache.maven.plugins/") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-modules-filtered/") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-modules-filtered/pom.xml") +content.add("META-INF/maven/org.apache.maven.plugins/multirelease-modules-filtered/pom.properties") +verify(new File(target, "foo.bar.more-1.0-SNAPSHOT.jar"), content, null) + +/** + * Asserts the JAR's file entries are exactly the given set, + * and that the JAR is flagged Multi-Release with the expected Main-Class. + */ +void verify(File artifact, Set content, String mainClass) +{ + JarFile jar = new JarFile(artifact) + Enumeration jarEntries = jar.entries() + while (jarEntries.hasMoreElements()) + { + JarEntry entry = (JarEntry) jarEntries.nextElement() + String name = entry.getName() + assert content.remove(name) : "Missing entry: " + name + } + assert content.isEmpty() : "Unexpected entries: " + content + + Attributes attributes = jar.getManifest().getMainAttributes() + assert Objects.equals("true", attributes.get(Attributes.Name.MULTI_RELEASE)) + assert Objects.equals(mainClass, attributes.get(Attributes.Name.MAIN_CLASS)) + + jar.close(); +} From 109d93f6bc868c41711a7707df603cfe7dd7e3e6 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 24 Aug 2026 10:44:27 +0200 Subject: [PATCH 15/25] Rename two integration tests with no change in the actual test. For consistency with integration test added in previous commit. --- .../pom.xml | 0 .../src/foo.bar.more/main/java/module-info.java | 0 .../src/foo.bar.more/main/java/more/MainFile.java | 0 .../src/foo.bar.more/main/java/more/OtherFile.java | 0 .../src/foo.bar.more/main/java_16/more/OtherFile.java | 0 .../src/foo.bar/main/java/foo/MainFile.java | 0 .../src/foo.bar/main/java/foo/OtherFile.java | 0 .../src/foo.bar/main/java/foo/YetAnotherFile.java | 0 .../src/foo.bar/main/java/module-info.java | 0 .../src/foo.bar/main/java_16/foo/OtherFile.java | 0 .../verify.groovy | 0 src/it/{multi-module => multimodule}/pom.xml | 0 .../src/foo.bar.more/main/java/module-info.java | 0 .../src/foo.bar.more/main/java/more/MainFile.java | 0 .../src/foo.bar/main/java/foo/MainFile.java | 0 .../src/foo.bar/main/java/module-info.java | 0 src/it/{multi-module => multimodule}/verify.groovy | 0 17 files changed, 0 insertions(+), 0 deletions(-) rename src/it/{multirelease-with-modules => multimodule-multirelease}/pom.xml (100%) rename src/it/{multi-module => multimodule-multirelease}/src/foo.bar.more/main/java/module-info.java (100%) rename src/it/{multi-module => multimodule-multirelease}/src/foo.bar.more/main/java/more/MainFile.java (100%) rename src/it/{multirelease-with-modules => multimodule-multirelease}/src/foo.bar.more/main/java/more/OtherFile.java (100%) rename src/it/{multirelease-with-modules => multimodule-multirelease}/src/foo.bar.more/main/java_16/more/OtherFile.java (100%) rename src/it/{multi-module => multimodule-multirelease}/src/foo.bar/main/java/foo/MainFile.java (100%) rename src/it/{multirelease-with-modules => multimodule-multirelease}/src/foo.bar/main/java/foo/OtherFile.java (100%) rename src/it/{multirelease-with-modules => multimodule-multirelease}/src/foo.bar/main/java/foo/YetAnotherFile.java (100%) rename src/it/{multi-module => multimodule-multirelease}/src/foo.bar/main/java/module-info.java (100%) rename src/it/{multirelease-with-modules => multimodule-multirelease}/src/foo.bar/main/java_16/foo/OtherFile.java (100%) rename src/it/{multirelease-with-modules => multimodule-multirelease}/verify.groovy (100%) rename src/it/{multi-module => multimodule}/pom.xml (100%) rename src/it/{multirelease-with-modules => multimodule}/src/foo.bar.more/main/java/module-info.java (100%) rename src/it/{multirelease-with-modules => multimodule}/src/foo.bar.more/main/java/more/MainFile.java (100%) rename src/it/{multirelease-with-modules => multimodule}/src/foo.bar/main/java/foo/MainFile.java (100%) rename src/it/{multirelease-with-modules => multimodule}/src/foo.bar/main/java/module-info.java (100%) rename src/it/{multi-module => multimodule}/verify.groovy (100%) diff --git a/src/it/multirelease-with-modules/pom.xml b/src/it/multimodule-multirelease/pom.xml similarity index 100% rename from src/it/multirelease-with-modules/pom.xml rename to src/it/multimodule-multirelease/pom.xml diff --git a/src/it/multi-module/src/foo.bar.more/main/java/module-info.java b/src/it/multimodule-multirelease/src/foo.bar.more/main/java/module-info.java similarity index 100% rename from src/it/multi-module/src/foo.bar.more/main/java/module-info.java rename to src/it/multimodule-multirelease/src/foo.bar.more/main/java/module-info.java diff --git a/src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multimodule-multirelease/src/foo.bar.more/main/java/more/MainFile.java similarity index 100% rename from src/it/multi-module/src/foo.bar.more/main/java/more/MainFile.java rename to src/it/multimodule-multirelease/src/foo.bar.more/main/java/more/MainFile.java diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java b/src/it/multimodule-multirelease/src/foo.bar.more/main/java/more/OtherFile.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/OtherFile.java rename to src/it/multimodule-multirelease/src/foo.bar.more/main/java/more/OtherFile.java diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java b/src/it/multimodule-multirelease/src/foo.bar.more/main/java_16/more/OtherFile.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar.more/main/java_16/more/OtherFile.java rename to src/it/multimodule-multirelease/src/foo.bar.more/main/java_16/more/OtherFile.java diff --git a/src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/foo/MainFile.java similarity index 100% rename from src/it/multi-module/src/foo.bar/main/java/foo/MainFile.java rename to src/it/multimodule-multirelease/src/foo.bar/main/java/foo/MainFile.java diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/foo/OtherFile.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar/main/java/foo/OtherFile.java rename to src/it/multimodule-multirelease/src/foo.bar/main/java/foo/OtherFile.java diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/foo/YetAnotherFile.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar/main/java/foo/YetAnotherFile.java rename to src/it/multimodule-multirelease/src/foo.bar/main/java/foo/YetAnotherFile.java diff --git a/src/it/multi-module/src/foo.bar/main/java/module-info.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/module-info.java similarity index 100% rename from src/it/multi-module/src/foo.bar/main/java/module-info.java rename to src/it/multimodule-multirelease/src/foo.bar/main/java/module-info.java diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java_16/foo/OtherFile.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar/main/java_16/foo/OtherFile.java rename to src/it/multimodule-multirelease/src/foo.bar/main/java_16/foo/OtherFile.java diff --git a/src/it/multirelease-with-modules/verify.groovy b/src/it/multimodule-multirelease/verify.groovy similarity index 100% rename from src/it/multirelease-with-modules/verify.groovy rename to src/it/multimodule-multirelease/verify.groovy diff --git a/src/it/multi-module/pom.xml b/src/it/multimodule/pom.xml similarity index 100% rename from src/it/multi-module/pom.xml rename to src/it/multimodule/pom.xml diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java b/src/it/multimodule/src/foo.bar.more/main/java/module-info.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar.more/main/java/module-info.java rename to src/it/multimodule/src/foo.bar.more/main/java/module-info.java diff --git a/src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multimodule/src/foo.bar.more/main/java/more/MainFile.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar.more/main/java/more/MainFile.java rename to src/it/multimodule/src/foo.bar.more/main/java/more/MainFile.java diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java b/src/it/multimodule/src/foo.bar/main/java/foo/MainFile.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar/main/java/foo/MainFile.java rename to src/it/multimodule/src/foo.bar/main/java/foo/MainFile.java diff --git a/src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java b/src/it/multimodule/src/foo.bar/main/java/module-info.java similarity index 100% rename from src/it/multirelease-with-modules/src/foo.bar/main/java/module-info.java rename to src/it/multimodule/src/foo.bar/main/java/module-info.java diff --git a/src/it/multi-module/verify.groovy b/src/it/multimodule/verify.groovy similarity index 100% rename from src/it/multi-module/verify.groovy rename to src/it/multimodule/verify.groovy From 3e0852ea7e524ba9cb434aa65d75c482eaee71b8 Mon Sep 17 00:00:00 2001 From: Gerd Aschemann Date: Mon, 24 Aug 2026 12:54:18 +0200 Subject: [PATCH 16/25] Write a JDK-independent "Created-By" when strictly reproducible builds is requested. Before this commit, the "Created-By" value generated by the `jar` tool was overwritten only if `addDefaultEntries` was `true`. Reported by Sergey Chernov on #508. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Martin Desruisseaux --- src/it/manifest-created-by/invoker.properties | 21 ++++++++ src/it/manifest-created-by/pom.xml | 52 +++++++++++++++++++ .../src/main/java/myproject/HelloWorld.java | 25 +++++++++ src/it/manifest-created-by/verify.groovy | 44 ++++++++++++++++ .../maven/plugins/jar/ToolExecutor.java | 35 +++++++++++-- 5 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 src/it/manifest-created-by/invoker.properties create mode 100644 src/it/manifest-created-by/pom.xml create mode 100644 src/it/manifest-created-by/src/main/java/myproject/HelloWorld.java create mode 100644 src/it/manifest-created-by/verify.groovy diff --git a/src/it/manifest-created-by/invoker.properties b/src/it/manifest-created-by/invoker.properties new file mode 100644 index 00000000..9b1313ed --- /dev/null +++ b/src/it/manifest-created-by/invoker.properties @@ -0,0 +1,21 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals = clean package + +# Reproducible mode only activates on JDK 19 or later because the `jar --date` option requires that JDK. +invoker.java.version = 19+ diff --git a/src/it/manifest-created-by/pom.xml b/src/it/manifest-created-by/pom.xml new file mode 100644 index 00000000..7eb23287 --- /dev/null +++ b/src/it/manifest-created-by/pom.xml @@ -0,0 +1,52 @@ + + + + 4.0.0 + org.apache.maven.plugins + manifest-created-by + 1.0-SNAPSHOT + jar + manifest-created-by-it + Verifies that when strictly reproducible builds is requested, + the vendor-specific "Created-By" attribute value (e.g. "21.0.10 (Amazon.com Inc.)") + is replaced by a hard-coded value. + + 11 + + 2020-02-02T00:00:00Z + + + + + org.apache.maven.plugins + maven-jar-plugin + @project.version@ + + + false + + false + + + + + + + diff --git a/src/it/manifest-created-by/src/main/java/myproject/HelloWorld.java b/src/it/manifest-created-by/src/main/java/myproject/HelloWorld.java new file mode 100644 index 00000000..0e4760f5 --- /dev/null +++ b/src/it/manifest-created-by/src/main/java/myproject/HelloWorld.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package myproject; + +public class HelloWorld { + public String greeting() { + return "Hello World"; + } +} diff --git a/src/it/manifest-created-by/verify.groovy b/src/it/manifest-created-by/verify.groovy new file mode 100644 index 00000000..f13f6b48 --- /dev/null +++ b/src/it/manifest-created-by/verify.groovy @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.util.jar.*; + +/* + * The project sets addDefaultEntries=false, so Maven archiver writes no Created-By. Without the fix the + * jar tool fills that gap with its own value, java.version + " (" + java.vendor + ")" (for example + * "21.0.10 (Amazon.com Inc.)"). That is JDK- and vendor-specific, so the same sources produce different + * bytes on Temurin vs Corretto -- a reproducibility regression, and it ignores addDefaultEntries=false. + * The plugin must instead write a stable, JDK-independent Created-By. + */ +File artifact = new File(basedir, "target/manifest-created-by-1.0-SNAPSHOT.jar") +assert artifact.isFile() : "artifact is missing: " + artifact + +JarFile jar = new JarFile(artifact) +try { + Attributes attributes = jar.getManifest().getMainAttributes() + String createdBy = attributes.getValue("Created-By") + + assert createdBy != null : "Created-By is missing." + assert createdBy.startsWith("Maven JAR Plugin") : "Unexpected Created-By: " + createdBy + + // addMavenDescriptor=false must still be honored: no Maven metadata in the JAR. + assert jar.getEntry("META-INF/maven/") == null : "addMavenDescriptor=false was not honored" +} finally { + jar.close() +} diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 858df8d9..8f1b00c1 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -62,6 +62,13 @@ final class ToolExecutor { */ static final int JDK_SUPPORT_DATE = 19; + /** + * The {@value} attribute. Its value is automatically generated by {@link Manifest}, + * but may need to be replaced by another value if JDK-independent value is desired + * for strictly reproducible builds. + */ + private static final String CREATED_BY = "Created-By"; + /** * The Maven project for which to create an archive. */ @@ -221,13 +228,20 @@ final class ToolExecutor { } } if (manifest != null) { + final Attributes mainAttributes = manifest.getMainAttributes(); if (mojo.detectMultiReleaseJar) { - manifest.getMainAttributes().remove(Attributes.Name.MULTI_RELEASE); + mainAttributes.remove(Attributes.Name.MULTI_RELEASE); } - if (!isReproducible()) { + if (isReproducible()) { + // If a "Created-By" attribute was generated by Maven Archiver, it is assumed JDK-independent. + // Otherwise, substitute by another value which does not depend on the JDK. + if (mainAttributes.getValue(CREATED_BY) == null) { + mainAttributes.putValue(CREATED_BY, createdBy()); + } + } else { // If reproducible build was not requested, let the tool declares itself. // This is a workaround until we port Maven archiver to this JAR plugin. - manifest.getMainAttributes().remove("Created-By"); + mainAttributes.remove(CREATED_BY); } } manifestFromPlugin = manifest; @@ -242,6 +256,21 @@ public boolean isReproducible() { return outputTimestamp != null; } + /** + * Returns the default {@value #CREATED_BY} value to use when strictly reproducible builds is requested. + * This value is ignored if the Maven Archiver or if user's configuration provided themselves a value. + * + * @return a JDK-independent {@value #CREATED_BY} value + */ + private static String createdBy() { + String value = "Maven JAR Plugin"; + String version = ToolExecutor.class.getPackage().getImplementationVersion(); + if (version != null) { + value = value + ' ' + version; + } + return value; + } + /** * Creates an initially empty archive for JAR file to generate. * This method does not create the JAR file immediately, From 21da29585406fdb60df61a25ea00d59008c4da67 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 24 Aug 2026 15:05:26 +0200 Subject: [PATCH 17/25] Decouple the `isReproducible` flag from whether the `--date` option is supported. It allows the creation of quasi-reproducible JAR files on JDK 17/18. --- .../directory-entries-mr/invoker.properties | 22 ------------------- src/it/directory-entries-mr/pom.xml | 7 ------ src/it/manifest-created-by/invoker.properties | 21 ------------------ .../maven/plugins/jar/AbstractJarMojo.java | 4 ---- .../maven/plugins/jar/ToolExecutor.java | 10 ++++++--- 5 files changed, 7 insertions(+), 57 deletions(-) delete mode 100644 src/it/directory-entries-mr/invoker.properties delete mode 100644 src/it/manifest-created-by/invoker.properties diff --git a/src/it/directory-entries-mr/invoker.properties b/src/it/directory-entries-mr/invoker.properties deleted file mode 100644 index 59942dc6..00000000 --- a/src/it/directory-entries-mr/invoker.properties +++ /dev/null @@ -1,22 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -invoker.goals = clean package - -# Reproducible mode (and thus the enumeration-vs-directory difference that this IT guards) -# only activates on JDK 19 or later; the `jar --date` option requires that JDK. -invoker.java.version = 19+ diff --git a/src/it/directory-entries-mr/pom.xml b/src/it/directory-entries-mr/pom.xml index a4ad982d..de13d653 100644 --- a/src/it/directory-entries-mr/pom.xml +++ b/src/it/directory-entries-mr/pom.xml @@ -31,13 +31,6 @@ This is the same test as `directory-entries` but with multi-release. - - UTF-8 - - 2020-02-02T00:00:00Z - - diff --git a/src/it/manifest-created-by/invoker.properties b/src/it/manifest-created-by/invoker.properties deleted file mode 100644 index 9b1313ed..00000000 --- a/src/it/manifest-created-by/invoker.properties +++ /dev/null @@ -1,21 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -invoker.goals = clean package - -# Reproducible mode only activates on JDK 19 or later because the `jar --date` option requires that JDK. -invoker.java.version = 19+ diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 1c6e75a8..a2c11e6d 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -239,10 +239,6 @@ protected String getOutputTimestamp() { return null; } } - if (Runtime.version().feature() < ToolExecutor.JDK_SUPPORT_DATE) { - log.warn("Reproducible build requires Java " + ToolExecutor.JDK_SUPPORT_DATE + " or later."); - return null; - } for (int i = time.length(); --i >= 0; ) { char c = time.charAt(i); if ((c < '0' || c > '9') && (i != 0 || c != '-')) { diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 8f1b00c1..60c20818 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -60,7 +60,7 @@ final class ToolExecutor { /** * First JDK feature version whose {@code jar} tool support the {@code --date} option. */ - static final int JDK_SUPPORT_DATE = 19; + private static final int JDK_SUPPORT_DATE = 19; /** * The {@value} attribute. Its value is automatically generated by {@link Manifest}, @@ -388,8 +388,12 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE arguments.add("--no-compress"); } if (outputTimestamp != null) { - arguments.add("--date"); - arguments.add(outputTimestamp); + if (Runtime.version().feature() >= JDK_SUPPORT_DATE) { + arguments.add("--date"); + arguments.add(outputTimestamp); + } else { + logger.warn("Reproducible build requires Java " + JDK_SUPPORT_DATE + " or later."); + } } archive.arguments(arguments); /* From aeb815c631d49b604caf043b41771a0e9ad0aa8b Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 24 Aug 2026 16:40:05 +0200 Subject: [PATCH 18/25] Address issues raised by AI agent on the pull request. --- .../maven/plugins/jar/AbstractJarMojo.java | 8 +++++++- .../org/apache/maven/plugins/jar/Archive.java | 2 +- .../maven/plugins/jar/MetadataFiles.java | 14 ++++++++++++-- .../maven/plugins/jar/PomDerivation.java | 19 +++++++++++-------- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index a2c11e6d..786000f5 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.DateTimeException; import java.time.Instant; import java.util.Arrays; import java.util.List; @@ -228,6 +229,7 @@ protected ToolProvider getJarTool() throws MojoException { * If the time stamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is. * * @return the time stamp in presumed ISO 8601 format, or {@code null} if none + * @throws MojoException if the timestamp looks like a number of seconds but cannot be parsed as such * * @since 4.0.0-beta-2 */ @@ -245,7 +247,11 @@ protected String getOutputTimestamp() { return time; } } - return Instant.ofEpochSecond(Long.parseLong(time)).toString(); + try { + return Instant.ofEpochSecond(Long.parseLong(time)).toString(); + } catch (NumberFormatException | DateTimeException e) { + throw new MojoException("Timestamp \"" + time + "\" is not a number of seconds.", e); + } } /** diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index a6dca26a..51bbccbb 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -546,7 +546,7 @@ void arguments(final List addTo) { * @return whether a validation should be run */ boolean validate(final List addTo) { - if (filesetForRelease.values().stream().allMatch(Objects::isNull)) { + if (filesetForRelease.keySet().stream().anyMatch(Objects::nonNull)) { // At least one --release option was used. Validation was implicit. return false; } diff --git a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java index 521b4234..b26305c5 100644 --- a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java +++ b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java @@ -222,8 +222,14 @@ public List addPOM(final MavenArchiveConfiguration archive, final boolean private static Path linkOrCopy(final Path source, final Path target) throws IOException { try { return Files.createLink(target, source); - } catch (UnsupportedOperationException e) { - return Files.copy(source, target); + } catch (UnsupportedOperationException | IOException s) { + // Cross-filesystem hard links fail with an IOException ("Invalid cross-device link"). + try { + return Files.copy(source, target); + } catch (IOException e) { + e.addSuppressed(s); + throw e; + } } } @@ -238,6 +244,10 @@ public void cancelFileDeletion() { /** * Deletes all temporary files and directories created by this class. + * If a file cannot be deleted, an arbitrary number of files will be leftovers. + * This is consistent with what we do when the {@code jar} tool raised an error + * and {@link #cancelFileDeletion()} is invoked. These leftover files are in the + * build directory, so they will be deleted by the next call to {@code mvn clean}. * * @throws IOException if an error occurred while deleting a file or directory */ diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java index de78233e..9920e8d4 100644 --- a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -166,15 +166,18 @@ final class PomDerivation { } builtModules = new HashMap<>(moduleRoots.size()); // TODO: use newHashMap with JDK19. for (Path root : moduleRoots) { + root = root.toRealPath(); ModuleDescriptor descriptor = fromURI.get(root.toUri()).descriptor(); - builtModules.put( - descriptor.name(), - Dependency.newBuilder() - .groupId(projectModel.getGroupId()) - .artifactId(descriptor.name()) - .version(projectModel.getVersion()) - .type(Type.MODULAR_JAR) - .build()); + if (descriptor != null) { + builtModules.put( + descriptor.name(), + Dependency.newBuilder() + .groupId(projectModel.getGroupId()) + .artifactId(descriptor.name()) + .version(projectModel.getVersion()) + .type(Type.MODULAR_JAR) + .build()); + } } } From 1f68f5b311954b3b52f1f78a951eb1983231a944 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Wed, 26 Aug 2026 00:36:50 +0200 Subject: [PATCH 19/25] If a path to exclude is a directory, move to a path created by `createTemporaryDirectory` instead of `createTemporaryFile`. Also fix a wrong NullPointerException in `PomDerivation`. These changes adress issues reported on the pull request. --- .../maven/plugins/jar/ExcludedFiles.java | 32 +++++++++++---- .../maven/plugins/jar/FileCollector.java | 41 ++++++++++++++----- .../maven/plugins/jar/PomDerivation.java | 9 ++-- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java index 5ccddf3f..91de3a4f 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java +++ b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java @@ -52,15 +52,26 @@ final class ExcludedFiles implements Closeable { */ private final Path temporaryDirectory; + /** + * Index of the first path which is a directory instead of a file. + * All paths before this index in the {@link #original} and {@link #moved} arrays are files. + * All paths at this index and after this index are directories. + */ + private final int indexOfFirstDirectory; + /** * Creates a new list of files to move in a temporary directory. * * @param directory the directory which was scanned for files to include in the JAR - * @param files paths of files or directories to temporarily move in another directory + * @param excludedFiles paths of files to temporarily move in another directory + * @param excludedDirectories paths of directories to temporarily move in another directory * @throws IOException if an error occurred while creating the temporary directory. */ - ExcludedFiles(final Path directory, final List files) throws IOException { - original = files.toArray(Path[]::new); + ExcludedFiles(Path directory, List excludedFiles, List excludedDirectories) throws IOException { + indexOfFirstDirectory = excludedFiles.size(); + final int nd = excludedDirectories.size(); + original = excludedFiles.toArray(new Path[indexOfFirstDirectory + nd]); + System.arraycopy(excludedDirectories.toArray(), 0, original, indexOfFirstDirectory, nd); moved = new Path[original.length]; temporaryDirectory = Files.createTempDirectory(directory, "excluded-"); } @@ -75,12 +86,17 @@ public void move() throws IOException { final Path source = original[i]; String prefix = source.getFileName().toString(); String suffix = null; - int s = prefix.lastIndexOf('.'); - if (s > 0) { - suffix = prefix.substring(s); - prefix = prefix.substring(0, s); + Path target; + if (i < indexOfFirstDirectory) { + int s = prefix.lastIndexOf('.'); + if (s > 0) { + suffix = prefix.substring(s); + prefix = prefix.substring(0, s); + } + target = Files.createTempFile(temporaryDirectory, prefix, suffix); + } else { + target = Files.createTempDirectory(temporaryDirectory, prefix); } - Path target = Files.createTempFile(temporaryDirectory, prefix, suffix); try { moved[i] = Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index d45273ca..3fb0ddfc 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -104,7 +104,7 @@ final class FileCollector extends SimpleFileVisitor { private final PathMatcher directoryMatcher; /** - * Files or directories to exclude. These files will be moved to a temporary location + * Files to exclude. These files will be moved to a temporary location * for allowing {@link ToolExecutor} to specify whole directories to the {@code jar} tool. * Specifying whole directories is preferable to enumerating the files because otherwise, * the generated JAR file contains only entries for the files and is missing @@ -114,7 +114,14 @@ final class FileCollector extends SimpleFileVisitor { * (because there is no include/exclude filters).

*/ @Nullable - private final List exclusion; + private final List excludedFiles; + + /** + * Directories to exclude. This field serves the same purpose as {@link #excludedFiles}, + * but where the sources a directories instead of files. + */ + @Nullable + private final List excludedDirectories; /** * Files found in the output directory when package hierarchy is used. @@ -178,9 +185,11 @@ final class FileCollector extends SimpleFileVisitor { fileMatcher = matcherFactory.createPathMatcher(directory, mojo.getIncludes(), mojo.getExcludes(), false); directoryMatcher = matcherFactory.deriveDirectoryMatcher(fileMatcher); if (matcherFactory.isIncludesAll(fileMatcher) && matcherFactory.isIncludesAll(directoryMatcher)) { - exclusion = null; + excludedFiles = null; + excludedDirectories = null; } else { - exclusion = new ArrayList<>(); + excludedFiles = new ArrayList<>(); + excludedDirectories = new ArrayList<>(); } packageHierarchy = context.newArchive(null, null, directory); moduleHierarchy = new LinkedHashMap<>(); @@ -249,7 +258,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt role = DirectoryRole.ROOT; } else { if (!directoryMatcher.matches(directory)) { - exclusion.add(directory); // Cannot be null if excluded directories may exist. + excludedDirectories.add(directory); // Cannot be null if excluded directories may exist. return FileVisitResult.SKIP_SUBTREE; } checkForManifest = false; @@ -343,7 +352,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt */ if (role == DirectoryRole.RESOURCES) { currentFilesToArchive.add(directory, attributes, true); - if (exclusion == null) { + if (excludedFiles == null) { /* * Since we are skipping the whole directory, `postVisitDirectory(…)` will not be invoked. * We must reset `currentFilesToArchive` and `currentTargetVersion` by an explicit call. @@ -423,7 +432,7 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr currentFilesToArchive.add(file, attributes, false); } } else { - exclusion.add(file); // Cannot be null if excluded files may exist. + excludedFiles.add(file); // Cannot be null if excluded files may exist. } return FileVisitResult.CONTINUE; } @@ -469,6 +478,17 @@ Path handleOrphanFiles() { return packageHierarchy.discardAllFiles(); } + /** + * Returns the object in charge of moving excluded files to a temporary directory, or {@code null} if none. + */ + private ExcludedFiles exclusion() throws IOException { + if ((excludedFiles == null || excludedFiles.isEmpty()) + && (excludedDirectories == null || excludedDirectories.isEmpty())) { + return null; + } + return new ExcludedFiles(rootDirectory, excludedFiles, excludedDirectories); + } + /** * Writes all JAR files. * If the project is multi-module, then this method returns the path to the generated parent POM file. @@ -481,10 +501,9 @@ Path handleOrphanFiles() { * @throws IOException if an error occurred while reading or writing a manifest file */ Path writeAllJARs(final ToolExecutor executor) throws IOException { - try (ExcludedFiles moved = - (exclusion == null || exclusion.isEmpty()) ? null : new ExcludedFiles(rootDirectory, exclusion)) { - if (moved != null) { - moved.move(); + try (ExcludedFiles exclusion = exclusion()) { + if (exclusion != null) { + exclusion.move(); } for (Archive module : moduleHierarchy.values()) { executor.writeSingleJAR(this, module); diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java index 9920e8d4..1cb6acb2 100644 --- a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -167,13 +167,14 @@ final class PomDerivation { builtModules = new HashMap<>(moduleRoots.size()); // TODO: use newHashMap with JDK19. for (Path root : moduleRoots) { root = root.toRealPath(); - ModuleDescriptor descriptor = fromURI.get(root.toUri()).descriptor(); - if (descriptor != null) { + ModuleReference reference = fromURI.get(root.toUri()); + if (reference != null) { // Actually a null value would be a bug, but be tolerant. + String moduleName = reference.descriptor().name(); builtModules.put( - descriptor.name(), + moduleName, Dependency.newBuilder() .groupId(projectModel.getGroupId()) - .artifactId(descriptor.name()) + .artifactId(moduleName) .version(projectModel.getVersion()) .type(Type.MODULAR_JAR) .build()); From 245dcb22131f553bf6e92c0fee7565b48d4d56ab Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Thu, 27 Aug 2026 12:55:35 +0200 Subject: [PATCH 20/25] Execute `jar --validate` only if requested. https://github.com/apache/maven-jar-plugin/issues/484 --- .../maven/plugins/jar/AbstractJarMojo.java | 50 ++++++++++++++++--- .../org/apache/maven/plugins/jar/Archive.java | 28 +++++++---- .../maven/plugins/jar/ToolExecutor.java | 9 +++- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 786000f5..62bde4bf 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.spi.ToolProvider; @@ -169,6 +170,20 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj @Parameter(property = "maven.jar.detectMultiReleaseJar", defaultValue = "true") protected boolean detectMultiReleaseJar; + /** + * Whether to validate the JAR files after their creation. + * If {@code true}, the {@code jar} tool is executed a second time with + * the {@code --validate} option for each created JAR file. + * This verification may slow down the build if these files are large. + * If {@code false} (the default), there is no second {@code jar} execution. + * If {@code auto}, the Maven JAR Plugin will decide itself whether + * to execute {@code jar --validate} based on heuristic rules. + * + * @since 4.0.0-beta-2 + */ + @Parameter(defaultValue = "false") + protected String validate; + /** * The MOJO logger. */ @@ -268,6 +283,16 @@ protected List getExcludes() { return asList(excludes); } + /** + * Returns the given elements as a list if non-null. + * + * @param elements the elements, or {@code null} + * @return the elements as a list, or {@code null} if the given array was null + */ + private static List asList(String[] elements) { + return (elements == null) ? List.of() : Arrays.asList(elements); + } + /** * Returns the output directory and ensures that the directory exists. * The returned directory will be either {@link #outputDirectory} if non-null, @@ -292,13 +317,26 @@ protected Path getOutputDirectory() throws IOException { } /** - * Returns the given elements as a list if non-null. - * - * @param elements the elements, or {@code null} - * @return the elements as a list, or {@code null} if the given array was null + * Returns the value of the {@link #validate} parameter, or {@code null} if {@code "auto"}. */ - private static List asList(String[] elements) { - return (elements == null) ? List.of() : Arrays.asList(elements); + final Boolean getValidate() { + String value = validate; + if (value != null) { + value = value.strip(); + if (!value.isEmpty()) { + switch (value.toLowerCase(Locale.ENGLISH)) { + default: + throw new MojoException("The 'validate' parameter value cannot be \"" + value + "\"."); + case "auto": + return null; + case "true": + return Boolean.TRUE; + case "false": + break; + } + } + } + return Boolean.FALSE; } /** diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 51bbccbb..e1298755 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -533,27 +533,33 @@ void arguments(final List addTo) { /** * Adds to the given list the arguments to provide to the "jar" tool for validating the JAR file. - * The file is validated only if the validation was not done implicitly at JAR creation time. - * This is the case if no {@code --release} option was used. - * This method adds the following options: + * The file is validated only if requested or, in automatic mode, if the validation was not done implicitly at + * JAR creation time. Implicit validation happens if some {@code --release} options were used. + * If {@code true}, this method adds the following options: * *
    *
  • {@code --validate} operation mode
  • *
  • {@code --file} followed by the path to the JAR file
  • *
* + * @param validate whether to validate, or {@code null} for automatic based on heuristic rules * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances * @return whether a validation should be run */ - boolean validate(final List addTo) { - if (filesetForRelease.keySet().stream().anyMatch(Objects::nonNull)) { - // At least one --release option was used. Validation was implicit. - return false; + boolean validate(final Boolean validate, final List addTo) { + boolean effective; + if (validate != null) { + effective = validate; + } else { + // If at least one --release option was used, then validation was implicit. + effective = !filesetForRelease.keySet().stream().anyMatch(Objects::nonNull); } - addTo.add("--validate"); - addTo.add("--file"); - addTo.add(jarFile); - return true; + if (effective) { + addTo.add("--validate"); + addTo.add("--file"); + addTo.add(jarFile); + } + return effective; } /** diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 60c20818..103111ad 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -96,6 +96,12 @@ final class ToolExecutor { */ private final String classifier; + /** + * Whether to validate the JAR file after its creation. + * If {@code null}, a value will be determined automatically based on heuristic rules. + */ + private final Boolean validate; + /** * The tool to use for creating the JAR files. */ @@ -200,6 +206,7 @@ final class ToolExecutor { (mojo.finalName != null) ? mojo.finalName : project.getBuild().getFinalName(); forceCreation = mojo.forceCreation; outputTimestamp = mojo.getOutputTimestamp(); + validate = mojo.getValidate(); logger = mojo.log; tool = mojo.getJarTool(); @@ -422,7 +429,7 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE * we skip the post-creation validation pass; the archive was already created * successfully by the `--create` pass above. */ - if (archive.validate(arguments)) { + if (archive.validate(validate, arguments)) { final int version = Runtime.version().feature(); if (version >= JDK_FIXING_JAR_VALIDATE) { int status = executeJarTool(); From 9c90049c0eb517b01bc751ca9bc1fb25f613ab79 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 31 Aug 2026 12:36:41 +0900 Subject: [PATCH 21/25] Upgrade Maven Compiler Plugin from beta-3 to beta-5 in integration tests. It allows the removal of a hack. --- .../pom.xml | 23 +------------------ src/it/multimodule-multirelease/pom.xml | 23 +------------------ src/it/multimodule/pom.xml | 2 +- 3 files changed, 3 insertions(+), 45 deletions(-) diff --git a/src/it/multimodule-multirelease-with-excludes/pom.xml b/src/it/multimodule-multirelease-with-excludes/pom.xml index 8155e6d2..d0351313 100644 --- a/src/it/multimodule-multirelease-with-excludes/pom.xml +++ b/src/it/multimodule-multirelease-with-excludes/pom.xml @@ -31,7 +31,7 @@ org.apache.maven.plugins maven-compiler-plugin - 4.0.0-beta-3 + 4.0.0-beta-5 @@ -40,27 +40,6 @@ - - - org.apache.maven.plugins - maven-antrun-plugin - 3.1.0 - - - move - - run - - prepare-package - - - - - - - - - org.apache.maven.plugins maven-jar-plugin diff --git a/src/it/multimodule-multirelease/pom.xml b/src/it/multimodule-multirelease/pom.xml index 5f02bbbe..7394f632 100644 --- a/src/it/multimodule-multirelease/pom.xml +++ b/src/it/multimodule-multirelease/pom.xml @@ -31,7 +31,7 @@ org.apache.maven.plugins maven-compiler-plugin - 4.0.0-beta-3 + 4.0.0-beta-5 @@ -40,27 +40,6 @@ - - - org.apache.maven.plugins - maven-antrun-plugin - 3.1.0 - - - move - - run - - prepare-package - - - - - - - - - org.apache.maven.plugins maven-jar-plugin diff --git a/src/it/multimodule/pom.xml b/src/it/multimodule/pom.xml index d4a781e9..5e0794c0 100644 --- a/src/it/multimodule/pom.xml +++ b/src/it/multimodule/pom.xml @@ -31,7 +31,7 @@ org.apache.maven.plugins maven-compiler-plugin - 4.0.0-beta-3 + 4.0.0-beta-5 From 13769e8abed6d78d184947ec79b33e9677787a44 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 31 Aug 2026 12:51:19 +0900 Subject: [PATCH 22/25] Apply English corrections suggested in comments to the pull request. --- .../maven/plugins/jar/AbstractJarMojo.java | 40 ++++++----- .../org/apache/maven/plugins/jar/Archive.java | 32 ++++----- .../maven/plugins/jar/DirectoryRole.java | 10 +-- .../maven/plugins/jar/ExcludedFiles.java | 2 +- .../maven/plugins/jar/FileCollector.java | 66 +++++++++---------- .../maven/plugins/jar/MetadataFiles.java | 8 +-- .../maven/plugins/jar/PomDerivation.java | 6 +- .../maven/plugins/jar/TimestampCheck.java | 16 ++--- .../maven/plugins/jar/ToolExecutor.java | 8 +-- .../apache/maven/plugins/jar/ArchiveTest.java | 10 +-- 10 files changed, 102 insertions(+), 96 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 62bde4bf..0330ed69 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -51,7 +51,7 @@ */ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Mojo { /** - * Identifier of the tool to use. This identifier shall match the identifier of a tool + * Identifier of the tool to use. This identifier must match the identifier of a tool * registered as a {@link ToolProvider}. By default, the {@code "jar"} tool is used. * * @since 4.0.0-beta-2 @@ -150,7 +150,7 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * environment variable is used as a fallback value, * to ease forcing Reproducible Build externally when the build has not enabled it natively in POM. * - *

This property is supported only with Java Development Kit (JDK) version 19 or later.

+ *

This property is supported only in Java 19 or later.

* * @since 3.2.0 */ @@ -159,11 +159,17 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj /** * Whether to detect multi-release JAR files. - * If the JAR contains the {@code META-INF/versions} directory it will be detected as a multi-release JAR file, - * adding the {@code Multi-Release: true} attribute to the main section of the JAR {@code MANIFEST.MF} entry. - * In addition, the class files in {@code META-INF/versions} will be checked for API compatibility - * with the class files in the base version. If this flag is {@code false}, then the {@code META-INF/versions} - * directories are included without processing. + * If the JAR contains the {@code META-INF/versions} directory, it is detected as a multi-release JAR file. + * In such case: + * + *
    + *
  • the {@code Multi-Release: true} attribute is added + * to the main section of the JAR {@code MANIFEST.MF} entry, and
  • + *
  • the class files in {@code META-INF/versions} are checked for API compatibility + * with the class files in the base version.
  • + *
+ * + * If this flag is {@code false}, then the {@code META-INF/versions} directories are included without processing. * * @since 3.4.0 */ @@ -174,7 +180,7 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * Whether to validate the JAR files after their creation. * If {@code true}, the {@code jar} tool is executed a second time with * the {@code --validate} option for each created JAR file. - * This verification may slow down the build if these files are large. + * This verification can slow down the build if these files are large. * If {@code false} (the default), there is no second {@code jar} execution. * If {@code auto}, the Maven JAR Plugin will decide itself whether * to execute {@code jar --validate} based on heuristic rules. @@ -224,7 +230,7 @@ protected AbstractJarMojo() {} * {@return the scope of dependencies} * It should be {@link PathScope#MAIN_COMPILE} or {@link PathScope#TEST_COMPILE}. * Note that we use compile scope rather than runtime scope because dependencies - * cannot appear in {@code requires} statement if they didn't had compile scope. + * cannot appear in {@code requires} statement if they don't have compile scope. */ protected abstract PathScope getDependencyScope(); @@ -240,10 +246,10 @@ protected ToolProvider getJarTool() throws MojoException { } /** - * Returns the output time stamp or, as a fallback, the {@code SOURCE_DATE_EPOCH} environment variable. - * If the time stamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is. + * Returns the output timestamp or, as a fallback, the {@code SOURCE_DATE_EPOCH} environment variable. + * If the timestamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is. * - * @return the time stamp in presumed ISO 8601 format, or {@code null} if none + * @return the timestamp in presumed ISO 8601 format, or {@code null} if none * @throws MojoException if the timestamp looks like a number of seconds but cannot be parsed as such * * @since 4.0.0-beta-2 @@ -272,14 +278,14 @@ protected String getOutputTimestamp() { /** * {@return the patterns of files to include, or an empty list if no include pattern was specified} */ - protected List getIncludes() { + final List getIncludes() { return asList(includes); } /** * {@return the patterns of files to exclude, or an empty list if no exclude pattern was specified} */ - protected List getExcludes() { + final List getExcludes() { return asList(excludes); } @@ -303,7 +309,7 @@ private static List asList(String[] elements) { * However, this method does not try to create the parent directory, which should be the Maven sub-project.

* * @return the directory containing the generated JAR files. - * @throws IOException if the output directory did not existed and could not be created + * @throws IOException if the output directory did not exist and could not be created */ protected Path getOutputDirectory() throws IOException { Path dir = outputDirectory; @@ -346,9 +352,9 @@ final Boolean getValidate() { * type is {@code "pom"}, {@code "jar"} or {@code "test-jar"} and path * is the path to the POM or JAR file. * - *

Note that a null key does not necessarily means that the JAR is not modular. + *

Note that a null key does not necessarily mean that the JAR is not modular. * It only means that the project was not compiled with module hierarchy, - * i.e. {@code target/classes/} subdirectories having module names. + * that is {@code target/classes/} subdirectories having module names. * A project can be compiled with package hierarchy and still be modular.

* * @return the paths to the created archive files diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index e1298755..726f059c 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -43,8 +43,8 @@ /** * Files or root directories to archive for a single module. - * A single instance of {@code Archive} may contain many directories for different target Java releases. - * Many instances of {@code Archive} may exist when archiving a multi-modules project. + * A single instance of {@code Archive} can contain many directories for different target Java releases. + * Many instances of {@code Archive} can exist when archiving a multi-modules project. */ final class Archive { /** @@ -57,8 +57,8 @@ final class Archive { Path pomFile; /** - * The JAR file to create. May be an existing file, - * in which case the file creation may be skipped if the file is still up-to-date. + * The JAR file to create. Can be an existing file, + * in which case the file creation can be skipped if the file is still up-to-date. */ @Nonnull final Path jarFile; @@ -92,7 +92,7 @@ final class Archive { /** * The Maven generated {@code pom.xml} and {@code pom.properties} files, or {@code null} if none. - * This first item shall be the base directory where the files are located. + * This first item must be the base directory where the files are located. */ @Nullable List mavenFiles; @@ -113,7 +113,7 @@ final class Archive { /** * Files or root directories to archive for a single target Java release of a single module. - * The {@link Archive} enclosing shall contain at least one instance of {@code FileSet} for + * The {@link Archive} enclosing must contain at least one instance of {@code FileSet} for * the base release, and an arbitrary amount of other instances for other target releases. */ final class FileSet { @@ -126,7 +126,7 @@ final class FileSet { /** * The files or directories to include in the JAR file. - * May be absolute paths or paths relative to {@link #directory}. + * Can be absolute paths or paths relative to {@link #directory}. * It usually contains only the files or directories directly in * the root {@linkplain #directory}, not in sub-directories. */ @@ -194,12 +194,12 @@ void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { * Our tests suggest that the first file after the directory specified by the {@code -C} option must * be relative to that directory and all files after the first one must be prefixed by the directory * which was specified in the {@code -C} option. This behavior is not very intuitive and replying on - * it may be fragile. Furthermore, it seems that the relativized file needs to be the shortest one, + * it can be fragile. Furthermore, it seems that the relativized file needs to be the shortest one, * otherwise the {@code jar} tool rejects files after the first one with "names do not match". * Which file is first depends on the unspecified directory-iteration order. * Repeating the {@code -C} option for each file seems safer. * - * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances + * @param addTo the list to add the arguments as {@link String} or {@link Path} instances to * @param version the target Java release, or {@code null} for the base version of the JAR file * @throws IllegalArgumentException if a path cannot be made relative to the base directory */ @@ -299,7 +299,7 @@ FileSet baseRelease() { /** * Returns the {@code module-info.class} files. Conceptually, there is at most once such file per module. - * However, more than one file may exist if additional files are provided for additional Java releases. + * However, more than one file can exist if additional files are provided for additional Java releases. * This method returns only the files that exist. * * @return all {@code module-info.class} files found for all target Java releases @@ -361,8 +361,8 @@ public void prune(final boolean skipIfEmpty) { /** * {@return whether this archive has nothing to archive} - * Note that this method may return {@code false} even when there is zero file to archive. - * It may happen if {@link AbstractJarMojo#skipIfEmpty} is {@code false}. In such case, the + * This method can return {@code false} even when there is zero file to archive. + * It can happen if {@link AbstractJarMojo#skipIfEmpty} is {@code false}. In such case, the * "empty" JAR file will still contain at {@code META-INF/MANIFEST.MF} file. * *

Prerequisites

@@ -408,7 +408,7 @@ FileSet newTargetRelease(Path directory, Runtime.Version version) { * Java tools). If a module is specified, the main class is kept only if the module match. The intent is to * allow users to specify on which module the main class applies when they use plugin configuration. * - *

This method may modify the {@code content} manifest. Caller shall ensure that the given manifest + *

This method may modify the {@code content} manifest. Caller must ensure that the given manifest * is not a shared instance. This method returns whether a change has actually been done.

* * @param content combination of existing {@code MANIFEST.MF} and manifest inferred from configuration, or null @@ -486,7 +486,7 @@ Manifest mergeManifest(Path file, Manifest content) throws IOException { /** * Adds to the given list the arguments to provide to the "jar" tool for each version. - * Elements added to the list shall be instances of {@link String} or {@link Path}. + * Elements added to the list must be instances of {@link String} or {@link Path}. * Callers should have added the following options (if applicable) before to invoke this method: * *
    @@ -543,7 +543,7 @@ void arguments(final List addTo) { * * * @param validate whether to validate, or {@code null} for automatic based on heuristic rules - * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances + * @param addTo the list to add the arguments as {@link String} or {@link Path} instances to * @return whether a validation should be run */ boolean validate(final Boolean validate, final List addTo) { @@ -620,7 +620,7 @@ Path writeDebugFile(Path baseDir, Path debugDirectory, String classifier, List/META-INF/versions"} directory. * This is part of the JAR specification, except the {@code } prefix. - * The sub-directories are named according Java releases such as "21". + * The sub-directories are named according to Java releases such as "21". * The next location can only be {@link #RESOURCES}. */ VERSIONS, /** * The Maven-specific {@code "META-INF/versions-modular"} directory. - * Note that {@code "/META-INF/versions-modular"} is not forbidden, but does not make sense. - * The sub-directories are named according Java releases such as "21". + * {@code "/META-INF/versions-modular"} is not forbidden, but does not make sense. + * The sub-directories are named according to Java releases such as "21". * The next location can only be {@link #MODULES}. */ VERSIONS_MODULAR, /** * The Maven-specific {@code "META-INF/versions-modular"} directory. - * All sub-directories shall have the name of a Java module. + * All sub-directories must have the name of a Java module. * The next location can only be {@link #NAMED_MODULE}. */ MODULES, @@ -67,7 +67,7 @@ enum DirectoryRole { /** * The classes or other types of resources to include in a single archive. - * May also be other files in the {@code META-INF} directory. + * Can also be other files in the {@code META-INF} directory. */ RESOURCES } diff --git a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java index 91de3a4f..31b2be18 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java +++ b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java @@ -27,7 +27,7 @@ /** * A list of files to temporarily move outside the directory to package in a JAR archive. - * This is used for excluding files from the JAR archive according include/exclude filters. + * This is used for excluding files from the JAR archive according to include/exclude filters. * We move these files for making possible to specify the whole directory to the {@code jar} tool. * This approach is used instead of enumerating files in arguments given to the {@code jar} tool because * such enumeration can not contain directory entries (otherwise the whole directory would be included). diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index 3fb0ddfc..66f1c08d 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -139,14 +139,14 @@ final class FileCollector extends SimpleFileVisitor { /** * The current module being archived. This field is updated every times that {@code FileCollector} - * enters in a new module directory. + * visits a new module directory. */ @Nonnull private Archive currentModule; /** * The module and target Java release currently being scanned. This field is updated every times that - * {@code FileCollector} enters in a new module directory or in a new target Java release for a given module. + * {@code FileCollector} visits a new module directory or in a new target Java release for a given module. */ @Nonnull private Archive.FileSet currentFilesToArchive; @@ -211,7 +211,7 @@ private void resetToPackageHierarchy() { * * @param directory a {@code ""} or {@code "META-INF/versions-modular/"} directory */ - private void enterModuleDirectory(final Path directory) { + private void preVisitVersionDirectory(final Path directory) { String moduleName = directory.getFileName().toString(); currentModule = moduleHierarchy.computeIfAbsent( moduleName, (name) -> context.newArchive(name, currentTargetVersion, directory)); @@ -228,7 +228,7 @@ private void enterModuleDirectory(final Path directory) { * @param useDirectly whether the directory is {@code "META-INF/versions/"} * @return whether to skip the directory because of invalid version number */ - private boolean enterVersionDirectory(final Path directory, final boolean useDirectly) { + private boolean preVisitVersionDirectory(final Path directory, final boolean useDirectly) { try { currentTargetVersion = Runtime.Version.parse(directory.getFileName().toString()); } catch (IllegalArgumentException e) { @@ -244,7 +244,7 @@ private boolean enterVersionDirectory(final Path directory, final boolean useDir /** * Determines if the given directory should be scanned for files to archive. * This method may also update {@link #currentFilesToArchive} if it detects - * that we are entering in a new module or a new target Java release. + * that we are visiting the content of a new module or a new target Java release. * * @param directory the directory which will be traversed * @param attributes the directory's basic attributes @@ -266,7 +266,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt switch (role) { case ROOT: /* - * Entering in any subdirectory of `target/classes` (or other directory to archive). + * Visiting any subdirectory of `target/classes` (or other directory to archive). * We need to handle `META-INF` and modules in a special way, and archive the rest. */ if (directory.endsWith(MetadataFiles.META_INF)) { @@ -274,7 +274,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt checkForManifest = true; } else if (Files.isRegularFile(directory.resolve(MODULE_DESCRIPTOR_FILE_NAME))) { role = DirectoryRole.NAMED_MODULE; - enterModuleDirectory(directory); + preVisitVersionDirectory(directory); } else { role = DirectoryRole.RESOURCES; } @@ -282,7 +282,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt case META_INF: /* - * Entering in a subdirectory of `META-INF` or `/META-INF`. We will need to handle + * Visiting a subdirectory of `META-INF` or `/META-INF`. We will need to handle * `MANIFEST.MF`, `versions` and `versions-modular` in a special way, and archive the rest. */ if (detectMultiReleaseJar && directory.endsWith(VERSIONS)) { @@ -300,11 +300,11 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt case VERSIONS: /* - * Entering in a `META-INF/versions//` directory for a specific target Java release. - * May also be a `/META-INF/versions//` directory, even if the latter is not + * Visiting a `META-INF/versions//` directory for a specific target Java release. + * Can also be a `/META-INF/versions//` directory, even if the latter is not * the layout generated by Maven Compiler Plugin. */ - if (enterVersionDirectory(directory, true)) { + if (preVisitVersionDirectory(directory, true)) { // An error occurred while parsing the version number. return FileVisitResult.SKIP_SUBTREE; } @@ -313,11 +313,11 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt case VERSIONS_MODULAR: /* - * Entering in a `META-INF/versions-modular//` directory for a specific target Java release. + * Visiting a `META-INF/versions-modular//` directory for a specific target Java release. * That directory contains all modules for the version. */ resetToPackageHierarchy(); // No module in particular yet. - if (enterVersionDirectory(directory, false)) { + if (preVisitVersionDirectory(directory, false)) { // An error occurred while parsing the version number. return FileVisitResult.SKIP_SUBTREE; } @@ -326,15 +326,15 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt case MODULES: /* - * Entering in a `META-INF/versions-modular//` directory. + * Visiting a `META-INF/versions-modular//` directory. */ - enterModuleDirectory(directory); + preVisitVersionDirectory(directory); role = DirectoryRole.NAMED_MODULE; break; case NAMED_MODULE: /* - * Entering in a `` or `META-INF/versions-modular//` subdirectory. + * Visiting a `` or `META-INF/versions-modular//` subdirectory. * A module could have its own `META-INF` subdirectory, so we need to check again. */ if (directory.endsWith(MetadataFiles.META_INF)) { @@ -348,7 +348,7 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt } /* * Do not move this condition inside the `switch` block because `role` may have been modified. - * The `role` value is now the role of `directory`, not anymore the role of parent directory. + * The `role` value is now the role of `directory`, not the role of parent directory. */ if (role == DirectoryRole.RESOURCES) { currentFilesToArchive.add(directory, attributes, true); @@ -356,8 +356,8 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt /* * Since we are skipping the whole directory, `postVisitDirectory(…)` will not be invoked. * We must reset `currentFilesToArchive` and `currentTargetVersion` by an explicit call. - * This is important mostly after we added a whole `META-INF/versions/` directory, - * otherwise base files visited afterwards (directory iteration order is unspecified) + * This is important mostly after we added a whole `META-INF/versions/` directory. + * Otherwise, since directory iteration order is unspecified, base files visited afterwards * would be added to this version's file set instead of the base release. */ resetToParentDirectoryState(); @@ -369,8 +369,8 @@ public FileVisitResult preVisitDirectory(final Path directory, final BasicFileAt } /** - * Updates the {@code FileCollector} state after we finished to scan the content of a directory. - * The fields to update depend on which directory has been visited (module, version, etc.). + * Updates the {@code FileCollector} state after we finished scanning the contents of a directory. + * The fields to update depend on which directory has been visited (module, version, etc.). * * @param directory the directory which has been traversed * @param error the error that occurred while traversing the directory, or {@code null} if none @@ -397,8 +397,8 @@ public FileVisitResult postVisitDirectory(final Path directory, final IOExceptio } /** - * Updates {@code FileCollector} to a state suitable to the parent of the directory that we finished to scan. - * Contrarily to {@link #postVisitDirectory(Path, IOException)}, this method expects the last element of + * Updates {@code FileCollector} to a state suitable for the parent of the directory that we finished to scan. + * Contrary to {@link #postVisitDirectory(Path, IOException)}, this method expects the last element of * {@link #directoryRoles} to describe the parent directory, not the directory that we finished to visit. */ @SuppressWarnings("checkstyle:MissingSwitchDefault") @@ -440,8 +440,8 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr /** * Removes all empty archives and ensures that the lowest version is declared as the base version. * This method should be invoked after all output directories to archive have been fully scanned. - * If {@code skipIfEmpty} is {@code false}, then this method ensures that at least one archive - * remains even if that archive is empty. + * If {@code skipIfEmpty} is {@code false}, this method ensures that at least one archive remains + * even if that archive is empty. * * @param skipIfEmpty value of {@link AbstractJarMojo#skipIfEmpty} */ @@ -453,7 +453,7 @@ public void prune(boolean skipIfEmpty) { } /** - * Moves, copies or ignores orphan files. + * Moves, copies, or ignores orphan files. * An orphan file is a file which is not in any module when module hierarchy is used. * For example, some Maven plugins may create files such as {@code META-INF/LICENSE}, * {@code META-INF/NOTICE} or {@code META-INF/DEPENDENCIES}. These files are not in @@ -462,10 +462,10 @@ public void prune(boolean skipIfEmpty) { * *

    A possible strategy could be to copy the {@code LICENSE} and {@code NOTICE} files * in each module, and ignore the {@code DEPENDENCIES} file because its content is not - * correct for a module. For now, we just log a warning an ignore.

    + * correct for a module. For now, we just log a warning and ignore.

    * *

    Prerequisites

    - * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + * The {@link #prune(boolean)} method should have been invoked once before invoking this method. * * @return if this method ignored some files, the root directory of those files */ @@ -491,7 +491,7 @@ private ExcludedFiles exclusion() throws IOException { /** * Writes all JAR files. - * If the project is multi-module, then this method returns the path to the generated parent POM file. + * If the project is multi-module, this method returns the path to the generated parent POM file. * *

    Prerequisites

    * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. @@ -522,16 +522,16 @@ Path writeAllJARs(final ToolExecutor executor) throws IOException { * {@return the paths to all root directories of modules in a module hierarchy} * They are usually {@code target/classes/} directories, but could also * be sub-directories in the {@code META-INF/modular-versions//} directory. - * If the project does not use module hierarchy, then this method returns an empty list. + * If the project does not use module hierarchy, this method returns an empty list. * *

    Ignored package hierarchy

    - * Note that an empty list does not necessarily means that the JAR is not modular, + * Note that an empty list does not mean that the JAR is not modular, * as a modular JAR can also be built from package hierarchy. But we intentionally * ignore the latter case because this method is used for deriving POM files, and - * we do not perform such derivation for projects organized in the classical Maven 3 way. + * we do not perform such derivation for projects organized in the Maven 3 way. * *

    Prerequisites

    - * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + * The {@link #prune(boolean)} method should have been invoked once before invoking this method. */ List getModuleHierarchyRoots() { return moduleHierarchy.values().stream() diff --git a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java index b26305c5..51264e8b 100644 --- a/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java +++ b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java @@ -70,7 +70,7 @@ final class MetadataFiles implements Closeable { /** * All files and directories in the order that they were created. - * The first element of this list shall be the root temporary directory created by this class. + * The first element of this list must be the root temporary directory created by this class. */ private final List filesToDelete; @@ -178,11 +178,11 @@ public List addPOM(final MavenArchiveConfiguration archive, final boolean final Path baseDir = baseDirectory(); final Path mavenDir = createDirectories(baseDir, META_INF, MAVEN_DIR, groupId, artifactId); final Path pomFile = linkOrCopy(attachedPOM, mavenDir.resolve("pom.xml")); - filesToDelete.add(pomFile); // Add soon for deleting this file even if an exception is thrown below. + filesToDelete.add(pomFile); // Register now for deleting this file even if an exception is thrown below. /* * Subset of above "pom.xml" file but written as a properties file. * If reproducible build is enabled, we will need to reformat after - * writing for ensuring a deterministic order of entries. + * writing to ensure a deterministic order of entries. */ final var properties = new Properties(); Path propertiesFile = archive.getPomPropertiesFile(); @@ -196,7 +196,7 @@ public List addPOM(final MavenArchiveConfiguration archive, final boolean properties.setProperty("version", version); propertiesFile = mavenDir.resolve("pom.properties"); try (BufferedWriter out = Files.newBufferedWriter(propertiesFile)) { - filesToDelete.add(propertiesFile); // Add soon for deleting this file even if an exception is thrown below. + filesToDelete.add(propertiesFile); // Register now for deleting even if an exception is thrown below. properties.store(out, "Subset of pom.xml"); } if (reproducible) { diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java index 1cb6acb2..43d7b0bb 100644 --- a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -99,9 +99,9 @@ final class PomDerivation { private final Map fromURI; /** - * Module names associated to Maven dependencies. + * Module names associated with Maven dependencies. * This map contains {@link DependencyCoordinates#getId()} as keys and module references as values. - * This is used for detecting which dependencies are really used according {@code module-info.class}. + * This is used to detect which dependencies are really used according to {@code module-info.class}. * * @todo The keys should be instances of {@link DependencyCoordinates}. Unfortunately, as of Maven 4.0.0-rc-5 * that interface does not define the {@code equals} and {@code hashCode} contracts. @@ -455,7 +455,7 @@ private Model deriveModulePOM() throws ModelBuilderException { } /** - * Modifies the optional and scope elements of the given dependency according the given modifiers. + * Modifies the optional and scope elements of the given dependency according to the given modifiers. * * @param dependency the dependency to amend * @param modifiers the modifiers to apply diff --git a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java index 003ba88c..8a663aa8 100644 --- a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -37,8 +37,8 @@ /** * Checks file timestamps in order to determine if anything changed compared to an existing JAR file. * This class may scan directories, but only if they have not already been visited by {@link FileCollector}. - * Note that the latter can occur only if {@link FileCollector} has no {@code PathMatcher}. - * Therefore, this class uses no {@code PathMatcher} neither. + * The latter can only occur if {@link FileCollector} has no {@code PathMatcher}. + * Therefore, this class uses no {@code PathMatcher}. * *

    Ignore files

    * The {@code META-INF/MANIFEST.MF} file and the {@code META-INF/maven/} directory are ignored. @@ -67,13 +67,13 @@ final class TimestampCheck extends SimpleFileVisitor { /** * Entries of the JAR file. Note that getting elements from this enumeration can be costly. - * Therefore, we do not fetch all elements in advance but only when needed. + * Therefore, we only fetch elements when needed. */ private Enumeration entries; /** * Files found in the JAR file but not yet traversed by the file visitor. - * Files are added lazily only when needed, and removed as soon as they have been traversed. + * Files are added lazily when needed, and removed as soon as they have been traversed. * Path are absolute (resolved with {@link #classesDir}). * For each entry, the associated value is whether the path is a directory. */ @@ -81,7 +81,7 @@ final class TimestampCheck extends SimpleFileVisitor { /** * Some of the files in the build directory. This list contains only the files for which we have already - * verified the timestamp. We store them in a separated list for avoiding to check the timestamp twice. + * verified the timestamp. We store them in a separate list for avoiding to check the timestamp twice. * We need this list because we still need to verify if the files are in the {@link #jarFile}. * For each entry, the associated value is whether the path is a directory. */ @@ -138,7 +138,7 @@ boolean isUpToDateJAR(final Collection fileSets) { try (ZipFile jar = new ZipFile(jarFile.toFile())) { entries = jar.entries(); for (Path file : filesInBuild.keySet()) { - if (!isFoundInJAR(file)) { + if (!removeFromFilesInJAR(file)) { return false; } } @@ -229,7 +229,7 @@ private boolean hasUpdatedInSubdir(final Path directory) throws IOException { */ @Override public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) { - if (jarFileTime.compareTo(attributes.lastModifiedTime()) >= 0 && isFoundInJAR(file)) { + if (jarFileTime.compareTo(attributes.lastModifiedTime()) >= 0 && removeFromFilesInJAR(file)) { return FileVisitResult.CONTINUE; } else { hasUpdates = true; @@ -244,7 +244,7 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr * @param file the file to check * @return whether the given file was found in the JAR file */ - private boolean isFoundInJAR(final Path file) { + private boolean removeFromFilesInJAR(final Path file) { if (filesInJAR.remove(file) != null) { return true; } diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 103111ad..1ef5027a 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -129,7 +129,7 @@ final class ToolExecutor { /** * A buffer for the arguments given to the "jar" tool, reused for each module. - * Each element of the list shall be instances of either {@link String} or {@link Path}. + * Each element of the list must be instances of either {@link String} or {@link Path}. */ private final List arguments; @@ -161,8 +161,8 @@ final class ToolExecutor { /** * The file from which {@link #manifestFromPlugin} has been read, or {@code null} if none. - * If non-null, reading that file shall produce the same manifest as {@link #manifestFromPlugin}. - * It implies that this field shall be {@code null} if {@link #manifestFromPlugin} is the result + * If non-null, reading that file must produce the same manifest as {@link #manifestFromPlugin}. + * It implies that this field must be {@code null} if {@link #manifestFromPlugin} is the result * of merging elements specified in {@code } with a file specified in the plugin configuration. */ private final Path manifestFile; @@ -174,7 +174,7 @@ final class ToolExecutor { /** * The timestamp in ISO-8601 extended offset date-time, or {@code null} if none. - * If user provided a value in seconds, it shall have been converted to ISO-8601. + * If user provided a value in seconds, it must have been converted to ISO-8601. * This is used for reproducible builds. */ private final String outputTimestamp; diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index 38580060..8f5b4498 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -76,7 +76,7 @@ private static Object mainClassOf(Manifest m) { * Verifies that {@link Archive#setMainClass(Manifest)} takes ownership for a module-qualified * {@code "module/Class"} main class. * - * Note that the {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. + * The {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. */ @Test @@ -92,7 +92,7 @@ void owningModuleClaimsMainClassAndRemovesItFromManifest() { /** * Verifies that {@link Archive#setMainClass(Manifest)} does not take ownership fo * a module-qualified {@code "module/Class"} main class when the module name does not match. - * Note that the {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. + * The {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. */ @Test @@ -104,7 +104,7 @@ void nonOwningModuleRejectsMainClass() { } /** - * Tests that which module keeps the main class must not depend on processing order. + * Tests that which module keeps the main class does not depend on processing order. * {@link ToolExecutor} gives each module a copy of the shared plugin manifest; * this pins that the owning module (and only it) keeps the main class in either order, * and that the shared manifest is never consumed. @@ -120,7 +120,7 @@ void mainClassAssignmentIsIndependentOfModuleOrder() { * Asserts that {@link Archive#setMainClass(Manifest)} returns {@code true} * for the owner and {@code false} for the other module. * - *

    Note that the {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. + *

    The {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. * This extension is used by the plugin for identifying in which JAR file to add this * {@code Main-Class} manifest entry.

    @@ -166,7 +166,7 @@ void baseReleaseBindingIsIndependentOfDirectoryOrder() { * *

    Historical note

    * In our tests, it seems that the first JAR entry after the {@code -C} option - * shall be relative, and only that file. Furthermore, it seems that this file shall be the + * must be relative, and only that file. Furthermore, it seems that this file must be the * shortest. We tried to apply this heuristic rules in a branch, but it does not save a lot * of characters compared to repeating {@code -C}. */ From cdf4da5776e5702b162417c3c75f254b7979aee4 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Thu, 3 Sep 2026 19:47:27 +0900 Subject: [PATCH 23/25] Apply more English corrections and a few API changes suggested on the pull request. --- .../maven/plugins/jar/AbstractJarMojo.java | 15 +++---- .../org/apache/maven/plugins/jar/Archive.java | 45 +++++++++---------- .../maven/plugins/jar/ExcludedFiles.java | 13 +++--- .../maven/plugins/jar/FileCollector.java | 13 +++--- .../maven/plugins/jar/PomDerivation.java | 11 ++--- .../maven/plugins/jar/TimestampCheck.java | 8 ++-- .../maven/plugins/jar/ToolExecutor.java | 8 ++-- .../apache/maven/plugins/jar/ArchiveTest.java | 6 +-- 8 files changed, 60 insertions(+), 59 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 0330ed69..7dd2945e 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -44,7 +44,7 @@ import org.apache.maven.shared.archiver.MavenArchiver; /** - * Base class for creating a JAR file from project classes. + * Base class for creating a JAR file from project classes. * * @author Emmanuel Venisse * @author Martin Desruisseaux @@ -74,13 +74,13 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj protected String[] excludes; /** - * Directory containing the generated JAR files. + * Directory containing the generated JAR files. */ @Parameter(defaultValue = "${project.build.directory}", required = true) protected Path outputDirectory; /** - * Name of the generated JAR file. + * Name of the generated JAR file. * The default value is {@code "${project.build.finalName}"}, * which itself defaults to {@code "${artifactId}-${version}"}. * Ignored if the Maven sub-project to archive uses module hierarchy. @@ -124,7 +124,7 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * By default, this plugin looks to see if the output JAR files exist and inputs have not changed. * If these conditions are true, the plugin skips creation of the JAR files. * This does not work when other plugins, like the maven-shade-plugin, are configured to post-process the JAR. - * This plugin can not detect the post-processing, and so leaves the post-processed JAR file in place. + * This plugin cannot detect the post-processing, and so leaves the post-processed JAR file in place. * This can lead to failures when those plugins do not expect to find their own output as an input. * Set this parameter to {@code true} to avoid these problems by forcing this plugin to recreate the JAR every time. * @@ -148,7 +148,8 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * If not configured or disabled, * the SOURCE_DATE_EPOCH * environment variable is used as a fallback value, - * to ease forcing Reproducible Build externally when the build has not enabled it natively in POM. + * to ease forcing Reproducible Build externally when the build has not enabled it natively in + * POM. * *

    This property is supported only in Java 19 or later.

    * @@ -402,9 +403,7 @@ public void execute() throws MojoException { final Map> artifactFiles; try { artifactFiles = createArchives(); - } catch (MojoException e) { - throw e; - } catch (Exception e) { + } catch (IOException e) { throw new MojoException("Error while assembling the JAR file.", e); } if (artifactFiles.isEmpty()) { diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 726f059c..3c7c0c65 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -57,7 +57,7 @@ final class Archive { Path pomFile; /** - * The JAR file to create. Can be an existing file, + * The JAR file to create. Can be an existing file, * in which case the file creation can be skipped if the file is still up-to-date. */ @Nonnull @@ -125,7 +125,7 @@ final class FileSet { final Path directory; /** - * The files or directories to include in the JAR file. + * The files or directories to include in the JAR file. * Can be absolute paths or paths relative to {@link #directory}. * It usually contains only the files or directories directly in * the root {@linkplain #directory}, not in sub-directories. @@ -178,8 +178,7 @@ private Path discardAllFiles(Path base) { * @param isDirectory whether the file is a directory */ void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { - TimestampCheck tc = existingJAR; - if (tc != null && tc.isUpdated(item, attributes, isDirectory)) { + if (existingJAR != null && existingJAR.isUpdated(item, attributes, isDirectory)) { existingJAR = null; // Signal that the existing file is outdated. } files.add(item); @@ -203,7 +202,7 @@ void add(Path item, BasicFileAttributes attributes, boolean isDirectory) { * @param version the target Java release, or {@code null} for the base version of the JAR file * @throws IllegalArgumentException if a path cannot be made relative to the base directory */ - private void arguments(List addTo, Runtime.Version version) { + private void addArgumentsTo(List addTo, Runtime.Version version) { if (files.isEmpty()) { return; } @@ -298,7 +297,7 @@ FileSet baseRelease() { } /** - * Returns the {@code module-info.class} files. Conceptually, there is at most once such file per module. + * Returns the {@code module-info.class} files. Conceptually, there is at most one such file per module. * However, more than one file can exist if additional files are provided for additional Java releases. * This method returns only the files that exist. * @@ -361,12 +360,12 @@ public void prune(final boolean skipIfEmpty) { /** * {@return whether this archive has nothing to archive} - * This method can return {@code false} even when there is zero file to archive. - * It can happen if {@link AbstractJarMojo#skipIfEmpty} is {@code false}. In such case, the - * "empty" JAR file will still contain at {@code META-INF/MANIFEST.MF} file. + * This method can return {@code false} even when there are no file to archive. + * It can happen if {@link AbstractJarMojo#skipIfEmpty} is {@code false}. In this case, + * the "empty" JAR file will still contain at {@code META-INF/MANIFEST.MF} file. * - *

    Prerequisites

    - * The {@link #prune(boolean)} method should be invoked before this method for accurate result. + *

    Prerequisites: + * The {@link #prune(boolean)} method should be invoked before this method for accurate result.

    */ public boolean isEmpty() { return filesetForRelease.isEmpty(); @@ -375,20 +374,20 @@ public boolean isEmpty() { /** * Checks whether the JAR file already exists and can be reused. * This method verifies that the JAR file contains all the files to archive, - * contains no extra file, and no file to archive is newer than the JAR file. + * contains no extra files, and no file to archive is newer than the JAR file. * *

    This method can be invoked only once. - * If invoked more often, it returns {@code false} on all subsequent invocations.

    + * If invoked more than once, it returns {@code false} on all subsequent invocations.

    * * @return whether the JAR file already exists and can be reused */ public boolean isUpToDateJAR() { - final TimestampCheck tc = existingJAR; - if (tc == null) { + final TimestampCheck candidate = existingJAR; + if (candidate == null) { return false; } existingJAR = null; // Let GC do its job. - return tc.isUpToDateJAR(filesetForRelease.values()); + return candidate.isUpToDateJAR(filesetForRelease.values()); } /** @@ -408,8 +407,8 @@ FileSet newTargetRelease(Path directory, Runtime.Version version) { * Java tools). If a module is specified, the main class is kept only if the module match. The intent is to * allow users to specify on which module the main class applies when they use plugin configuration. * - *

    This method may modify the {@code content} manifest. Caller must ensure that the given manifest - * is not a shared instance. This method returns whether a change has actually been done.

    + *

    This method can modify the {@code content} manifest. Caller must ensure that the given manifest + * is not a shared instance. This method returns whether a change has been done.

    * * @param content combination of existing {@code MANIFEST.MF} and manifest inferred from configuration, or null * @return whether the given manifest has been modified by this method @@ -453,7 +452,7 @@ boolean setManifest(Path file, boolean force) { * If both {@code file} and {@code content} are non-null, then {@code content} must be the * result of reading {@code file}. * - *

    This method never modifies the given {@code content} object. If manifest are merged, + *

    This method never modifies the given {@code content} object. If manifests are merged, * a new {@link Manifest} instance is created. Therefore, caller can check whether this * method returned a new instance as a way to recognize that a merge occurred.

    * @@ -511,7 +510,7 @@ Manifest mergeManifest(Path file, Manifest content) throws IOException { * * @param addTo the list where to add the arguments as {@link String} or {@link Path} instances */ - void arguments(final List addTo) { + void addArgumentsTo(final List addTo) { addTo.add("--file"); addTo.add(jarFile); if (manifest != null) { @@ -527,7 +526,7 @@ void arguments(final List addTo) { addTo.addAll(mavenFiles); } for (Map.Entry entry : filesetForRelease.entrySet()) { - entry.getValue().arguments(addTo, entry.getKey()); + entry.getValue().addArgumentsTo(addTo, entry.getKey()); } } @@ -546,7 +545,7 @@ void arguments(final List addTo) { * @param addTo the list to add the arguments as {@link String} or {@link Path} instances to * @return whether a validation should be run */ - boolean validate(final Boolean validate, final List addTo) { + boolean addValidateArgumentsTo(final Boolean validate, final List addTo) { boolean effective; if (validate != null) { effective = validate; @@ -571,7 +570,7 @@ boolean validate(final Boolean validate, final List addTo) { * @param baseDir project base directory for relativizing the arguments * @param debugDirectory the directory where to write the debug file * @param classifier the classifier (e.g. "tests"), or {@code null} if none - * @param arguments the arguments formatted by {@link #arguments(List)} + * @param arguments the arguments formatted by {@link #addArgumentsTo(List)} * @return the debug file where arguments have been written * @throws IOException if an error occurred while writing the debug file */ diff --git a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java index 31b2be18..c0fff941 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java +++ b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java @@ -27,10 +27,11 @@ /** * A list of files to temporarily move outside the directory to package in a JAR archive. - * This is used for excluding files from the JAR archive according to include/exclude filters. + * This class is needed when the {@link AbstractJarMojo} configuration has include or exclude filters. + * Excluded files are temporarily moved outside the directory to archive. * We move these files for making possible to specify the whole directory to the {@code jar} tool. * This approach is used instead of enumerating files in arguments given to the {@code jar} tool because - * such enumeration can not contain directory entries (otherwise the whole directory would be included). + * such enumeration cannot contain directory entries (otherwise the whole directory would be included). * Some software such as Spring applications component scan relies on the presence of directory entries. */ final class ExcludedFiles implements Closeable { @@ -69,9 +70,9 @@ final class ExcludedFiles implements Closeable { */ ExcludedFiles(Path directory, List excludedFiles, List excludedDirectories) throws IOException { indexOfFirstDirectory = excludedFiles.size(); - final int nd = excludedDirectories.size(); - original = excludedFiles.toArray(new Path[indexOfFirstDirectory + nd]); - System.arraycopy(excludedDirectories.toArray(), 0, original, indexOfFirstDirectory, nd); + final int numDirs = excludedDirectories.size(); + original = excludedFiles.toArray(new Path[indexOfFirstDirectory + numDirs]); + System.arraycopy(excludedDirectories.toArray(), 0, original, indexOfFirstDirectory, numDirs); moved = new Path[original.length]; temporaryDirectory = Files.createTempDirectory(directory, "excluded-"); } @@ -113,7 +114,7 @@ public void move() throws IOException { /** * Moves the temporary files back to their original locations. * This method can be invoked even if only a subset of the files were moved. - * The latter may happen if an error occurred in the middle of {@link #move()} execution. + * The latter can happen if an error occurred in the middle of {@link #move()} execution. * * @throws IOException if an error occurred while moving a temporary files or deleting the temporary directory. */ diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index 66f1c08d..3df2aa98 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -52,7 +52,8 @@ * * * - * The reason for using the options is that they allow the {@code jar} tool to perform additional verifications. + * The reason for using the {@code --release} and {@code --manifest} options instead of adding explicitly + * the entries is because the options allow the {@code jar} tool to perform additional verifications. * For example, when using the {@code --release} option, {@code jar} verifies the API compatibility. */ final class FileCollector extends SimpleFileVisitor { @@ -207,7 +208,7 @@ private void resetToPackageHierarchy() { /** * Declares that the given directory is the base directory of a module. * For an output generated by {@code javac} from a module source hierarchy, - * the directory name is guaranteed to be the module name. + * the directory name is the module name. * * @param directory a {@code ""} or {@code "META-INF/versions-modular/"} directory */ @@ -464,8 +465,8 @@ public void prune(boolean skipIfEmpty) { * in each module, and ignore the {@code DEPENDENCIES} file because its content is not * correct for a module. For now, we just log a warning and ignore.

    * - *

    Prerequisites

    - * The {@link #prune(boolean)} method should have been invoked once before invoking this method. + *

    Prerequisites: + * The {@link #prune(boolean)} method should have been invoked once before invoking this method.

    * * @return if this method ignored some files, the root directory of those files */ @@ -493,8 +494,8 @@ private ExcludedFiles exclusion() throws IOException { * Writes all JAR files. * If the project is multi-module, this method returns the path to the generated parent POM file. * - *

    Prerequisites

    - * The {@link #prune(boolean)} method should have been invoked once before to invoke this method. + *

    Prerequisites: + * The {@link #prune(boolean)} method should have been invoked once before to invoke this method.

    * * @return path to the generated parent POM file, or {@code null} if none * @throws MojoException if an error occurred during the execution of the "jar" tool diff --git a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java index 43d7b0bb..de6abd6f 100644 --- a/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -137,14 +137,14 @@ final class PomDerivation { rethrow(result); final Map dependencies = result.getDependencies(); final Path[] allModulePaths = toRealPaths(moduleRoots, dependencies.values()); - fromURI = new HashMap<>(allModulePaths.length); // TODO: use newHashMap with JDK19. + fromURI = new HashMap<>(allModulePaths.length); moduleFinder = ModuleFinder.of(allModulePaths); if (moduleFinder != null) { for (ModuleReference reference : moduleFinder.findAll()) { reference.location().ifPresent((location) -> fromURI.put(location, reference)); } } - fromDependency = new HashMap<>(dependencies.size()); // TODO: use newHashMap with JDK19. + fromDependency = new HashMap<>(dependencies.size()); for (Map.Entry entry : dependencies.entrySet()) { Path modulePath = entry.getValue().toRealPath(); ModuleReference reference = fromURI.get(modulePath.toUri()); @@ -484,6 +484,7 @@ private static Dependency amend(Dependency dependency, EnumSet modifie * * @see ForModule#writeModulePOM() */ + @SuppressWarnings("deprecation") Path writeParentPOM(final Archive packageHierarchy) throws IOException { Path pomFile = derivePathToPOM(packageHierarchy.jarFile); Model.Builder builder = Model.newBuilder(projectModel, true); @@ -520,9 +521,9 @@ private static Path derivePathToPOM(final Path jarFile) { */ private static void writePOM(Model model, Path file) throws IOException, XMLStreamException { try (BufferedWriter out = Files.newBufferedWriter(file)) { - var sw = new MavenStaxWriter(); - sw.setAddLocationInformation(false); - sw.write(out, model); + var xmlWriter = new MavenStaxWriter(); + xmlWriter.setAddLocationInformation(false); + xmlWriter.write(out, model); out.newLine(); } } diff --git a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java index 8a663aa8..83f94df6 100644 --- a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -74,14 +74,14 @@ final class TimestampCheck extends SimpleFileVisitor { /** * Files found in the JAR file but not yet traversed by the file visitor. * Files are added lazily when needed, and removed as soon as they have been traversed. - * Path are absolute (resolved with {@link #classesDir}). + * Paths are absolute (resolved with {@link #classesDir}). * For each entry, the associated value is whether the path is a directory. */ private final Map filesInJAR; /** * Some of the files in the build directory. This list contains only the files for which we have already - * verified the timestamp. We store them in a separate list for avoiding to check the timestamp twice. + * verified the timestamp. We store them in a separate list to avoid checking to check the timestamp twice. * We need this list because we still need to verify if the files are in the {@link #jarFile}. * For each entry, the associated value is whether the path is a directory. */ @@ -89,7 +89,7 @@ final class TimestampCheck extends SimpleFileVisitor { /** * Whether at least one file is more recent than the JAR file. - * The scan of files will stop quickly after this flag become {@code true}. + * The scan of files will stop soon after this flag become {@code true}. */ private boolean hasUpdates; @@ -128,7 +128,7 @@ boolean isUpdated(final Path file, final BasicFileAttributes attributes, final b } /** - * Checks if the JAR file contains all the given files, no extra entry, and no outdated entry. + * Checks if the JAR file contains all the given files, no extra entries, and no outdated entry. * * @param fileSets pairs of base directory and files potentially relative to the base directory * @return whether the JAR file is up-to-date diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index 1ef5027a..d50e416a 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -306,8 +306,8 @@ Archive newArchive(final String moduleName, final Runtime.Version version, final * The derived POM files are the intersections of the project POM with the * content of {@code module-info.class} files. * - *

    Prerequisites

    - * The {@link FileCollector#prune(boolean)} method should have been invoked once before to invoke this method. + *

    Prerequisites: + * The {@link FileCollector#prune(boolean)} method should have been invoked once before to invoke this method.

    * * @param files the result of scanning the build directory for listing the files or directories to archive * @return the paths to the created archive files @@ -402,7 +402,7 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE logger.warn("Reproducible build requires Java " + JDK_SUPPORT_DATE + " or later."); } } - archive.arguments(arguments); + archive.addArgumentsTo(arguments); /* * Execute the `jar` tool with arguments determined by the values dispatched * in the various fields of the `Archive`. Information and error essages are logged. @@ -429,7 +429,7 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE * we skip the post-creation validation pass; the archive was already created * successfully by the `--create` pass above. */ - if (archive.validate(validate, arguments)) { + if (archive.addValidateArgumentsTo(validate, arguments)) { final int version = Runtime.version().feature(); if (version >= JDK_FIXING_JAR_VALIDATE) { int status = executeJarTool(); diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index 8f5b4498..279b3d30 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -90,7 +90,7 @@ void owningModuleClaimsMainClassAndRemovesItFromManifest() { } /** - * Verifies that {@link Archive#setMainClass(Manifest)} does not take ownership fo + * Verifies that {@link Archive#setMainClass(Manifest)} does not take ownership of * a module-qualified {@code "module/Class"} main class when the module name does not match. * The {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. @@ -139,7 +139,7 @@ private static void assertOwnership(String first, String second, boolean ownerIs } /** - * Tests that {@code FileSet} association to target release is consistent regardless creation order. + * Tests that {@code FileSet} association to target release is consistent regardless of creation order. * Verifies that the base (version-less) release binds to the true {@code } directory even * when the {@link Archive} was first created from a {@code META-INF/versions-modular//} * directory (which happens when the file-tree walk visits the version directory first). @@ -219,7 +219,7 @@ private static List argsAfterAdding(Path directory, Path... filesInOrder base.add(f, null, false); } var args = new ArrayList(); - archive.arguments(args); + archive.addArgumentsTo(args); return args; } From b09021426c1259d821634af39f7cc7c831deb0e0 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Thu, 3 Sep 2026 20:15:56 +0900 Subject: [PATCH 24/25] Remove the `title` attribute in `` because it is not accepted by Javadoc. --- .../org/apache/maven/plugins/jar/AbstractJarMojo.java | 8 ++++---- src/main/java/org/apache/maven/plugins/jar/Archive.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 7dd2945e..00075cde 100644 --- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java +++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java @@ -44,7 +44,7 @@ import org.apache.maven.shared.archiver.MavenArchiver; /** - * Base class for creating a JAR file from project classes. + * Base class for creating a JAR file from project classes. * * @author Emmanuel Venisse * @author Martin Desruisseaux @@ -74,13 +74,13 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj protected String[] excludes; /** - * Directory containing the generated JAR files. + * Directory containing the generated JAR files. */ @Parameter(defaultValue = "${project.build.directory}", required = true) protected Path outputDirectory; /** - * Name of the generated JAR file. + * Name of the generated JAR file. * The default value is {@code "${project.build.finalName}"}, * which itself defaults to {@code "${artifactId}-${version}"}. * Ignored if the Maven sub-project to archive uses module hierarchy. @@ -149,7 +149,7 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj * the SOURCE_DATE_EPOCH * environment variable is used as a fallback value, * to ease forcing Reproducible Build externally when the build has not enabled it natively in - * POM. + * POM. * *

    This property is supported only in Java 19 or later.

    * diff --git a/src/main/java/org/apache/maven/plugins/jar/Archive.java b/src/main/java/org/apache/maven/plugins/jar/Archive.java index 3c7c0c65..75e1001a 100644 --- a/src/main/java/org/apache/maven/plugins/jar/Archive.java +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -57,7 +57,7 @@ final class Archive { Path pomFile; /** - * The JAR file to create. Can be an existing file, + * The JAR file to create. Can be an existing file, * in which case the file creation can be skipped if the file is still up-to-date. */ @Nonnull From 40169c8709022c071a438d0d214a508055a62970 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Mon, 7 Sep 2026 20:47:31 +0900 Subject: [PATCH 25/25] Apply changes requested on the pull request, mostly English fixes and variable renaming. --- src/it/jar-without-sources/pom.xml | 2 +- .../maven/plugins/jar/DirectoryRole.java | 19 ++++++++++++------- .../maven/plugins/jar/ExcludedFiles.java | 10 +++++----- .../maven/plugins/jar/FileCollector.java | 18 +++++++++--------- .../maven/plugins/jar/TimestampCheck.java | 5 ++--- .../maven/plugins/jar/ToolExecutor.java | 6 +++--- .../apache/maven/plugins/jar/ArchiveTest.java | 19 ++++++++++--------- 7 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/it/jar-without-sources/pom.xml b/src/it/jar-without-sources/pom.xml index 3b990ad5..3b9e3a9a 100644 --- a/src/it/jar-without-sources/pom.xml +++ b/src/it/jar-without-sources/pom.xml @@ -27,7 +27,7 @@ This project has neither `src/main` nor `src/test`, so nothing is compiled and the `target` directory is never created by an earlier lifecycle phase. Versions 3.x of the JAR plugin still produced an empty, manifest-only JAR in that case. - This IT verifies that versions 4.x of the JAR plugin has the same behavior for compatibility purposes. + This IT verifies that versions 4.x of the JAR plugin have the same behavior for compatibility purposes. NOTE: it does not mean that it is a good idea to produce an empty JAR in such case. It would also be a reasonable choice if a future version decide to fail the build instead. diff --git a/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java b/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java index 79d3c5f3..461948f0 100644 --- a/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java +++ b/src/main/java/org/apache/maven/plugins/jar/DirectoryRole.java @@ -24,14 +24,14 @@ enum DirectoryRole { /** * The root directory. This is usually {@code "target/classes"}. - * The next locations can be {@link #META_INF}, {@link #NAMED_MODULE} or {@link #RESOURCES}. + * The next directory roles can be {@link #META_INF}, {@link #NAMED_MODULE} or {@link #RESOURCES}. */ ROOT, /** * The {@code "META-INF"} or {@code "/META-INF"} directory. * This is part of the JAR specification. - * The next locations can be {@link #VERSIONS} or {@link #VERSIONS_MODULAR}. + * The next directory roles can be {@link #VERSIONS} or {@link #VERSIONS_MODULAR}. */ META_INF, @@ -39,7 +39,7 @@ enum DirectoryRole { * The {@code "META-INF/versions"} or {@code "/META-INF/versions"} directory. * This is part of the JAR specification, except the {@code } prefix. * The sub-directories are named according to Java releases such as "21". - * The next location can only be {@link #RESOURCES}. + * The next directory role can only be {@link #RESOURCES}. */ VERSIONS, @@ -47,21 +47,26 @@ enum DirectoryRole { * The Maven-specific {@code "META-INF/versions-modular"} directory. * {@code "/META-INF/versions-modular"} is not forbidden, but does not make sense. * The sub-directories are named according to Java releases such as "21". - * The next location can only be {@link #MODULES}. + * The next directory role can only be {@link #MODULES}. */ VERSIONS_MODULAR, /** - * The Maven-specific {@code "META-INF/versions-modular"} directory. + * A directory other than the root directory which contains many modules. + * This is typically a {@code "META-INF/versions-modular/"} directory + * containing all modules for Java version n. * All sub-directories must have the name of a Java module. - * The next location can only be {@link #NAMED_MODULE}. + * The next directory role can only be {@link #NAMED_MODULE}. + * + *

    The root directory may also contain many modules, + * but is nevertheless handled by a separated enumeration value {@link #ROOT}.

    */ MODULES, /** * The root of a single Java module in a module hierarchy. * The name of this directory is the Java module name. - * The next location can only be {@link #RESOURCES}. + * The next directory role can only be {@link #RESOURCES}. */ NAMED_MODULE, diff --git a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java index c0fff941..7f1dca3a 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java +++ b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java @@ -29,14 +29,14 @@ * A list of files to temporarily move outside the directory to package in a JAR archive. * This class is needed when the {@link AbstractJarMojo} configuration has include or exclude filters. * Excluded files are temporarily moved outside the directory to archive. - * We move these files for making possible to specify the whole directory to the {@code jar} tool. + * We move these files to make it possible to pass an entire directory to the {@code jar} tool. * This approach is used instead of enumerating files in arguments given to the {@code jar} tool because * such enumeration cannot contain directory entries (otherwise the whole directory would be included). * Some software such as Spring applications component scan relies on the presence of directory entries. */ final class ExcludedFiles implements Closeable { /** - * The paths of files or directories to temporarily move in another directory. + * The paths of files or directories to temporarily move to another directory. */ private final Path[] original; @@ -61,11 +61,11 @@ final class ExcludedFiles implements Closeable { private final int indexOfFirstDirectory; /** - * Creates a new list of files to move in a temporary directory. + * Creates a new list of files to move to a temporary directory. * * @param directory the directory which was scanned for files to include in the JAR - * @param excludedFiles paths of files to temporarily move in another directory - * @param excludedDirectories paths of directories to temporarily move in another directory + * @param excludedFiles paths of files to temporarily move to another directory + * @param excludedDirectories paths of directories to temporarily move to another directory * @throws IOException if an error occurred while creating the temporary directory. */ ExcludedFiles(Path directory, List excludedFiles, List excludedDirectories) throws IOException { diff --git a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java index 3df2aa98..cc5e197c 100644 --- a/src/main/java/org/apache/maven/plugins/jar/FileCollector.java +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -37,8 +37,8 @@ import org.apache.maven.api.services.PathMatcherFactory; /** - * Dispatch the files from the output directory into the JAR files to create. - * Instead of just archiving as-is the content of the output directory, this class separates + * Distributes the files from the output directory into the JAR files to create. + * Instead of just archiving the content of the output directory as-is, this class separates * the following subdirectories to the options listed below: * *
      @@ -52,13 +52,13 @@ * *
    * - * The reason for using the {@code --release} and {@code --manifest} options instead of adding explicitly - * the entries is because the options allow the {@code jar} tool to perform additional verifications. - * For example, when using the {@code --release} option, {@code jar} verifies the API compatibility. + * The reason for using the {@code --release} and {@code --manifest} options instead of adding the + * entries explicitly is because the options allow the {@code jar} tool to perform additional verifications. + * For example, when using the {@code --release} option, {@code jar} verifies API compatibility. */ final class FileCollector extends SimpleFileVisitor { /** - * The file to check for deciding whether the JAR is modular. + * The file to check to decide whether the JAR is modular. */ static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class"; @@ -106,7 +106,7 @@ final class FileCollector extends SimpleFileVisitor { /** * Files to exclude. These files will be moved to a temporary location - * for allowing {@link ToolExecutor} to specify whole directories to the {@code jar} tool. + * to allow {@link ToolExecutor} to specify whole directories to the {@code jar} tool. * Specifying whole directories is preferable to enumerating the files because otherwise, * the generated JAR file contains only entries for the files and is missing * entries for the directories. @@ -119,7 +119,7 @@ final class FileCollector extends SimpleFileVisitor { /** * Directories to exclude. This field serves the same purpose as {@link #excludedFiles}, - * but where the sources a directories instead of files. + * but where the sources are directories instead of files. */ @Nullable private final List excludedDirectories; @@ -146,7 +146,7 @@ final class FileCollector extends SimpleFileVisitor { private Archive currentModule; /** - * The module and target Java release currently being scanned. This field is updated every times that + * The module and target Java release currently being scanned. This field is updated every time that * {@code FileCollector} visits a new module directory or in a new target Java release for a given module. */ @Nonnull diff --git a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java index 83f94df6..c5737647 100644 --- a/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -40,9 +40,8 @@ * The latter can only occur if {@link FileCollector} has no {@code PathMatcher}. * Therefore, this class uses no {@code PathMatcher}. * - *

    Ignore files

    - * The {@code META-INF/MANIFEST.MF} file and the {@code META-INF/maven/} directory are ignored. - * See {@link #isIgnored(Path)} for the rational. + *

    The {@code META-INF/MANIFEST.MF} file and the {@code META-INF/maven/} directory are ignored. + * See {@link #isIgnored(Path)} for the rational.

    */ final class TimestampCheck extends SimpleFileVisitor { /** diff --git a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java index d50e416a..9c2f1cde 100644 --- a/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -60,7 +60,7 @@ final class ToolExecutor { /** * First JDK feature version whose {@code jar} tool support the {@code --date} option. */ - private static final int JDK_SUPPORT_DATE = 19; + private static final int JDK_SUPPORTING_DATE = 19; /** * The {@value} attribute. Its value is automatically generated by {@link Manifest}, @@ -395,11 +395,11 @@ void writeSingleJAR(final FileCollector files, final Archive archive) throws IOE arguments.add("--no-compress"); } if (outputTimestamp != null) { - if (Runtime.version().feature() >= JDK_SUPPORT_DATE) { + if (Runtime.version().feature() >= JDK_SUPPORTING_DATE) { arguments.add("--date"); arguments.add(outputTimestamp); } else { - logger.warn("Reproducible build requires Java " + JDK_SUPPORT_DATE + " or later."); + logger.warn("Reproducible build requires Java " + JDK_SUPPORTING_DATE + " or later."); } } archive.addArgumentsTo(arguments); diff --git a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java index 279b3d30..976bd4a2 100644 --- a/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -55,21 +55,21 @@ private static Archive archive(String moduleName, Runtime.Version version, Path * @return a new manifest with the given attribute value */ private static Manifest manifestWithMainClass(String value) { - Manifest m = new Manifest(); - Attributes attributes = m.getMainAttributes(); + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); attributes.put(Attributes.Name.MAIN_CLASS, value); - return m; + return manifest; } /** * Returns the value of the main class attribute. * - * @param m the manifest from which to get the value + * @param manifest the manifest from which to get the value * @return the main class attribute value, or {@code null} if none */ - private static Object mainClassOf(Manifest m) { - return m.getMainAttributes().get(Attributes.Name.MAIN_CLASS); + private static Object mainClassOf(Manifest manifest) { + return manifest.getMainAttributes().get(Attributes.Name.MAIN_CLASS); } /** @@ -122,16 +122,17 @@ void mainClassAssignmentIsIndependentOfModuleOrder() { * *

    The {@code "foo.bar/"} prefix in this test (the module name) is a Maven extension. * The standard JAR specification accepts only the {@code "foo.MainFile"} class name. - * This extension is used by the plugin for identifying in which JAR file to add this + * This extension is used by the plugin to identify which JAR file to add this * {@code Main-Class} manifest entry.

    */ private static void assertOwnership(String first, String second, boolean ownerIsFirst) { + boolean ownerIsSecond = !ownerIsFirst; final Path path = Path.of("."); final Manifest shared = manifestWithMainClass("foo.bar/foo.MainFile"); final Manifest m1 = new Manifest(shared); final Manifest m2 = new Manifest(shared); assertEquals(ownerIsFirst, archive(first, null, path).setMainClass(m1)); - assertEquals(!ownerIsFirst, archive(second, null, path).setMainClass(m2)); + assertEquals(ownerIsSecond, archive(second, null, path).setMainClass(m2)); // Per-module copies must leave the shared plugin manifest untouched. assertEquals("foo.bar/foo.MainFile", mainClassOf(shared)); assertNull(mainClassOf(m1)); @@ -166,7 +167,7 @@ void baseReleaseBindingIsIndependentOfDirectoryOrder() { * *

    Historical note

    * In our tests, it seems that the first JAR entry after the {@code -C} option - * must be relative, and only that file. Furthermore, it seems that this file must be the + * must be relative, and only that file. Furthermore, it seems that this filename must be the * shortest. We tried to apply this heuristic rules in a branch, but it does not save a lot * of characters compared to repeating {@code -C}. */