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/directory-entries-mr/pom.xml b/src/it/directory-entries-mr/pom.xml new file mode 100644 index 00000000..de13d653 --- /dev/null +++ b/src/it/directory-entries-mr/pom.xml @@ -0,0 +1,69 @@ + + + + 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. + + + + + + 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 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/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/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/multimodule-multirelease-with-excludes/pom.xml b/src/it/multimodule-multirelease-with-excludes/pom.xml new file mode 100644 index 00000000..d0351313 --- /dev/null +++ b/src/it/multimodule-multirelease-with-excludes/pom.xml @@ -0,0 +1,85 @@ + + + + 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-5 + + + + + 17 + + + + + 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(); +} diff --git a/src/it/multimodule-multirelease/pom.xml b/src/it/multimodule-multirelease/pom.xml new file mode 100644 index 00000000..7394f632 --- /dev/null +++ b/src/it/multimodule-multirelease/pom.xml @@ -0,0 +1,81 @@ + + + + 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-5 + + + + + 17 + + + + + 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/multimodule-multirelease/src/foo.bar.more/main/java/module-info.java b/src/it/multimodule-multirelease/src/foo.bar.more/main/java/module-info.java new file mode 100644 index 00000000..778a3a4a --- /dev/null +++ b/src/it/multimodule-multirelease/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/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multimodule-multirelease/src/foo.bar.more/main/java/more/MainFile.java new file mode 100644 index 00000000..d64f30a7 --- /dev/null +++ b/src/it/multimodule-multirelease/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/multimodule-multirelease/src/foo.bar.more/main/java/more/OtherFile.java b/src/it/multimodule-multirelease/src/foo.bar.more/main/java/more/OtherFile.java new file mode 100644 index 00000000..54e29b3c --- /dev/null +++ b/src/it/multimodule-multirelease/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/multimodule-multirelease/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 new file mode 100644 index 00000000..4b21485e --- /dev/null +++ b/src/it/multimodule-multirelease/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/multimodule-multirelease/src/foo.bar/main/java/foo/MainFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/foo/MainFile.java new file mode 100644 index 00000000..502f2780 --- /dev/null +++ b/src/it/multimodule-multirelease/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/multimodule-multirelease/src/foo.bar/main/java/foo/OtherFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/foo/OtherFile.java new file mode 100644 index 00000000..472210e1 --- /dev/null +++ b/src/it/multimodule-multirelease/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/multimodule-multirelease/src/foo.bar/main/java/foo/YetAnotherFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/foo/YetAnotherFile.java new file mode 100644 index 00000000..ab5f9009 --- /dev/null +++ b/src/it/multimodule-multirelease/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/multimodule-multirelease/src/foo.bar/main/java/module-info.java b/src/it/multimodule-multirelease/src/foo.bar/main/java/module-info.java new file mode 100644 index 00000000..38f61c0e --- /dev/null +++ b/src/it/multimodule-multirelease/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/src/foo.bar/main/java_16/foo/OtherFile.java b/src/it/multimodule-multirelease/src/foo.bar/main/java_16/foo/OtherFile.java new file mode 100644 index 00000000..cbfa0b98 --- /dev/null +++ b/src/it/multimodule-multirelease/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/multimodule-multirelease/verify.groovy b/src/it/multimodule-multirelease/verify.groovy new file mode 100644 index 00000000..d62d91f2 --- /dev/null +++ b/src/it/multimodule-multirelease/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/it/multimodule/pom.xml b/src/it/multimodule/pom.xml new file mode 100644 index 00000000..5e0794c0 --- /dev/null +++ b/src/it/multimodule/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-5 + + + + + 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/multimodule/src/foo.bar.more/main/java/module-info.java b/src/it/multimodule/src/foo.bar.more/main/java/module-info.java new file mode 100644 index 00000000..778a3a4a --- /dev/null +++ b/src/it/multimodule/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/src/foo.bar.more/main/java/more/MainFile.java b/src/it/multimodule/src/foo.bar.more/main/java/more/MainFile.java new file mode 100644 index 00000000..d64f30a7 --- /dev/null +++ b/src/it/multimodule/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/multimodule/src/foo.bar/main/java/foo/MainFile.java b/src/it/multimodule/src/foo.bar/main/java/foo/MainFile.java new file mode 100644 index 00000000..502f2780 --- /dev/null +++ b/src/it/multimodule/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/multimodule/src/foo.bar/main/java/module-info.java b/src/it/multimodule/src/foo.bar/main/java/module-info.java new file mode 100644 index 00000000..38f61c0e --- /dev/null +++ b/src/it/multimodule/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/verify.groovy b/src/it/multimodule/verify.groovy new file mode 100644 index 00000000..df8756de --- /dev/null +++ b/src/it/multimodule/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/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/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java index 10e00afb..0330ed69 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 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; +import java.util.Locale; 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 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 + */ + @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,13 @@ 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. + * + *

This property is supported only in Java 19 or later.

* * @since 3.2.0 */ @@ -145,15 +158,38 @@ 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 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 */ @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 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. + * + * @since 4.0.0-beta-2 + */ + @Parameter(defaultValue = "false") + protected String validate; + /** * The MOJO logger. */ @@ -166,7 +202,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 +215,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 +227,232 @@ 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 don't have 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 - */ - 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); + * @throws MojoException if no JAR tool was found + * + * @since 4.0.0-beta-2 + */ + protected ToolProvider getJarTool() throws MojoException { + return ToolProvider.findFirst(toolId).orElseThrow(() -> new MojoException("No such \"" + toolId + "\" tool.")); } /** - * Generates the JAR. + * 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 path to the created archive file - * @throws MojoException in case of an error + * @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 */ - 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"); + protected String getOutputTimestamp() { + String time = nullIfAbsent(outputTimestamp); + if (time == null) { + time = nullIfAbsent(System.getenv("SOURCE_DATE_EPOCH")); + if (time == null) { + return null; + } } + for (int i = time.length(); --i >= 0; ) { + char c = time.charAt(i); + if ((c < '0' || c > '9') && (i != 0 || c != '-')) { + return time; + } + } + try { + return Instant.ofEpochSecond(Long.parseLong(time)).toString(); + } catch (NumberFormatException | DateTimeException e) { + throw new MojoException("Timestamp \"" + time + "\" is not a number of seconds.", e); + } + } - // 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"; + /** + * {@return the patterns of files to include, or an empty list if no include pattern was specified} + */ + final List getIncludes() { + return asList(includes); + } - 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 exclude, or an empty list if no exclude pattern was specified} + */ + final List getExcludes() { + return asList(excludes); + } - // configure for Reproducible Builds based on outputTimestamp value - archiver.configureReproducibleBuild(outputTimestamp); + /** + * 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); + } - archive.setForced(forceCreation); + /** + * 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 exist 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; + } - try { - Path contentDirectory = getClassesDirectory(); - if (!Files.exists(contentDirectory)) { - if (!forceCreation) { - getLog().warn("JAR will be empty - no content was marked for inclusion!"); + /** + * Returns the value of the {@link #validate} parameter, or {@code null} if {@code "auto"}. + */ + 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; } - } else { - archiver.getArchiver().addDirectory(contentDirectory.toFile(), getIncludes(), getExcludes()); } + } + return Boolean.FALSE; + } - archiver.createArchive(session, project, archive); - - return jarFile; - } catch (Exception e) { - // TODO: improve error handling - throw new MojoException("Error assembling JAR", e); + /** + * 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 mean that the JAR is not modular. + * It only means that the project was not compiled with module hierarchy, + * 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 + * @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(); + } + } + 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..726f059c --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/Archive.java @@ -0,0 +1,655 @@ +/* + * 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} 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 { + /** + * 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. 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; + + /** + * 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. + * 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; + + /** + * The Maven generated {@code pom.xml} and {@code pom.properties} files, or {@code null} if none. + * This first item must 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 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 { + /** + * 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. + * 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. + */ + @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) { + 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. + * 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 + */ + 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. + } + files.add(item); + } + + /** + * Adds to the given list the arguments to provide to the "jar" tool for this version. + * 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 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 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 + */ + private void arguments(List addTo, Runtime.Version version) { + 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; + } + 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); + } + } + + /** + * {@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 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( + 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) -> { + if (v1 == v2) return 0; + if (v1 == null) return -1; + if (v2 == null) return +1; + return v1.compareTo(v2); + }); + filesetForRelease.put(version, 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() { + 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); + } + + /** + * Returns the {@code module-info.class} files. Conceptually, there is at most once 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. + * + * @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} + * 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

+ * 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. + * + *

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 + * @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 must 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 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 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 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); + } + if (effective) { + addTo.add("--validate"); + addTo.add("--file"); + addTo.add(jarFile); + } + return effective; + } + + /** + * 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 the paths to the artifacts produced for this archive in the given map. + * + * @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..79d3c5f3 --- /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 to Java releases such as "21". + * The next location can only be {@link #RESOURCES}. + */ + VERSIONS, + + /** + * 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}. + */ + VERSIONS_MODULAR, + + /** + * The Maven-specific {@code "META-INF/versions-modular"} directory. + * All sub-directories must 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. + * 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 new file mode 100644 index 00000000..31b2be18 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/ExcludedFiles.java @@ -0,0 +1,145 @@ +/* + * 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 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). + * 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; + + /** + * 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 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(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-"); + } + + /** + * 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; + 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); + } + 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 new file mode 100644 index 00000000..66f1c08d --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/FileCollector.java @@ -0,0 +1,541 @@ +/* + * 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.ArrayList; +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 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: + * + *
    + *
  • 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. + * The default value is {@code true}. + * + * @see AbstractJarMojo#detectMultiReleaseJar + */ + 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. + */ + @Nonnull + private final PathMatcher fileMatcher; + + /** + * Combination of includes and excludes path matcher applied on directories. + */ + @Nonnull + private final PathMatcher directoryMatcher; + + /** + * 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 + * 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).

+ */ + @Nullable + 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. + * 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} + * 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} visits 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; + rootDirectory = directory; + detectMultiReleaseJar = mojo.detectMultiReleaseJar; + directoryRoles = new ArrayDeque<>(); + fileMatcher = matcherFactory.createPathMatcher(directory, mojo.getIncludes(), mojo.getExcludes(), false); + directoryMatcher = matcherFactory.deriveDirectoryMatcher(fileMatcher); + if (matcherFactory.isIncludesAll(fileMatcher) && matcherFactory.isIncludesAll(directoryMatcher)) { + excludedFiles = null; + excludedDirectories = null; + } else { + excludedFiles = new ArrayList<>(); + excludedDirectories = new ArrayList<>(); + } + packageHierarchy = context.newArchive(null, 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 preVisitVersionDirectory(final Path directory) { + String moduleName = directory.getFileName().toString(); + currentModule = moduleHierarchy.computeIfAbsent( + moduleName, (name) -> context.newArchive(name, currentTargetVersion, 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 preVisitVersionDirectory(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 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 + */ + @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)) { + excludedDirectories.add(directory); // Cannot be null if excluded directories may exist. + return FileVisitResult.SKIP_SUBTREE; + } + checkForManifest = false; + role = directoryRoles.getLast(); + switch (role) { + case ROOT: + /* + * 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)) { + role = DirectoryRole.META_INF; + checkForManifest = true; + } else if (Files.isRegularFile(directory.resolve(MODULE_DESCRIPTOR_FILE_NAME))) { + role = DirectoryRole.NAMED_MODULE; + preVisitVersionDirectory(directory); + } else { + role = DirectoryRole.RESOURCES; + } + break; + + case META_INF: + /* + * 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)) { + 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; + } else { + role = DirectoryRole.RESOURCES; + } + break; + + case VERSIONS: + /* + * 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 (preVisitVersionDirectory(directory, true)) { + // An error occurred while parsing the version number. + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.RESOURCES; + break; + + case VERSIONS_MODULAR: + /* + * 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 (preVisitVersionDirectory(directory, false)) { + // An error occurred while parsing the version number. + return FileVisitResult.SKIP_SUBTREE; + } + role = DirectoryRole.MODULES; + break; + + case MODULES: + /* + * Visiting a `META-INF/versions-modular//` directory. + */ + preVisitVersionDirectory(directory); + role = DirectoryRole.NAMED_MODULE; + break; + + case NAMED_MODULE: + /* + * 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)) { + role = DirectoryRole.META_INF; + checkForManifest = true; + } else { + role = DirectoryRole.RESOURCES; + } + 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 the role of parent directory. + */ + if (role == DirectoryRole.RESOURCES) { + currentFilesToArchive.add(directory, attributes, true); + 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. + * 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(); + return FileVisitResult.SKIP_SUBTREE; + } + } + directoryRoles.addLast(role); + return FileVisitResult.CONTINUE; + } + + /** + * 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 + */ + @Override + public FileVisitResult postVisitDirectory(final Path directory, final IOException error) throws IOException { + if (error != null) { + throw error; + } + switch (directoryRoles.removeLast()) { + case ROOT: + break; + + case NAMED_MODULE: + // Exited the directory of a single module. + resetToPackageHierarchy(); + break; + + default: + resetToParentDirectoryState(); + break; + } + return FileVisitResult.CONTINUE; + } + + /** + * 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") + 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. + * + * @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); + } + } else { + excludedFiles.add(file); // Cannot be null if excluded files may exist. + } + 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}, 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 and ignore.

+ * + *

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 + */ + 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(); + } + + /** + * 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, 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 { + try (ExcludedFiles exclusion = exclusion()) { + if (exclusion != null) { + exclusion.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; + } + + /** + * {@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, this method returns an empty list. + * + *

Ignored package hierarchy

+ * 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 Maven 3 way. + * + *

Prerequisites

+ * The {@link #prune(boolean)} method should have been invoked once before invoking 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..51264e8b --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/MetadataFiles.java @@ -0,0 +1,260 @@ +/* + * 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 must 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); // 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 to ensure 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); // Register now for deleting 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 | 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; + } + } + } + + /** + * 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. + * 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 + */ + @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..43d7b0bb --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/PomDerivation.java @@ -0,0 +1,529 @@ +/* + * 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. + * May be {@code null} if no {@code module-info} was found. + */ + private final ModuleFinder moduleFinder; + + /** + * Module references from paths to the JAR file or root directory. + */ + private final Map fromURI; + + /** + * Module names associated with Maven dependencies. + * This map contains {@link DependencyCoordinates#getId()} as keys and module references as values. + * 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. + */ + 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); + 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()) { + 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) { + root = root.toRealPath(); + 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( + moduleName, + Dependency.newBuilder() + .groupId(projectModel.getGroupId()) + .artifactId(moduleName) + .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) { + if (moduleFinder == null) { + return Optional.empty(); + } + 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 to 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..8a663aa8 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/TimestampCheck.java @@ -0,0 +1,261 @@ +/* + * 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.HashMap; +import java.util.Map; +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}. + * 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. + */ +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 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 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 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. + * 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 Map 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 HashMap<>(); + filesInBuild = new HashMap<>(); + } + + /** + * 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 than the JAR file + */ + boolean isUpdated(final Path file, final BasicFileAttributes attributes, final boolean isDirectory) { + if (jarFileTime.compareTo(attributes.lastModifiedTime()) < 0) { + return true; + } + filesInBuild.put(file, isDirectory); + 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.keySet()) { + if (!removeFromFilesInJAR(file)) { + return false; + } + } + // Verify the timestamps of files that were not verified by `isUpdate(…)`. + for (Archive.FileSet fileSet : fileSets) { + Path directory = null; + for (Path file : fileSet.files) { + 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 (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())))) { + 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; + } + + /** + * 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. + * + * @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 && removeFromFilesInJAR(file)) { + return FileVisitResult.CONTINUE; + } else { + hasUpdates = true; + return FileVisitResult.TERMINATE; + } + } + + /** + * 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 removeFromFilesInJAR(final Path file) { + if (filesInJAR.remove(file) != null) { + return true; + } + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + 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 new file mode 100644 index 00000000..1ef5027a --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/jar/ToolExecutor.java @@ -0,0 +1,551 @@ +/* + * 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 { + /** + * 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; + + /** + * First JDK feature version whose {@code jar} tool support the {@code --date} option. + */ + private 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. + */ + 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; + + /** + * 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. + */ + 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 must 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 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; + + /** + * 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 must 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.getOutputDirectory(); + classifier = AbstractJarMojo.nullIfAbsent(mojo.getClassifier()); + finalName = + (mojo.finalName != null) ? mojo.finalName : project.getBuild().getFinalName(); + forceCreation = mojo.forceCreation; + outputTimestamp = mojo.getOutputTimestamp(); + validate = mojo.getValidate(); + 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) { + final Attributes mainAttributes = manifest.getMainAttributes(); + if (mojo.detectMultiReleaseJar) { + mainAttributes.remove(Attributes.Name.MULTI_RELEASE); + } + 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. + mainAttributes.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; + } + + /** + * 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, + * 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 Runtime.Version version, 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, version, 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 . + 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) { + 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) { + 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); + /* + * 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(); + /* + * 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(validate, arguments)) { + 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()); + } + } 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(); + } + 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/ArchiveTest.java b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java new file mode 100644 index 00000000..8f5b4498 --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/jar/ArchiveTest.java @@ -0,0 +1,257 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +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; +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. + * + * 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. + * 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 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. + */ + @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. + * + *

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); + } + + /** + * Ensures that all path are relative. + * + *

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 + * 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 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"); + String[] ordered = {"HelloWorld.class", "Utils.class"}; + String[] reverse = {"Utils.class", "HelloWorld.class"}; + assertArrayEquals(ordered, assertRelativeArguments(argsAfterAdding(classes, a, b))); + assertArrayEquals(reverse, assertRelativeArguments(argsAfterAdding(classes, b, a))); + } + + /** + * 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. + * + * @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 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[] assertRelativeArguments(final List args) { + final var filenames = new ArrayList(2); + boolean foundOptionC = false; + boolean expectDirectory = true; + for (Object token : args) { + if ("-C".equals(token)) { + foundOptionC = true; + expectDirectory = true; + } + if (token instanceof Path p) { + if (expectDirectory) { + assertTrue(p.isAbsolute(), p.toString()); + if (foundOptionC) { + expectDirectory = false; + } + } else { + assertFalse(p.isAbsolute(), p.toString()); + filenames.add(p.getFileName().toString()); + } + } + } + return filenames.toArray(String[]::new); + } +} 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()); } }