diff --git a/pom.xml b/pom.xml index a90ea182..b9a58d05 100644 --- a/pom.xml +++ b/pom.xml @@ -119,6 +119,16 @@ asm-commons ${asmVersion} + + org.ow2.asm + asm-tree + ${asmVersion} + + + org.ow2.asm + asm-analysis + ${asmVersion} + org.jdom jdom2 @@ -257,6 +267,7 @@ src/it/projects/rerun-with-reloc/src/main/resources/some-ordinary-resource.txt src/it/projects/rerun-without-reloc/src/main/resources/some-ordinary-resource.txt src/it/projects/MSHADE-182/src/main/resources/META-INF/services/relocateme.Service + src/it/projects/module-info-merge/automatic/src/main/resources/META-INF/services/* src/it/projects/MSHADE-390-sisu-index/** diff --git a/src/it/projects/MSHADE-215/app/pom.xml b/src/it/projects/MSHADE-215/app/pom.xml new file mode 100644 index 00000000..64a8c467 --- /dev/null +++ b/src/it/projects/MSHADE-215/app/pom.xml @@ -0,0 +1,68 @@ + + + + 4.0.0 + + + org.apache.maven.its.shade.mshade215 + parent + 1.0 + + + app + + + + ${project.groupId} + base + ${project.version} + + + + + + + org.apache.maven.plugins + maven-shade-plugin + @project.version@ + + + package + + shade + + + false + + + + MSHADE-215 + + + + + + + + + + diff --git a/src/it/projects/MSHADE-215/base/pom.xml b/src/it/projects/MSHADE-215/base/pom.xml new file mode 100644 index 00000000..4ab5a90f --- /dev/null +++ b/src/it/projects/MSHADE-215/base/pom.xml @@ -0,0 +1,32 @@ + + + + 4.0.0 + + + org.apache.maven.its.shade.mshade215 + parent + 1.0 + + + base + diff --git a/src/it/projects/MSHADE-215/base/src/main/java/org/apache/maven/its/shade/mshade215/Base.java b/src/it/projects/MSHADE-215/base/src/main/java/org/apache/maven/its/shade/mshade215/Base.java new file mode 100644 index 00000000..a89d7113 --- /dev/null +++ b/src/it/projects/MSHADE-215/base/src/main/java/org/apache/maven/its/shade/mshade215/Base.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. + */ + +package org.apache.maven.its.shade.mshade215; + +public class Base {} diff --git a/src/it/projects/MSHADE-215/invoker.properties b/src/it/projects/MSHADE-215/invoker.properties new file mode 100644 index 00000000..95122c94 --- /dev/null +++ b/src/it/projects/MSHADE-215/invoker.properties @@ -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. + +# Running test as a second lifecycle task makes Maven expose the reactor dependency's output directory. +invoker.goals = clean package test diff --git a/src/it/projects/MSHADE-215/pom.xml b/src/it/projects/MSHADE-215/pom.xml new file mode 100644 index 00000000..24114d6a --- /dev/null +++ b/src/it/projects/MSHADE-215/pom.xml @@ -0,0 +1,50 @@ + + + + 4.0.0 + + org.apache.maven.its.shade.mshade215 + parent + 1.0 + pom + + + base + app + + + + 8 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @version.maven-compiler-plugin@ + + + + + diff --git a/src/it/projects/MSHADE-215/verify.groovy b/src/it/projects/MSHADE-215/verify.groovy new file mode 100644 index 00000000..0b3ea6d4 --- /dev/null +++ b/src/it/projects/MSHADE-215/verify.groovy @@ -0,0 +1,28 @@ +/* + * 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 + +def shadedJar = new File(basedir, 'app/target/app-1.0.jar') +assert shadedJar.isFile() + +new JarFile(shadedJar).withCloseable { jar -> + assert jar.getEntry('org/apache/maven/its/shade/mshade215/Base.class') != null + assert jar.manifest.mainAttributes.getValue('Test-Case') == 'MSHADE-215' +} diff --git a/src/it/projects/module-info-merge/app/pom.xml b/src/it/projects/module-info-merge/app/pom.xml new file mode 100644 index 00000000..9ef29860 --- /dev/null +++ b/src/it/projects/module-info-merge/app/pom.xml @@ -0,0 +1,107 @@ + + + + 4.0.0 + + org.apache.maven.its.shade.modules + module-info-merge-parent + 1.0 + + app + + + + ${project.groupId} + embedded + + + ${project.groupId} + automatic + + + + + + + org.apache.maven.plugins + maven-shade-plugin + @project.version@ + + + package + + shade + + + false + merge + + shaded.app.module + merge + + + external.module + true + + + optional.module + true + true + + + + + embedded.internal + + + + + embedded.internal + + + + + + org.apache.maven.its.shade.modules:external + + + + + automatic.library + shaded.automatic + + + embedded.api + shaded.library + + + embedded.internal + shaded.internal + + + + + + + + + diff --git a/src/it/projects/module-info-merge/app/src/main/java/app/api/Application.java b/src/it/projects/module-info-merge/app/src/main/java/app/api/Application.java new file mode 100644 index 00000000..84123caa --- /dev/null +++ b/src/it/projects/module-info-merge/app/src/main/java/app/api/Application.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 app.api; + +import automatic.library.AutomaticLibrary; + +public final class Application { + private Application() {} + + public static String loadAutomaticService() { + return AutomaticLibrary.loadService(); + } +} diff --git a/src/it/projects/module-info-merge/app/src/main/java/module-info.java b/src/it/projects/module-info-merge/app/src/main/java/module-info.java new file mode 100644 index 00000000..9bd3f723 --- /dev/null +++ b/src/it/projects/module-info-merge/app/src/main/java/module-info.java @@ -0,0 +1,24 @@ +/* + * 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 app.module { + requires automatic.module; + requires embedded.module; + + exports app.api; +} diff --git a/src/it/projects/module-info-merge/automatic/pom.xml b/src/it/projects/module-info-merge/automatic/pom.xml new file mode 100644 index 00000000..0a5a20ba --- /dev/null +++ b/src/it/projects/module-info-merge/automatic/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + org.apache.maven.its.shade.modules + module-info-merge-parent + 1.0 + + automatic + + + + ${project.groupId} + external + + + + + + + org.apache.maven.plugins + maven-jar-plugin + @version.maven-jar-plugin@ + + + + automatic.module + + + + + + + diff --git a/src/it/projects/module-info-merge/automatic/src/main/java/automatic/library/AutomaticLibrary.java b/src/it/projects/module-info-merge/automatic/src/main/java/automatic/library/AutomaticLibrary.java new file mode 100644 index 00000000..249f893c --- /dev/null +++ b/src/it/projects/module-info-merge/automatic/src/main/java/automatic/library/AutomaticLibrary.java @@ -0,0 +1,35 @@ +/* + * 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 automatic.library; + +import java.sql.Driver; +import java.util.ServiceLoader; + +import external.api.AutomaticService; +import external.api.Service; + +public final class AutomaticLibrary { + private static final Class[] REQUIREMENTS = {Driver.class, Service.class}; + + private AutomaticLibrary() {} + + public static String loadService() { + return ServiceLoader.load(AutomaticService.class).findFirst().orElseThrow().name(); + } +} diff --git a/src/it/projects/module-info-merge/automatic/src/main/java/automatic/library/AutomaticProvider.java b/src/it/projects/module-info-merge/automatic/src/main/java/automatic/library/AutomaticProvider.java new file mode 100644 index 00000000..49626de1 --- /dev/null +++ b/src/it/projects/module-info-merge/automatic/src/main/java/automatic/library/AutomaticProvider.java @@ -0,0 +1,28 @@ +/* + * 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 automatic.library; + +import external.api.AutomaticService; + +public final class AutomaticProvider implements AutomaticService { + @Override + public String name() { + return "automatic"; + } +} diff --git a/src/it/projects/module-info-merge/automatic/src/main/resources/META-INF/services/external.api.AutomaticService b/src/it/projects/module-info-merge/automatic/src/main/resources/META-INF/services/external.api.AutomaticService new file mode 100644 index 00000000..f3e7c586 --- /dev/null +++ b/src/it/projects/module-info-merge/automatic/src/main/resources/META-INF/services/external.api.AutomaticService @@ -0,0 +1 @@ +automatic.library.AutomaticProvider diff --git a/src/it/projects/module-info-merge/embedded/pom.xml b/src/it/projects/module-info-merge/embedded/pom.xml new file mode 100644 index 00000000..c9f479f1 --- /dev/null +++ b/src/it/projects/module-info-merge/embedded/pom.xml @@ -0,0 +1,37 @@ + + + + 4.0.0 + + org.apache.maven.its.shade.modules + module-info-merge-parent + 1.0 + + embedded + + + + ${project.groupId} + external + + + diff --git a/src/it/projects/module-info-merge/embedded/src/main/java/embedded/api/Library.java b/src/it/projects/module-info-merge/embedded/src/main/java/embedded/api/Library.java new file mode 100644 index 00000000..1fe10f9f --- /dev/null +++ b/src/it/projects/module-info-merge/embedded/src/main/java/embedded/api/Library.java @@ -0,0 +1,23 @@ +/* + * 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 embedded.api; + +public final class Library { + private Library() {} +} diff --git a/src/it/projects/module-info-merge/embedded/src/main/java/embedded/internal/Provider.java b/src/it/projects/module-info-merge/embedded/src/main/java/embedded/internal/Provider.java new file mode 100644 index 00000000..1922c460 --- /dev/null +++ b/src/it/projects/module-info-merge/embedded/src/main/java/embedded/internal/Provider.java @@ -0,0 +1,28 @@ +/* + * 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 embedded.internal; + +import external.api.Service; + +public final class Provider implements Service { + @Override + public String name() { + return "embedded"; + } +} diff --git a/src/it/projects/module-info-merge/embedded/src/main/java/module-info.java b/src/it/projects/module-info-merge/embedded/src/main/java/module-info.java new file mode 100644 index 00000000..10fc107f --- /dev/null +++ b/src/it/projects/module-info-merge/embedded/src/main/java/module-info.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. + */ +module embedded.module { + requires transitive external.module; + + exports embedded.api; + uses external.api.Service; + provides external.api.Service with embedded.internal.Provider; +} diff --git a/src/it/projects/module-info-merge/external/pom.xml b/src/it/projects/module-info-merge/external/pom.xml new file mode 100644 index 00000000..57629f36 --- /dev/null +++ b/src/it/projects/module-info-merge/external/pom.xml @@ -0,0 +1,30 @@ + + + + 4.0.0 + + org.apache.maven.its.shade.modules + module-info-merge-parent + 1.0 + + external + diff --git a/src/it/projects/module-info-merge/external/src/main/java/external/api/AutomaticService.java b/src/it/projects/module-info-merge/external/src/main/java/external/api/AutomaticService.java new file mode 100644 index 00000000..9a16b6e9 --- /dev/null +++ b/src/it/projects/module-info-merge/external/src/main/java/external/api/AutomaticService.java @@ -0,0 +1,23 @@ +/* + * 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 external.api; + +public interface AutomaticService { + String name(); +} diff --git a/src/it/projects/module-info-merge/external/src/main/java/external/api/Service.java b/src/it/projects/module-info-merge/external/src/main/java/external/api/Service.java new file mode 100644 index 00000000..2d3ad3cf --- /dev/null +++ b/src/it/projects/module-info-merge/external/src/main/java/external/api/Service.java @@ -0,0 +1,23 @@ +/* + * 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 external.api; + +public interface Service { + String name(); +} diff --git a/src/it/projects/module-info-merge/external/src/main/java/module-info.java b/src/it/projects/module-info-merge/external/src/main/java/module-info.java new file mode 100644 index 00000000..b6a0e592 --- /dev/null +++ b/src/it/projects/module-info-merge/external/src/main/java/module-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +module external.module { + exports external.api; +} diff --git a/src/it/projects/module-info-merge/invoker.properties b/src/it/projects/module-info-merge/invoker.properties new file mode 100644 index 00000000..cabde7db --- /dev/null +++ b/src/it/projects/module-info-merge/invoker.properties @@ -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. + +invoker.goals = clean package +invoker.java.version = 11+ diff --git a/src/it/projects/module-info-merge/pom.xml b/src/it/projects/module-info-merge/pom.xml new file mode 100644 index 00000000..6e985d46 --- /dev/null +++ b/src/it/projects/module-info-merge/pom.xml @@ -0,0 +1,72 @@ + + + + 4.0.0 + + org.apache.maven.its.shade.modules + module-info-merge-parent + 1.0 + pom + + + external + embedded + automatic + app + + + + 11 + + + + + + ${project.groupId} + external + ${project.version} + + + ${project.groupId} + embedded + ${project.version} + + + ${project.groupId} + automatic + ${project.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @version.maven-compiler-plugin@ + + + + + diff --git a/src/it/projects/module-info-merge/verify.groovy b/src/it/projects/module-info-merge/verify.groovy new file mode 100644 index 00000000..0b97f62d --- /dev/null +++ b/src/it/projects/module-info-merge/verify.groovy @@ -0,0 +1,107 @@ +/* + * 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.lang.module.ModuleDescriptor +import java.lang.module.ModuleFinder +import java.util.jar.JarFile + +def shaded = new File( basedir, "app/target/app-1.0.jar" ) +assert shaded.isFile() + +def shadedFinder = ModuleFinder.of( shaded.toPath() ) +assert !shadedFinder.find( "app.module" ).isPresent() +def reference = shadedFinder.find( "shaded.app.module" ).orElseThrow { + new AssertionError( "The shaded JAR is not the shaded.app.module module" ) +} +def descriptor = reference.descriptor() + +assert descriptor.name() == "shaded.app.module" +assert descriptor.requires()*.name().contains( "external.module" ) +assert descriptor.requires()*.name().contains( "java.sql" ) +assert !descriptor.requires()*.name().contains( "automatic.module" ) +assert !descriptor.requires()*.name().contains( "embedded.module" ) +assert descriptor.requires().find { it.name() == "external.module" } + .modifiers().contains( ModuleDescriptor.Requires.Modifier.TRANSITIVE ) +def optional = descriptor.requires().find { it.name() == "optional.module" } +assert optional != null +assert optional.modifiers().contains( ModuleDescriptor.Requires.Modifier.STATIC ) +assert optional.modifiers().contains( ModuleDescriptor.Requires.Modifier.TRANSITIVE ) +assert ( descriptor.exports()*.source() as Set ) == + [ "app.api", "shaded.automatic", "shaded.internal", "shaded.library" ] as Set +assert ( descriptor.opens()*.source() as Set ) == + [ "shaded.automatic", "shaded.internal" ] as Set +assert descriptor.packages() == + [ "app.api", "shaded.automatic", "shaded.internal", "shaded.library" ] as Set +assert descriptor.uses() == + [ "external.api.AutomaticService", "external.api.Service" ] as Set + +def service = descriptor.provides().find { it.service() == "external.api.Service" } +assert service != null +assert service.providers() == [ "shaded.internal.Provider" ] +def automaticService = + descriptor.provides().find { it.service() == "external.api.AutomaticService" } +assert automaticService != null +assert automaticService.providers() == [ "shaded.automatic.AutomaticProvider" ] + +def external = new File( basedir, "external/target/external-1.0.jar" ) +assert external.isFile() + +def finder = ModuleFinder.of( shaded.toPath(), external.toPath() ) +def configuration = ModuleLayer.boot().configuration() + .resolve( finder, ModuleFinder.of(), [ "shaded.app.module" ] as Set ) +def layer = ModuleLayer.boot().defineModulesWithOneLoader( + configuration, ClassLoader.getSystemClassLoader() ) +def library = layer.findLoader( "shaded.app.module" ).loadClass( "shaded.library.Library" ) +assert library.module.name == "shaded.app.module" +def automaticLibrary = + Class.forName( "shaded.automatic.AutomaticLibrary", true, layer.findLoader( "shaded.app.module" ) ) +assert automaticLibrary.module.name == "shaded.app.module" +def application = layer.findLoader( "shaded.app.module" ).loadClass( "app.api.Application" ) +def thread = Thread.currentThread() +def contextLoader = thread.contextClassLoader +try +{ + thread.contextClassLoader = layer.findLoader( "shaded.app.module" ) + assert application.getMethod( "loadAutomaticService" ).invoke( null ) == "automatic" +} +finally +{ + thread.contextClassLoader = contextLoader +} + +def jar = new JarFile( shaded ) +try +{ + assert jar.getJarEntry( "module-info.class" ) == null + assert jar.getJarEntry( "META-INF/versions/11/module-info.class" ) != null + assert jar.getJarEntry( "META-INF/services/external.api.AutomaticService" ) != null + assert jar.getJarEntry( "shaded/automatic/AutomaticLibrary.class" ) != null + assert jar.getJarEntry( "shaded/automatic/AutomaticProvider.class" ) != null + assert jar.getJarEntry( "shaded/library/Library.class" ) != null + assert jar.getJarEntry( "shaded/internal/Provider.class" ) != null + assert jar.getJarEntry( "embedded/api/Library.class" ) == null + assert jar.getJarEntry( "embedded/internal/Provider.class" ) == null + assert jar.getJarEntry( "automatic/library/AutomaticLibrary.class" ) == null + assert jar.manifest.mainAttributes.getValue( "Automatic-Module-Name" ) == "shaded.app.module" + assert jar.manifest.mainAttributes.getValue( "Multi-Release" ) == "true" +} +finally +{ + jar.close() +} diff --git a/src/main/java/org/apache/maven/plugins/shade/AutomaticModuleAnalyzer.java b/src/main/java/org/apache/maven/plugins/shade/AutomaticModuleAnalyzer.java new file mode 100644 index 00000000..085d9d55 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/shade/AutomaticModuleAnalyzer.java @@ -0,0 +1,246 @@ +/* + * 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.shade; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Handle; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.commons.ClassRemapper; +import org.objectweb.asm.commons.Remapper; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.analysis.Analyzer; +import org.objectweb.asm.tree.analysis.AnalyzerException; +import org.objectweb.asm.tree.analysis.BasicInterpreter; +import org.objectweb.asm.tree.analysis.BasicValue; +import org.objectweb.asm.tree.analysis.Frame; + +/** + * Extracts bytecode-visible dependencies and service uses from classes retained from automatic modules. + */ +final class AutomaticModuleAnalyzer { + private static final String SERVICE_LOADER = "java/util/ServiceLoader"; + + private AutomaticModuleAnalyzer() {} + + static Analysis analyze(byte[] bytecode) { + Set references = new TreeSet<>(); + ClassVisitor collector = new ClassRemapper(new ClassWriter(0), new Remapper(Opcodes.ASM9) { + @Override + public String map(String internalName) { + if (internalName != null) { + references.add(internalName); + } + return internalName; + } + }); + ClassReader reader = new ClassReader(bytecode); + reader.accept(collector, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + + Set serviceUses = new TreeSet<>(); + SortedSet unresolvedServiceUses = new TreeSet<>(); + ClassNode classNode = new ClassNode(Opcodes.ASM9); + reader.accept(classNode, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + for (MethodNode method : classNode.methods) { + analyzeServiceUses(classNode.name, method, serviceUses, unresolvedServiceUses); + } + return new Analysis(reader.readShort(6), references, serviceUses, unresolvedServiceUses); + } + + private static void analyzeServiceUses( + String className, MethodNode method, Set serviceUses, SortedSet unresolvedServiceUses) { + boolean hasDirectInvocation = false; + for (AbstractInsnNode instruction : method.instructions) { + if (instruction instanceof MethodInsnNode + && SERVICE_LOADER.equals(((MethodInsnNode) instruction).owner) + && ((MethodInsnNode) instruction).name.startsWith("load")) { + hasDirectInvocation = true; + } else if (instruction instanceof InvokeDynamicInsnNode) { + InvokeDynamicInsnNode dynamic = (InvokeDynamicInsnNode) instruction; + if (isServiceLoaderHandle(dynamic.bsm) || containsServiceLoaderHandle(dynamic.bsmArgs)) { + unresolvedServiceUses.add( + "ServiceLoader method handle in " + toClassName(className) + '.' + method.name); + } + } else if (instruction instanceof LdcInsnNode && isServiceLoaderHandle(((LdcInsnNode) instruction).cst)) { + unresolvedServiceUses.add( + "ServiceLoader method handle in " + toClassName(className) + '.' + method.name); + } + } + if (!hasDirectInvocation) { + return; + } + + Frame[] frames; + try { + frames = new Analyzer<>(new ClassLiteralInterpreter()).analyze(className, method); + } catch (AnalyzerException e) { + unresolvedServiceUses.add( + "ServiceLoader call in " + toClassName(className) + '.' + method.name + method.desc); + return; + } + + for (int index = 0; index < method.instructions.size(); index++) { + AbstractInsnNode instruction = method.instructions.get(index); + if (!(instruction instanceof MethodInsnNode)) { + continue; + } + MethodInsnNode invocation = (MethodInsnNode) instruction; + int serviceArgument = serviceArgumentIndex(invocation); + if (serviceArgument < 0) { + if (SERVICE_LOADER.equals(invocation.owner) && invocation.name.startsWith("load")) { + unresolvedServiceUses.add("ServiceLoader." + invocation.name + invocation.desc + " in " + + toClassName(className) + '.' + method.name); + } + continue; + } + + Frame frame = frames[index]; + Type[] arguments = Type.getArgumentTypes(invocation.desc); + int stackIndex = frame == null ? -1 : frame.getStackSize() - arguments.length + serviceArgument; + BasicValue value = stackIndex < 0 ? null : frame.getStack(stackIndex); + if (value instanceof ClassLiteralValue) { + serviceUses.addAll(((ClassLiteralValue) value).classNames); + } else { + unresolvedServiceUses.add("ServiceLoader." + invocation.name + invocation.desc + " in " + + toClassName(className) + '.' + method.name); + } + } + } + + private static int serviceArgumentIndex(MethodInsnNode invocation) { + if (!SERVICE_LOADER.equals(invocation.owner) || invocation.getOpcode() != Opcodes.INVOKESTATIC) { + return -1; + } + if ("loadInstalled".equals(invocation.name) + && "(Ljava/lang/Class;)Ljava/util/ServiceLoader;".equals(invocation.desc)) { + return 0; + } + if (!"load".equals(invocation.name)) { + return -1; + } + if ("(Ljava/lang/Class;)Ljava/util/ServiceLoader;".equals(invocation.desc) + || "(Ljava/lang/Class;Ljava/lang/ClassLoader;)Ljava/util/ServiceLoader;".equals(invocation.desc)) { + return 0; + } + if ("(Ljava/lang/ModuleLayer;Ljava/lang/Class;)Ljava/util/ServiceLoader;".equals(invocation.desc)) { + return 1; + } + return -1; + } + + private static boolean containsServiceLoaderHandle(Object[] values) { + for (Object value : values) { + if (isServiceLoaderHandle(value)) { + return true; + } + } + return false; + } + + private static boolean isServiceLoaderHandle(Object value) { + if (!(value instanceof Handle)) { + return false; + } + Handle handle = (Handle) value; + return SERVICE_LOADER.equals(handle.getOwner()) && handle.getName().startsWith("load"); + } + + private static String toClassName(String internalName) { + return internalName.replace('/', '.'); + } + + static final class Analysis { + final int classVersion; + final Set references; + final Set serviceUses; + final SortedSet unresolvedServiceUses; + + private Analysis( + int classVersion, + Set references, + Set serviceUses, + SortedSet unresolvedServiceUses) { + this.classVersion = classVersion; + this.references = references; + this.serviceUses = serviceUses; + this.unresolvedServiceUses = unresolvedServiceUses; + } + } + + private static final class ClassLiteralInterpreter extends BasicInterpreter { + private ClassLiteralInterpreter() { + super(Opcodes.ASM9); + } + + @Override + public BasicValue newOperation(AbstractInsnNode instruction) throws AnalyzerException { + if (instruction.getOpcode() == Opcodes.LDC) { + Object value = ((LdcInsnNode) instruction).cst; + if (value instanceof Type && ((Type) value).getSort() == Type.OBJECT) { + return new ClassLiteralValue(Collections.singleton(((Type) value).getInternalName())); + } + } + return super.newOperation(instruction); + } + + @Override + public BasicValue merge(BasicValue first, BasicValue second) { + if (first instanceof ClassLiteralValue && second instanceof ClassLiteralValue) { + SortedSet classNames = new TreeSet<>(((ClassLiteralValue) first).classNames); + classNames.addAll(((ClassLiteralValue) second).classNames); + return classNames.equals(((ClassLiteralValue) first).classNames) + ? first + : new ClassLiteralValue(classNames); + } + return super.merge(first, second); + } + } + + private static final class ClassLiteralValue extends BasicValue { + private final SortedSet classNames; + + private ClassLiteralValue(Collection classNames) { + super(Type.getObjectType("java/lang/Class")); + this.classNames = Collections.unmodifiableSortedSet(new TreeSet<>(classNames)); + } + + @Override + public boolean equals(Object object) { + return object instanceof ClassLiteralValue && classNames.equals(((ClassLiteralValue) object).classNames); + } + + @Override + public int hashCode() { + return classNames.hashCode(); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/shade/DefaultShader.java b/src/main/java/org/apache/maven/plugins/shade/DefaultShader.java index d4f99674..3c03f46c 100644 --- a/src/main/java/org/apache/maven/plugins/shade/DefaultShader.java +++ b/src/main/java/org/apache/maven/plugins/shade/DefaultShader.java @@ -52,6 +52,7 @@ import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.CRC32; @@ -64,6 +65,7 @@ import org.apache.maven.plugins.shade.resource.ManifestResourceTransformer; import org.apache.maven.plugins.shade.resource.ReproducibleResourceTransformer; import org.apache.maven.plugins.shade.resource.ResourceTransformer; +import org.apache.maven.plugins.shade.resource.ServicesResourceTransformer; import org.codehaus.plexus.util.IOUtil; import org.codehaus.plexus.util.io.CachingOutputStream; import org.objectweb.asm.ClassReader; @@ -82,6 +84,9 @@ public class DefaultShader implements Shader { private static final int BUFFER_SIZE = 32 * 1024; + private static final Pattern VERSIONED_CLASS_ENTRY = + Pattern.compile("^(META-INF/versions/[1-9][0-9]*/)(.+[.]class)$"); + private final Logger logger; public DefaultShader() { @@ -135,12 +140,54 @@ public void shade(ShadeRequest shadeRequest) throws IOException, MojoExecutionEx Set resources = new HashSet<>(); ManifestResourceTransformer manifestTransformer = null; + String forceAutomaticModuleName = null; + boolean forceMultiRelease = false; + ServicesResourceTransformer servicesTransformer = null; List transformers = new ArrayList<>(shadeRequest.getResourceTransformers()); for (Iterator it = transformers.iterator(); it.hasNext(); ) { ResourceTransformer transformer = it.next(); if (transformer instanceof ManifestResourceTransformer) { manifestTransformer = (ManifestResourceTransformer) transformer; it.remove(); + } else if (transformer instanceof ServicesResourceTransformer) { + servicesTransformer = (ServicesResourceTransformer) transformer; + } + } + + ModuleInfoProcessor moduleInfoProcessor = null; + if (shadeRequest.getModuleInfoMode() == ModuleInfoMode.MERGE) { + moduleInfoProcessor = new ModuleInfoProcessor(shadeRequest, logger); + if (manifestTransformer != null && manifestTransformer.isMultiReleaseExplicitlyEnabled()) { + moduleInfoProcessor.enableMultiReleaseOutput(); + } + if (servicesTransformer == null) { + servicesTransformer = new ServicesResourceTransformer(); + transformers.add(servicesTransformer); + } + if (moduleInfoProcessor.hasPrimaryModule()) { + if (manifestTransformer == null) { + manifestTransformer = new ManifestResourceTransformer(); + } + forceAutomaticModuleName = moduleInfoProcessor.getOutputModuleName(); + } + } + + if (moduleInfoProcessor != null) { + List multiReleaseInputs = findMultiReleaseInputs(shadeRequest); + if (!multiReleaseInputs.isEmpty() || moduleInfoProcessor.requiresMultiReleaseOutput()) { + if (manifestTransformer == null) { + manifestTransformer = new ManifestResourceTransformer(); + } else if (manifestTransformer.isMultiReleaseExplicitlyDisabled()) { + if (multiReleaseInputs.isEmpty()) { + logger.warn("Configured Multi-Release: false is overridden because module descriptor merging " + + "produces versioned output."); + } else { + logger.warn( + "Configured Multi-Release: false is overridden because shaded input {} is a multi-release JAR.", + multiReleaseInputs.get(0)); + } + } + forceMultiRelease = true; } } @@ -151,13 +198,14 @@ public void shade(ShadeRequest shadeRequest) throws IOException, MojoExecutionEx try (JarOutputStream out = new JarOutputStream(new BufferedOutputStream(new CachingOutputStream(shadeRequest.getUberJar())))) { - goThroughAllJarEntriesForManifestTransformer(shadeRequest, resources, manifestTransformer, out); + goThroughAllJarEntriesForManifestTransformer( + shadeRequest, resources, manifestTransformer, forceMultiRelease, forceAutomaticModuleName, out); // CHECKSTYLE_OFF: MagicNumber Map> duplicates = new HashMap<>(); // CHECKSTYLE_ON: MagicNumber - shadeJars(shadeRequest, resources, transformers, out, duplicates, packageMapper); + shadeJars(shadeRequest, resources, transformers, out, duplicates, packageMapper, moduleInfoProcessor); // CHECKSTYLE_OFF: MagicNumber Map, HashSet> overlapping = new HashMap<>(); @@ -177,6 +225,10 @@ public void shade(ShadeRequest shadeRequest) throws IOException, MojoExecutionEx showOverlappingWarning(); } + if (moduleInfoProcessor != null) { + moduleInfoProcessor.writeDescriptors(out, servicesTransformer); + } + for (ResourceTransformer transformer : transformers) { if (transformer.hasTransformedResource()) { transformer.modifyOutputStream(out); @@ -189,6 +241,30 @@ public void shade(ShadeRequest shadeRequest) throws IOException, MojoExecutionEx } } + private List findMultiReleaseInputs(ShadeRequest shadeRequest) throws IOException { + List result = new ArrayList<>(); + for (File jar : shadeRequest.getJars()) { + Manifest manifest = null; + if (jar.isDirectory()) { + File manifestFile = new File(jar, JarFile.MANIFEST_NAME); + if (manifestFile.isFile()) { + try (InputStream input = Files.newInputStream(manifestFile.toPath())) { + manifest = new Manifest(input); + } + } + } else { + try (JarFile jarFile = newJarFile(jar)) { + manifest = jarFile.getManifest(); + } + } + if (manifest != null + && "true".equalsIgnoreCase(manifest.getMainAttributes().getValue("Multi-Release"))) { + result.add(jar); + } + } + return result; + } + /** * {@link InputStream} that can peek ahead at zip header bytes. */ @@ -248,13 +324,18 @@ private void shadeJars( List transformers, JarOutputStream jos, Map> duplicates, - DefaultPackageMapper packageMapper) + DefaultPackageMapper packageMapper, + ModuleInfoProcessor moduleInfoProcessor) throws IOException { for (File jar : shadeRequest.getJars()) { logger.debug("Processing JAR " + jar); List jarFilters = getFilters(jar, shadeRequest.getFilters()); + if (moduleInfoProcessor != null) { + // Class analysis depends on the effective descriptor view, so select descriptors first. + moduleInfoProcessor.selectDescriptors(jar, jarFilters); + } if (jar.isDirectory()) { shadeDir( shadeRequest, @@ -266,9 +347,19 @@ private void shadeJars( jar, jar, "", - jarFilters); + jarFilters, + moduleInfoProcessor); } else { - shadeJar(shadeRequest, resources, transformers, packageMapper, jos, duplicates, jar, jarFilters); + shadeJar( + shadeRequest, + resources, + transformers, + packageMapper, + jos, + duplicates, + jar, + jarFilters, + moduleInfoProcessor); } } } @@ -284,7 +375,8 @@ private void shadeDir( File jar, File current, String prefix, - List jarFilters) + List jarFilters, + ModuleInfoProcessor moduleInfoProcessor) throws IOException { final File[] children = current.listFiles(); if (children == null) { @@ -304,15 +396,25 @@ private void shadeDir( jar, file, prefix + file.getName() + '/', - jarFilters); + jarFilters, + moduleInfoProcessor); continue; } catch (Exception e) { throw new IOException(String.format("Problem shading JAR %s entry %s: %s", current, name, e), e); } } - if (isFiltered(jarFilters, name) || isExcludedEntry(name)) { + if (moduleInfoProcessor != null && ModuleInfoProcessor.isModuleInfo(name)) { + continue; + } + if (isFiltered(jarFilters, name)) { continue; } + if (isExcludedEntry(name)) { + continue; + } + if (moduleInfoProcessor != null && ModuleInfoProcessor.isServiceConfiguration(name)) { + moduleInfoProcessor.includeServiceConfiguration(jar, name); + } try { shadeJarEntry( @@ -331,7 +433,8 @@ public InputStream call() throws Exception { }, name, file.lastModified(), - -1 /*ignore*/); + -1 /*ignore*/, + moduleInfoProcessor); } catch (Exception e) { throw new IOException(String.format("Problem shading JAR %s entry %s: %s", current, name, e), e); } @@ -347,7 +450,8 @@ private void shadeJar( JarOutputStream jos, Map> duplicates, File jar, - List jarFilters) + List jarFilters, + ModuleInfoProcessor moduleInfoProcessor) throws IOException { try (JarFile jarFile = newJarFile(jar)) { @@ -356,9 +460,21 @@ private void shadeJar( String name = entry.getName(); - if (entry.isDirectory() || isFiltered(jarFilters, name) || isExcludedEntry(name)) { + if (entry.isDirectory()) { + continue; + } + if (moduleInfoProcessor != null && ModuleInfoProcessor.isModuleInfo(name)) { + continue; + } + if (isFiltered(jarFilters, name)) { + continue; + } + if (isExcludedEntry(name)) { continue; } + if (moduleInfoProcessor != null && ModuleInfoProcessor.isServiceConfiguration(name)) { + moduleInfoProcessor.includeServiceConfiguration(jar, name); + } try { shadeJarEntry( @@ -377,7 +493,8 @@ public InputStream call() throws Exception { }, name, getTime(entry), - entry.getMethod()); + entry.getMethod(), + moduleInfoProcessor); } catch (Exception e) { throw new IOException(String.format("Problem shading JAR %s entry %s: %s", jar, name, e), e); } @@ -393,7 +510,7 @@ private boolean isExcludedEntry(final String name) { return true; } - if ("module-info.class".equals(name)) { + if (ModuleInfoProcessor.isModuleInfo(name)) { logger.warn("Discovered module-info.class. " + "Shading will break its strong encapsulation."); return true; } @@ -412,7 +529,8 @@ private void shadeJarEntry( Callable inputProvider, String name, long time, - int method) + int method, + ModuleInfoProcessor moduleInfoProcessor) throws Exception { try (InputStream in = inputProvider.call()) { String mappedName = packageMapper.map(name, true, false); @@ -428,7 +546,7 @@ private void shadeJarEntry( duplicates.computeIfAbsent(name, k -> new HashSet<>()).add(jar); if (name.endsWith(".class")) { - addRemappedClass(jos, jar, name, time, in, packageMapper); + addRemappedClass(jos, jar, name, time, in, packageMapper, moduleInfoProcessor); } else if (shadeRequest.isShadeSourcesContent() && name.endsWith(".java")) { // Avoid duplicates if (resources.contains(mappedName)) { @@ -456,10 +574,26 @@ private void goThroughAllJarEntriesForManifestTransformer( ShadeRequest shadeRequest, Set resources, ManifestResourceTransformer manifestTransformer, + boolean forceMultiRelease, + String forceAutomaticModuleName, JarOutputStream jos) throws IOException { if (manifestTransformer != null) { for (File jar : shadeRequest.getJars()) { + if (jar.isDirectory()) { + File manifestFile = new File(jar, JarFile.MANIFEST_NAME); + if (manifestFile.isFile()) { + resources.add(JarFile.MANIFEST_NAME); + try (InputStream input = Files.newInputStream(manifestFile.toPath())) { + manifestTransformer.processResource( + JarFile.MANIFEST_NAME, + input, + shadeRequest.getRelocators(), + manifestFile.lastModified()); + } + } + continue; + } try (JarFile jarFile = newJarFile(jar)) { for (Enumeration en = jarFile.entries(); en.hasMoreElements(); ) { JarEntry entry = en.nextElement(); @@ -476,6 +610,8 @@ private void goThroughAllJarEntriesForManifestTransformer( } } if (manifestTransformer.hasTransformedResource()) { + manifestTransformer.setForceMultiRelease(forceMultiRelease); + manifestTransformer.setForceAutomaticModuleName(forceAutomaticModuleName); manifestTransformer.modifyOutputStream(jos); } } @@ -589,20 +725,32 @@ private void addDirectory(Set resources, JarOutputStream jos, String nam resources.add(name); } - private void addRemappedClass( - JarOutputStream jos, File jar, String name, long time, InputStream is, DefaultPackageMapper packageMapper) + private String addRemappedClass( + JarOutputStream jos, + File jar, + String name, + long time, + InputStream is, + DefaultPackageMapper packageMapper, + ModuleInfoProcessor moduleInfoProcessor) throws IOException, MojoExecutionException { if (packageMapper.relocators.isEmpty()) { try { JarEntry entry = new JarEntry(name); entry.setTime(time); jos.putNextEntry(entry); - IOUtil.copy(is, jos); + if (moduleInfoProcessor == null) { + IOUtil.copy(is, jos); + } else { + byte[] classBytes = IOUtil.toByteArray(is); + jos.write(classBytes); + moduleInfoProcessor.recordClass(jar, name, classBytes); + } + return name; } catch (ZipException e) { logger.debug("We have a duplicate " + name + " in " + jar); + return null; } - - return; } // Keep the original class, in case nothing was relocated by ShadeClassRemapper. This avoids binary @@ -639,7 +787,7 @@ private void addRemappedClass( } // Need to take the .class off for remapping evaluation - String mappedName = packageMapper.map(name.substring(0, name.indexOf('.')), true, false); + String mappedName = mapClassEntry(name, packageMapper); try { // Now we put it back on so the class file is written out with the right extension. @@ -648,9 +796,26 @@ private void addRemappedClass( jos.putNextEntry(entry); jos.write(renamedClass); + if (moduleInfoProcessor != null) { + moduleInfoProcessor.recordClass(jar, mappedName + ".class", renamedClass); + } + return mappedName + ".class"; } catch (ZipException e) { logger.debug("We have a duplicate " + mappedName + " in " + jar); + return null; + } + } + + private static String mapClassEntry(String name, DefaultPackageMapper packageMapper) { + Matcher versionedClass = VERSIONED_CLASS_ENTRY.matcher(name); + String prefix = ""; + String classEntry = name; + if (versionedClass.matches()) { + prefix = versionedClass.group(1); + classEntry = versionedClass.group(2); } + return prefix + + packageMapper.map(classEntry.substring(0, classEntry.length() - ".class".length()), true, false); } private boolean isFiltered(List filters, String name) { diff --git a/src/main/java/org/apache/maven/plugins/shade/ModuleInfoConfiguration.java b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoConfiguration.java new file mode 100644 index 00000000..5f7a7a76 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoConfiguration.java @@ -0,0 +1,194 @@ +/* + * 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.shade; + +import javax.lang.model.SourceVersion; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Additional choices used while constructing the module descriptor for amalgamated shaded contents. + */ +public class ModuleInfoConfiguration { + private String moduleName; + + private String publicBoundary = ModuleInfoPublicBoundary.PRIMARY.name(); + + private Map analysisJdkToolchain = Collections.emptyMap(); + + private List additionalExports = Collections.emptyList(); + + private List additionalOpens = Collections.emptyList(); + + private List additionalRequires = Collections.emptyList(); + + private Set additionalUses = Collections.emptySet(); + + private Set dynamicUses = Collections.emptySet(); + + public String getModuleName() { + return moduleName; + } + + public void setModuleName(String moduleName) { + this.moduleName = moduleName; + } + + static boolean isValidModuleName(String moduleName) { + if (moduleName == null || !SourceVersion.isName(moduleName)) { + return false; + } + for (String identifier : moduleName.split("[.]", -1)) { + if ("_".equals(identifier)) { + return false; + } + } + return true; + } + + public ModuleInfoPublicBoundary getPublicBoundary() { + return ModuleInfoPublicBoundary.fromString(publicBoundary); + } + + public void setPublicBoundary(String publicBoundary) { + this.publicBoundary = publicBoundary == null ? ModuleInfoPublicBoundary.PRIMARY.name() : publicBoundary; + } + + public Map getAnalysisJdkToolchain() { + return analysisJdkToolchain; + } + + public void setAnalysisJdkToolchain(Map analysisJdkToolchain) { + this.analysisJdkToolchain = + analysisJdkToolchain == null ? Collections.emptyMap() : analysisJdkToolchain; + } + + public List getAdditionalExports() { + return additionalExports; + } + + public void setAdditionalExports(List additionalExports) { + this.additionalExports = + additionalExports == null ? Collections.emptyList() : additionalExports; + } + + public List getAdditionalOpens() { + return additionalOpens; + } + + public void setAdditionalOpens(List additionalOpens) { + this.additionalOpens = additionalOpens == null ? Collections.emptyList() : additionalOpens; + } + + public List getAdditionalRequires() { + return additionalRequires; + } + + public void setAdditionalRequires(List additionalRequires) { + this.additionalRequires = + additionalRequires == null ? Collections.emptyList() : additionalRequires; + } + + public Set getAdditionalUses() { + return additionalUses; + } + + public void setAdditionalUses(Set additionalUses) { + this.additionalUses = additionalUses == null ? Collections.emptySet() : additionalUses; + } + + public Set getDynamicUses() { + return dynamicUses; + } + + public void setDynamicUses(Set dynamicUses) { + this.dynamicUses = dynamicUses == null ? Collections.emptySet() : dynamicUses; + } + + /** + * An additional export or open directive. An empty target set denotes an unqualified directive. + */ + public static class PackageDirective { + private String packageName; + + private Set targets = Collections.emptySet(); + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public void setPackage(String packageName) { + this.packageName = packageName; + } + + public Set getTargets() { + return targets; + } + + public void setTargets(Set targets) { + this.targets = targets == null ? Collections.emptySet() : targets; + } + } + + /** + * An additional module requirement. + */ + public static class Requirement { + private String module; + + private boolean staticRequirement; + + private boolean transitive; + + public String getModule() { + return module; + } + + public void setModule(String module) { + this.module = module; + } + + public boolean isStaticRequirement() { + return staticRequirement; + } + + public void setStaticRequirement(boolean staticRequirement) { + this.staticRequirement = staticRequirement; + } + + public void setStatic(boolean staticRequirement) { + this.staticRequirement = staticRequirement; + } + + public boolean isTransitive() { + return transitive; + } + + public void setTransitive(boolean transitive) { + this.transitive = transitive; + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/shade/ModuleInfoMode.java b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoMode.java new file mode 100644 index 00000000..fc763456 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoMode.java @@ -0,0 +1,51 @@ +/* + * 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.shade; + +import java.util.Locale; + +/** + * Controls how module descriptors are handled while shading. + */ +public enum ModuleInfoMode { + /** Discard module descriptors, retaining the historical behavior. */ + DISCARD, + + /** Merge module descriptors into the descriptor of the primary artifact. */ + MERGE; + + /** + * Parses a module descriptor handling mode without regard to case. + * + * @param value module descriptor handling mode + * @return parsed module descriptor handling mode + * @throws IllegalArgumentException if the value is not a supported mode + */ + public static ModuleInfoMode fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Module info mode must not be null."); + } + try { + return valueOf(value.toUpperCase(Locale.ROOT).trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Unknown module info mode '" + value + "'. Expected one of: discard, merge."); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/shade/ModuleInfoProcessor.java b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoProcessor.java new file mode 100644 index 00000000..5d274c01 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoProcessor.java @@ -0,0 +1,1993 @@ +/* + * 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.shade; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipException; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.shade.filter.Filter; +import org.apache.maven.plugins.shade.relocation.Relocator; +import org.apache.maven.plugins.shade.resource.ServicesResourceTransformer; +import org.objectweb.asm.Attribute; +import org.objectweb.asm.ByteVector; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Label; +import org.objectweb.asm.ModuleVisitor; +import org.objectweb.asm.Opcodes; +import org.slf4j.Logger; + +/** + * Collects, merges and writes module descriptors after the shaded archive contents are known. + */ +final class ModuleInfoProcessor { + private static final Pattern VERSIONED_MODULE_INFO = + Pattern.compile("^META-INF/versions/([1-9][0-9]*)/module-info[.]class$"); + + private static final Pattern VERSIONED_CLASS = Pattern.compile("^META-INF/versions/([1-9][0-9]*)/(.+)[.]class$"); + + private static final Pattern SERVICE_CONFIGURATION = Pattern.compile("^META-INF/services/([^/]+)$"); + + private static final int FIRST_MODULE_RELEASE = 9; + + private final Logger logger; + + private final File primaryArtifact; + + private final List relocators; + + private final ModuleInfoConfiguration configuration; + + private final File analysisJdkHome; + + private final Map artifacts = new LinkedHashMap<>(); + + private final Map dependencyArtifacts = new LinkedHashMap<>(); + + private final Map> outputClasses = new TreeMap<>(); + + private final Map> outputPackages = new TreeMap<>(); + + private final Map>> artifactOutputPackages = new LinkedHashMap<>(); + + private final Map> retainedClasses = new TreeMap<>(); + + private final Map providerOrigins = new HashMap<>(); + + private final Map transitiveRequirementOrigins = new HashMap<>(); + + private final Set warnedDroppedAttributes = new HashSet<>(); + + private final Set warnedAutomaticAnalysis = new HashSet<>(); + + private final Set warnedDynamicUses = new HashSet<>(); + + private String primaryModuleName; + + private String outputModuleName; + + private boolean multiReleaseOutput; + + private int modularFloor; + + private List floorReasons = Collections.emptyList(); + + private PlatformModuleIndex platformModules; + + ModuleInfoProcessor(ShadeRequest request, Logger logger) throws IOException, MojoExecutionException { + this.logger = logger; + this.primaryArtifact = request.getPrimaryArtifact(); + this.relocators = request.getRelocators(); + this.configuration = request.getModuleInfoConfiguration(); + this.analysisJdkHome = request.getModuleInfoAnalysisJdkHome(); + for (File artifact : request.getJars()) { + ArtifactData data = scanArtifact(artifact); + artifacts.put(artifact, data); + multiReleaseOutput |= data.multiRelease; + } + for (File artifact : request.getDependencyAnalysisArtifacts()) { + if (!artifacts.containsKey(artifact) && artifact != null && artifact.exists()) { + try { + dependencyArtifacts.put(artifact, scanArtifact(artifact, false)); + } catch (ZipException e) { + logger.debug("Skipping non-archive dependency-analysis input " + artifact); + } + } + } + determinePrimaryModuleName(); + determineOutputModuleName(); + multiReleaseOutput |= primaryRootDescriptorRequiresVersionedOutput(); + } + + static boolean isModuleInfo(String name) { + return "module-info.class".equals(name) + || VERSIONED_MODULE_INFO.matcher(name).matches(); + } + + static boolean isServiceConfiguration(String name) { + return SERVICE_CONFIGURATION.matcher(name).matches(); + } + + String getOutputModuleName() { + return outputModuleName; + } + + boolean hasPrimaryModule() { + return primaryModuleName != null; + } + + void selectDescriptors(File artifact, List filters) { + ArtifactData data = artifacts.get(artifact); + if (data != null) { + for (DescriptorEntry descriptor : data.descriptorsByName.values()) { + descriptor.included = true; + for (Filter filter : filters) { + if (filter.isFiltered(descriptor.name)) { + descriptor.included = false; + break; + } + } + } + } + } + + void includeServiceConfiguration(File artifact, String name) { + ArtifactData data = artifacts.get(artifact); + if (data != null) { + ServiceConfiguration configuration = data.serviceConfigurations.get(name); + if (configuration != null) { + configuration.included = true; + } + } + } + + void recordClass(File artifact, String outputName, byte[] bytecode) { + Matcher matcher = VERSIONED_CLASS.matcher(outputName); + int release = 0; + String className; + if (matcher.matches()) { + release = Integer.parseInt(matcher.group(1)); + className = matcher.group(2); + } else if (outputName.endsWith(".class")) { + className = outputName.substring(0, outputName.length() - ".class".length()); + } else { + return; + } + if ("module-info".equals(className)) { + return; + } + if (release > 0 && !multiReleaseOutput) { + return; + } + outputClasses.computeIfAbsent(release, key -> new LinkedHashSet<>()).add(className); + ArtifactData origin = artifacts.get(artifact); + int viewRelease = release == 0 ? FIRST_MODULE_RELEASE : release; + if (origin != null && !artifact.equals(primaryArtifact) && origin.effectiveDescriptor(viewRelease) == null) { + AutomaticModuleAnalyzer.Analysis analysis = AutomaticModuleAnalyzer.analyze(bytecode); + retainedClasses + .computeIfAbsent(release, key -> new LinkedHashMap<>()) + .put( + className, + new RetainedClass( + origin, + className, + Math.max( + release == 0 ? FIRST_MODULE_RELEASE : release, + classRelease(analysis.classVersion)), + analysis.references, + analysis.serviceUses, + analysis.unresolvedServiceUses)); + } + int separator = className.lastIndexOf('/'); + if (separator < 0) { + outputPackages + .computeIfAbsent(release, key -> new LinkedHashSet<>()) + .add(""); + } else { + String packaze = className.substring(0, separator).replace('/', '.'); + outputPackages + .computeIfAbsent(release, key -> new LinkedHashSet<>()) + .add(packaze); + artifactOutputPackages + .computeIfAbsent(artifact, key -> new TreeMap<>()) + .computeIfAbsent(release, key -> new LinkedHashSet<>()) + .add(packaze); + } + } + + void writeDescriptors(JarOutputStream output, ServicesResourceTransformer servicesTransformer) + throws IOException, MojoExecutionException { + if (primaryArtifact == null || !artifacts.containsKey(primaryArtifact)) { + logger.warn("Module descriptor merging was requested, but the shaded output has no primary artifact."); + return; + } + + SortedSet releases = collectReleaseBreakpoints(); + if (releases.isEmpty()) { + logger.warn("Module descriptor merging was requested, but the primary artifact has no module-info.class."); + return; + } + + int earliestRelease = findEarliestPrimaryRelease(releases); + if (earliestRelease < 0) { + logger.warn("Module descriptor merging was requested, but the primary module descriptor was filtered."); + return; + } + + if (requiresPlatformAnalysis(releases)) { + PlatformModuleIndex platform = platformModules(); + if (releases.last() > platform.getRelease()) { + throw analysisJdkTooOld(releases.last(), platform.getRelease()); + } + for (int release = earliestRelease; release <= platform.getRelease(); release++) { + releases.add(release); + } + } + + List candidates = new ArrayList<>(); + for (int release : releases.tailSet(earliestRelease)) { + DescriptorEntry primary = artifacts.get(primaryArtifact).effectiveDescriptor(release); + if (primary != null && primary.included) { + candidates.add(mergeRelease(release, primary)); + } + } + if (candidates.isEmpty()) { + logger.warn("Module descriptor merging was requested, but the primary module descriptor was filtered."); + return; + } + + validatePrimaryBoundary(candidates); + normalizeProvidersAndFloor(candidates, earliestRelease); + normalizeInvariantRequirements(candidates); + validateConfiguredRequirements(candidates); + SortedSet allPackages = collectAllPackages(candidates); + if (allPackages.contains("")) { + throw new MojoExecutionException("Cannot create module " + outputModuleName + + ": the shaded JAR contains a class in the unnamed package."); + } + for (MergedDescriptor descriptor : candidates) { + descriptor.packages.clear(); + descriptor.packages.addAll(allPackages); + } + + MergedDescriptor previous = null; + boolean first = true; + for (MergedDescriptor descriptor : candidates) { + if (descriptor.release < modularFloor) { + continue; + } + if (!first && descriptor.sameModuleSemantics(previous)) { + continue; + } + String entryName = first && modularFloor == FIRST_MODULE_RELEASE && hasIncludedRootPrimaryDescriptor() + ? "module-info.class" + : "META-INF/versions/" + descriptor.release + "/module-info.class"; + writeDescriptor(output, entryName, descriptor); + previous = descriptor; + first = false; + } + + bridgeAutomaticServices(candidates, earliestRelease, servicesTransformer); + logRaisedFloor(earliestRelease); + } + + boolean requiresMultiReleaseOutput() { + return multiReleaseOutput; + } + + void enableMultiReleaseOutput() { + multiReleaseOutput = true; + } + + private ArtifactData scanArtifact(File artifact) throws IOException, MojoExecutionException { + return scanArtifact(artifact, true); + } + + private ArtifactData scanArtifact(File artifact, boolean scanServices) throws IOException, MojoExecutionException { + ArtifactData result = new ArtifactData(artifact); + if (artifact.isDirectory()) { + File manifestFile = new File(artifact, JarFile.MANIFEST_NAME); + if (manifestFile.isFile()) { + try (InputStream input = Files.newInputStream(manifestFile.toPath())) { + result.readManifest(new Manifest(input)); + } + } + scanDirectory(result, artifact, artifact, "", scanServices); + } else { + try (JarFile jar = new JarFile(artifact)) { + result.readManifest(jar.getManifest()); + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (isModuleInfo(entry.getName())) { + try (InputStream input = jar.getInputStream(entry)) { + result.addDescriptor( + entry.getName(), + parseDescriptor(input, artifact, entry.getName()), + entry.getTime()); + } + } else if (entry.getName().endsWith(".class")) { + result.addClass(entry.getName()); + } else if (scanServices && isServiceConfiguration(entry.getName())) { + try (InputStream input = jar.getInputStream(entry)) { + result.addServiceConfiguration(parseServiceConfiguration(input, artifact, entry.getName())); + } + } + } + } + } + if (result.automaticModuleName == null) { + result.automaticModuleName = deriveAutomaticModuleName(artifact.getName()); + } + return result; + } + + private void scanDirectory(ArtifactData data, File root, File current, String prefix, boolean scanServices) + throws IOException, MojoExecutionException { + File[] children = current.listFiles(); + if (children == null) { + return; + } + Arrays.sort(children, Comparator.comparing(File::getName)); + for (File child : children) { + String name = prefix + child.getName(); + if (child.isDirectory()) { + scanDirectory(data, root, child, name + '/', scanServices); + } else if (isModuleInfo(name)) { + try (InputStream input = Files.newInputStream(child.toPath())) { + data.addDescriptor(name, parseDescriptor(input, root, name), child.lastModified()); + } + } else if (name.endsWith(".class")) { + data.addClass(name); + } else if (scanServices && isServiceConfiguration(name)) { + try (InputStream input = Files.newInputStream(child.toPath())) { + data.addServiceConfiguration(parseServiceConfiguration(input, root, name)); + } + } + } + } + + private ServiceConfiguration parseServiceConfiguration(InputStream input, File artifact, String entry) + throws IOException, MojoExecutionException { + Matcher matcher = SERVICE_CONFIGURATION.matcher(entry); + if (!matcher.matches()) { + throw new IllegalArgumentException(entry); + } + String service = matcher.group(1); + validateServiceClassName(service, artifact, entry); + SortedSet providers = new TreeSet<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + int comment = line.indexOf('#'); + String provider = (comment < 0 ? line : line.substring(0, comment)).trim(); + if (!provider.isEmpty()) { + validateServiceClassName(provider, artifact, entry); + providers.add(provider); + } + } + } + return new ServiceConfiguration(entry, service, providers); + } + + private void validateServiceClassName(String name, File artifact, String entry) throws MojoExecutionException { + if (!isBinaryClassName(name)) { + throw new MojoExecutionException( + "Invalid service class name " + name + " in " + artifact + '!' + entry + '.'); + } + } + + private static boolean isBinaryClassName(String name) { + boolean start = true; + for (int offset = 0; offset < name.length(); ) { + int character = name.codePointAt(offset); + if (character == '.') { + if (start) { + return false; + } + start = true; + } else if (start + ? !Character.isJavaIdentifierStart(character) + : !Character.isJavaIdentifierPart(character)) { + return false; + } else { + start = false; + } + offset += Character.charCount(character); + } + return !start; + } + + private ModuleDescriptorData parseDescriptor(InputStream input, File artifact, String entry) + throws IOException, MojoExecutionException { + try { + final ModuleDescriptorData descriptor = new ModuleDescriptorData(); + ClassReader reader = new ClassReader(input); + reader.accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit( + int version, + int access, + String name, + String signature, + String superName, + String[] interfaces) { + descriptor.classVersion = version; + descriptor.classAccess = access; + } + + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + descriptor.name = name; + descriptor.access = access; + descriptor.version = version; + return new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitMainClass(String mainClass) { + descriptor.mainClass = mainClass; + } + + @Override + public void visitPackage(String packaze) { + descriptor.packages.add(packaze); + } + + @Override + public void visitRequire(String module, int access, String version) { + descriptor.requires.put(module, new Require(module, access, version)); + } + + @Override + public void visitExport(String packaze, int access, String... modules) { + descriptor.exports.put(packaze, new Export(packaze, access, asSortedSet(modules))); + } + + @Override + public void visitOpen(String packaze, int access, String... modules) { + descriptor.opens.put(packaze, new Open(packaze, access, asSortedSet(modules))); + } + + @Override + public void visitUse(String service) { + descriptor.uses.add(service); + } + + @Override + public void visitProvide(String service, String... providers) { + descriptor + .provides + .computeIfAbsent(service, key -> new TreeSet<>()) + .addAll(Arrays.asList(providers)); + } + }; + } + + @Override + public void visitAttribute(Attribute attribute) { + if (attribute instanceof ModuleTargetAttribute) { + descriptor.targetPlatform = ((ModuleTargetAttribute) attribute).targetPlatform; + } else if (attribute instanceof ModuleResolutionAttribute) { + descriptor.resolutionFlags = ((ModuleResolutionAttribute) attribute).resolutionFlags; + } else { + descriptor.attributes.add(attribute.type); + } + } + }, + new Attribute[] {new ModuleTargetAttribute(), new ModuleResolutionAttribute()}, + 0); + if (descriptor.name == null) { + throw new MojoExecutionException( + "Invalid module descriptor " + entry + " in " + artifact + ": missing Module attribute."); + } + return descriptor; + } catch (IllegalArgumentException e) { + throw new MojoExecutionException("Cannot parse module descriptor " + entry + " in " + artifact, e); + } + } + + private MergedDescriptor mergeRelease(int release, DescriptorEntry primaryEntry) throws MojoExecutionException { + ModuleDescriptorData primary = primaryEntry.descriptor; + MergedDescriptor result = new MergedDescriptor(release, primaryEntry); + result.name = outputModuleName; + result.access = primary.access; + result.version = primary.version; + result.classVersion = primary.classVersion; + result.classAccess = primary.classAccess; + result.mainClass = relocateClass(primary.mainClass); + result.targetPlatform = primary.targetPlatform; + result.resolutionFlags = primary.resolutionFlags; + warnDroppedAttributes(primary, new Origin(primaryArtifact, primaryEntry)); + + Set embeddedNames = new HashSet<>(); + for (ArtifactData artifact : artifacts.values()) { + if (!artifact.file.equals(primaryArtifact)) { + embeddedNames.add(artifact.configuredModuleName(release)); + } + } + embeddedNames.remove(null); + embeddedNames.add(primaryModuleName); + embeddedNames.add(outputModuleName); + + for (Export export : primary.exports.values()) { + String packaze = relocatePackage(export.packaze); + SortedSet targets = removeEmbeddedTargets(export.targets, embeddedNames); + if (export.targets == null || !targets.isEmpty()) { + result.exports.put(packaze, new Export(packaze, export.access, targetsOrNull(export.targets, targets))); + } + } + for (Open open : primary.opens.values()) { + String packaze = relocatePackage(open.packaze); + SortedSet targets = removeEmbeddedTargets(open.targets, embeddedNames); + if (open.targets == null || !targets.isEmpty()) { + result.opens.put(packaze, new Open(packaze, open.access, targetsOrNull(open.targets, targets))); + } + } + result.primaryExports.putAll(result.exports); + result.primaryOpens.putAll(result.opens); + + addRequirements( + result, primary.requires.values(), embeddedNames, new Origin(primaryArtifact, primaryEntry), true); + addServices(result, primary, true, new Origin(primaryArtifact, primaryEntry)); + + for (ArtifactData artifact : artifacts.values()) { + if (artifact.file.equals(primaryArtifact)) { + continue; + } + DescriptorEntry descriptor = artifact.effectiveDescriptor(release); + if (descriptor == null) { + addAutomaticServices(result, artifact, release); + addAutomaticBoundary(result, artifact, release); + continue; + } + if (!descriptor.included) { + continue; + } + warnDroppedAttributes(descriptor.descriptor, new Origin(artifact.file, descriptor)); + addRequirements( + result, + descriptor.descriptor.requires.values(), + embeddedNames, + new Origin(artifact.file, descriptor), + false); + addServices(result, descriptor.descriptor, false, new Origin(artifact.file, descriptor)); + addEmbeddedBoundary(result, artifact, descriptor.descriptor, embeddedNames, release); + } + + addAutomaticRequirements(result, release); + addConfiguredDirectives(result, embeddedNames, release); + result.packages.addAll(effectivePackages(release)); + validateProjectDirectives(result, primary, release); + if ((result.access & Opcodes.ACC_OPEN) != 0) { + result.opens.clear(); + } + return result; + } + + private void addAutomaticServices(MergedDescriptor target, ArtifactData artifact, int release) { + if (artifact.effectiveDescriptor(release) != null) { + return; + } + for (ServiceConfiguration configuration : artifact.serviceConfigurations.values()) { + if (!configuration.included) { + continue; + } + String service = relocateClass(configuration.service); + for (String provider : configuration.providers) { + String relocatedProvider = relocateClass(provider); + if (hasClass(relocatedProvider, release)) { + target.provides + .computeIfAbsent(service, key -> new TreeSet<>()) + .add(relocatedProvider); + providerOrigins.putIfAbsent( + new ServiceProvider(service, relocatedProvider), + new Origin(artifact.file, configuration.name)); + } else { + logger.warn("Omitting automatic-module provider " + relocatedProvider + " from " + + artifact.file + '!' + configuration.name + + " because the class is not present in the shaded output."); + } + } + } + } + + private void addAutomaticBoundary(MergedDescriptor target, ArtifactData artifact, int release) { + if (configuration.getPublicBoundary() != ModuleInfoPublicBoundary.MERGE) { + return; + } + for (String packaze : effectiveArtifactPackages(artifact.file, release)) { + mergeExport(target.exports, new Export(packaze, 0, null)); + mergeOpen(target.opens, new Open(packaze, 0, null)); + } + } + + private void addEmbeddedBoundary( + MergedDescriptor target, + ArtifactData artifact, + ModuleDescriptorData descriptor, + Set embeddedNames, + int release) { + if (configuration.getPublicBoundary() != ModuleInfoPublicBoundary.MERGE) { + return; + } + Set packages = effectivePackages(release); + for (Export export : descriptor.exports.values()) { + String packaze = relocatePackage(export.packaze); + if (!packages.contains(packaze)) { + logger.warn("Omitting embedded export " + packaze + + " because the package is not present in the shaded output at Java " + release + '.'); + continue; + } + SortedSet targets = removeEmbeddedTargets(export.targets, embeddedNames); + if (export.targets == null || !targets.isEmpty()) { + mergeExport(target.exports, new Export(packaze, 0, targetsOrNull(export.targets, targets))); + } + } + if ((descriptor.access & Opcodes.ACC_OPEN) != 0) { + for (String packaze : effectiveArtifactPackages(artifact.file, release)) { + mergeOpen(target.opens, new Open(packaze, 0, null)); + } + } else { + for (Open open : descriptor.opens.values()) { + String packaze = relocatePackage(open.packaze); + if (!packages.contains(packaze)) { + logger.warn("Omitting embedded open package " + packaze + + " because the package is not present in the shaded output at Java " + release + '.'); + continue; + } + SortedSet targets = removeEmbeddedTargets(open.targets, embeddedNames); + if (open.targets == null || !targets.isEmpty()) { + mergeOpen(target.opens, new Open(packaze, 0, targetsOrNull(open.targets, targets))); + } + } + } + } + + private void addConfiguredDirectives(MergedDescriptor target, Set embeddedNames, int release) + throws MojoExecutionException { + for (ModuleInfoConfiguration.PackageDirective configured : configuration.getAdditionalExports()) { + Export export = configuredExport(configured, embeddedNames, "export"); + if (export != null) { + mergeExport(target.exports, export); + } + } + for (ModuleInfoConfiguration.PackageDirective configured : configuration.getAdditionalOpens()) { + Open open = configuredOpen(configured, embeddedNames); + if (open != null) { + mergeOpen(target.opens, open); + } + } + for (ModuleInfoConfiguration.Requirement configured : configuration.getAdditionalRequires()) { + String module = configured.getModule(); + if (module == null || module.trim().isEmpty()) { + throw new MojoExecutionException( + "moduleInfo.additionalRequires contains a requirement with no module."); + } + if (embeddedNames.contains(module)) { + throw new MojoExecutionException("moduleInfo.additionalRequires cannot require embedded module " + + module + " in the amalgamated output."); + } + if (isPlatformModule(module) && !platformModules().hasModule(module, release)) { + continue; + } + int access = 0; + if (configured.isStaticRequirement()) { + access |= Opcodes.ACC_STATIC_PHASE; + } + if (configured.isTransitive()) { + access |= Opcodes.ACC_TRANSITIVE; + } + target.requires.merge(module, new Require(module, access, null), Require::strongest); + } + for (String service : configuration.getAdditionalUses()) { + addConfiguredUse(target, service, "moduleInfo.additionalUses", release); + } + for (String service : configuration.getDynamicUses()) { + addConfiguredUse(target, service, "moduleInfo.dynamicUses", release); + } + } + + private void addConfiguredUse(MergedDescriptor target, String configured, String parameter, int release) + throws MojoExecutionException { + String service = relocateConfiguredClass(configured, parameter); + if (hasClass(service, release)) { + target.uses.add(service); + return; + } + SortedSet owners = findModuleOwners(toInternalName(service), release); + if (owners.size() > 1) { + throw new MojoExecutionException( + "Configured service type " + service + " is owned by multiple modules " + owners + '.'); + } + if (!owners.isEmpty()) { + target.uses.add(service); + String owner = owners.first(); + if (!"java.base".equals(owner) && !isAmalgamatedModuleName(owner)) { + target.requires.merge(owner, new Require(owner, 0, null), Require::strongest); + } + } + } + + private Export configuredExport( + ModuleInfoConfiguration.PackageDirective configured, Set embeddedNames, String directive) + throws MojoExecutionException { + String packaze = configuredPackage(configured, directive); + SortedSet targets = configuredTargets(configured, embeddedNames); + return configured.getTargets().isEmpty() || !targets.isEmpty() + ? new Export(packaze, 0, configured.getTargets().isEmpty() ? null : targets) + : null; + } + + private Open configuredOpen(ModuleInfoConfiguration.PackageDirective configured, Set embeddedNames) + throws MojoExecutionException { + String packaze = configuredPackage(configured, "open"); + SortedSet targets = configuredTargets(configured, embeddedNames); + return configured.getTargets().isEmpty() || !targets.isEmpty() + ? new Open(packaze, 0, configured.getTargets().isEmpty() ? null : targets) + : null; + } + + private String configuredPackage(ModuleInfoConfiguration.PackageDirective configured, String directive) + throws MojoExecutionException { + if (configured.getPackageName() == null + || configured.getPackageName().trim().isEmpty()) { + throw new MojoExecutionException("moduleInfo.additional" + directive + "s contains an empty package."); + } + return relocatePackage(configured.getPackageName()); + } + + private SortedSet configuredTargets( + ModuleInfoConfiguration.PackageDirective configured, Set embeddedNames) { + SortedSet targets = new TreeSet<>(configured.getTargets()); + targets.removeAll(embeddedNames); + return targets; + } + + private String relocateConfiguredClass(String className, String parameter) throws MojoExecutionException { + if (className == null || className.trim().isEmpty()) { + throw new MojoExecutionException(parameter + " contains an empty service class."); + } + return relocateClass(className); + } + + private boolean requiresPlatformAnalysis(SortedSet releases) { + for (int release : releases) { + for (RetainedClass retainedClass : effectiveRetainedClasses(release).values()) { + if (retainedClass.origin.effectiveDescriptor(release) == null) { + return true; + } + } + } + for (ModuleInfoConfiguration.Requirement requirement : configuration.getAdditionalRequires()) { + if (requirement.getModule() != null && isPlatformModule(requirement.getModule())) { + return true; + } + } + return !configuration.getAdditionalUses().isEmpty() + || !configuration.getDynamicUses().isEmpty(); + } + + private void validateConfiguredRequirements(List candidates) throws MojoExecutionException { + for (ModuleInfoConfiguration.Requirement requirement : configuration.getAdditionalRequires()) { + String module = requirement.getModule(); + boolean found = false; + for (MergedDescriptor candidate : candidates) { + if (candidate.requires.containsKey(module)) { + found = true; + break; + } + } + if (!found) { + throw new MojoExecutionException("Configured moduleInfo requirement " + module + + " is not available through Java " + candidates.get(candidates.size() - 1).release + + " in the selected analysis JDK or resolved dependency hull."); + } + } + validateConfiguredUses(candidates, configuration.getAdditionalUses(), "moduleInfo.additionalUses"); + validateConfiguredUses(candidates, configuration.getDynamicUses(), "moduleInfo.dynamicUses"); + } + + private void validateConfiguredUses(List candidates, Set configured, String parameter) + throws MojoExecutionException { + for (String service : configured) { + String relocated = relocateConfiguredClass(service, parameter); + boolean found = false; + for (MergedDescriptor candidate : candidates) { + if (candidate.uses.contains(relocated)) { + found = true; + break; + } + } + if (!found) { + throw new MojoExecutionException("Configured service type " + relocated + + " is not available through Java " + candidates.get(candidates.size() - 1).release + + " in the shaded output, selected analysis JDK, or resolved dependency hull."); + } + } + } + + private static void mergeExport(Map exports, Export addition) { + Export existing = exports.get(addition.packaze); + if (existing == null) { + exports.put(addition.packaze, addition); + } else { + exports.put( + addition.packaze, + new Export( + addition.packaze, + existing.access | addition.access, + mergeDirectiveTargets(existing.targets, addition.targets))); + } + } + + private static void mergeOpen(Map opens, Open addition) { + Open existing = opens.get(addition.packaze); + if (existing == null) { + opens.put(addition.packaze, addition); + } else { + opens.put( + addition.packaze, + new Open( + addition.packaze, + existing.access | addition.access, + mergeDirectiveTargets(existing.targets, addition.targets))); + } + } + + private static SortedSet mergeDirectiveTargets(SortedSet existing, SortedSet addition) { + if (existing == null || addition == null) { + return null; + } + SortedSet targets = new TreeSet<>(existing); + targets.addAll(addition); + return targets; + } + + private void addRequirements( + MergedDescriptor target, + Collection requirements, + Set embeddedNames, + Origin origin, + boolean primary) { + for (Require requirement : requirements) { + if (embeddedNames.contains(requirement.module)) { + continue; + } + Require retained = primary + ? requirement + : new Require( + requirement.module, requirement.access & Opcodes.ACC_STATIC_PHASE, requirement.version); + target.requires.merge(retained.module, retained, Require::strongest); + if (retained.isTransitive()) { + transitiveRequirementOrigins.putIfAbsent(requirement.module, origin); + } + } + } + + private void addAutomaticRequirements(MergedDescriptor target, int release) throws MojoExecutionException { + for (RetainedClass retainedClass : effectiveRetainedClasses(release).values()) { + if (retainedClass.origin.effectiveDescriptor(release) != null) { + continue; + } + + PlatformModuleIndex platform = platformModules(); + if (retainedClass.effectiveRelease > platform.getRelease()) { + throw analysisJdkTooOld(retainedClass.effectiveRelease, platform.getRelease()); + } + if (warnedAutomaticAnalysis.add(retainedClass.origin.file)) { + logger.warn("Inferring module requirements from bytecode retained from automatic module " + + retainedClass.origin.effectiveOutputModuleName(release) + " in " + retainedClass.origin.file + + "; reflective and string-only dependencies cannot be inferred."); + } + + if (!retainedClass.unresolvedServiceUses.isEmpty()) { + if (configuration.getDynamicUses().isEmpty()) { + throw new MojoExecutionException("Cannot preserve ServiceLoader semantics for automatic module " + + retainedClass.origin.effectiveOutputModuleName(release) + " in " + + retainedClass.origin.file + + ": retained class " + toClassName(retainedClass.className) + " invokes " + + retainedClass.unresolvedServiceUses.first() + + ", but its service type cannot be determined from bytecode. List every possible service " + + "type in moduleInfo.dynamicUses."); + } + if (warnedDynamicUses.add(retainedClass.origin.file)) { + logger.warn("Using moduleInfo.dynamicUses as the complete service-type set for dynamic " + + "ServiceLoader calls retained from " + retainedClass.origin.file + '.'); + } + } + for (String service : retainedClass.serviceUses) { + target.uses.add(toClassName(service)); + } + + for (String reference : retainedClass.references) { + if (hasClass(reference, release)) { + continue; + } + SortedSet owners = findModuleOwners(reference, release); + if (owners.isEmpty()) { + throw new MojoExecutionException("Cannot infer module requirements for automatic module " + + retainedClass.origin.effectiveOutputModuleName(release) + " in " + + retainedClass.origin.file + + ": retained class " + toClassName(retainedClass.className) + " references " + + toClassName(reference) + + ", which is not present in the shaded output, any resolved dependency, or the " + + "Java " + release + " platform represented by " + effectiveAnalysisJdkHome() + + '.'); + } + if (owners.size() > 1) { + throw new MojoExecutionException("Cannot infer module requirements for automatic module " + + retainedClass.origin.effectiveOutputModuleName(release) + " in " + + retainedClass.origin.file + + ": retained class " + toClassName(retainedClass.className) + " references " + + toClassName(reference) + ", which is owned by multiple modules " + owners + '.'); + } + String module = owners.first(); + if ("java.base".equals(module) || isAmalgamatedModuleName(module)) { + continue; + } + target.requires.merge(module, new Require(module, 0, null), Require::strongest); + logger.debug("Inferred requirement on " + module + " from automatic-module reference " + + toClassName(retainedClass.className) + " -> " + toClassName(reference)); + } + } + } + + private Map effectiveRetainedClasses(int release) { + Map result = new LinkedHashMap<>(); + Map root = retainedClasses.get(0); + if (root != null) { + result.putAll(root); + } + for (Map.Entry> entry : retainedClasses.entrySet()) { + if (entry.getKey() > 0 && entry.getKey() <= release) { + result.putAll(entry.getValue()); + } + } + return result; + } + + private SortedSet findModuleOwners(String className, int release) throws MojoExecutionException { + SortedSet owners = platformModules().findOwners(className, release); + for (ArtifactData artifact : dependencyArtifacts.values()) { + if (artifact.hasClass(className, release)) { + String moduleName = artifact.configuredModuleName(release); + if (moduleName != null) { + owners.add(moduleName); + } + } + } + return owners; + } + + private PlatformModuleIndex platformModules() throws MojoExecutionException { + if (platformModules == null) { + platformModules = new PlatformModuleIndex(effectiveAnalysisJdkHome()); + } + return platformModules; + } + + private File effectiveAnalysisJdkHome() { + return analysisJdkHome == null ? new File(System.getProperty("java.home")) : analysisJdkHome; + } + + private MojoExecutionException analysisJdkTooOld(int requiredRelease, int actualRelease) { + return new MojoExecutionException("Module-info analysis requires Java " + requiredRelease + + " platform data, but the selected JDK at " + effectiveAnalysisJdkHome() + " provides Java " + + actualRelease + ". Configure moduleInfo.analysisJdkToolchain with Java " + requiredRelease + + " or newer."); + } + + private static String toClassName(String internalName) { + return internalName.replace('/', '.'); + } + + private void addServices(MergedDescriptor target, ModuleDescriptorData source, boolean project, Origin origin) { + for (String service : source.uses) { + target.uses.add(relocateClass(service)); + } + for (Map.Entry> entry : source.provides.entrySet()) { + String service = relocateClass(entry.getKey()); + for (String provider : entry.getValue()) { + String relocatedProvider = relocateClass(provider); + if (project || hasClass(relocatedProvider, target.release)) { + target.provides + .computeIfAbsent(service, key -> new TreeSet<>()) + .add(relocatedProvider); + providerOrigins.putIfAbsent(new ServiceProvider(service, relocatedProvider), origin); + } else { + logger.warn("Omitting module provider " + relocatedProvider + " from " + origin.artifact + + " because the class is not present in the shaded output."); + } + } + } + } + + private void validateProjectDirectives(MergedDescriptor result, ModuleDescriptorData primary, int release) + throws MojoExecutionException { + Set packages = effectivePackages(release); + for (String packaze : result.primaryExports.keySet()) { + if (!packages.contains(packaze)) { + throw new MojoExecutionException("Cannot create module " + result.name + ": exported package " + packaze + + " from the primary descriptor is absent from the shaded output at Java " + release + '.'); + } + } + for (String packaze : result.primaryOpens.keySet()) { + if (!packages.contains(packaze)) { + throw new MojoExecutionException("Cannot create module " + result.name + ": opened package " + packaze + + " from the primary descriptor is absent from the shaded output at Java " + release + '.'); + } + } + if (result.mainClass != null && !hasClass(result.mainClass, release)) { + throw new MojoExecutionException("Cannot create module " + result.name + ": main class " + result.mainClass + + " from the primary descriptor is absent from the shaded output at Java " + release + '.'); + } + for (Map.Entry> provides : primary.provides.entrySet()) { + for (String provider : provides.getValue()) { + String relocated = relocateClass(provider); + if (!hasClass(relocated, release)) { + throw new MojoExecutionException("Cannot create module " + result.name + ": provider " + relocated + + " from the primary descriptor is absent from the shaded output at Java " + release + '.'); + } + } + } + } + + private void validatePrimaryBoundary(List candidates) throws MojoExecutionException { + MergedDescriptor first = candidates.get(0); + for (MergedDescriptor candidate : candidates) { + if (!Objects.equals(first.name, candidate.name) + || first.access != candidate.access + || !Objects.equals(first.version, candidate.version) + || !Objects.equals(first.mainClass, candidate.mainClass) + || !Objects.equals(first.targetPlatform, candidate.targetPlatform) + || !Objects.equals(first.resolutionFlags, candidate.resolutionFlags) + || !Objects.equals(first.primaryExports, candidate.primaryExports) + || !Objects.equals(first.primaryOpens, candidate.primaryOpens)) { + throw new MojoExecutionException( + "Primary module descriptors for " + primaryArtifact + + " change module identity, exports, opens, main class, target, or resolution across releases."); + } + } + } + + private void normalizeInvariantRequirements(List candidates) { + Map invariant = new TreeMap<>(); + for (MergedDescriptor candidate : candidates) { + for (Require requirement : candidate.requires.values()) { + if (!isPlatformModule(requirement.module) || requirement.isTransitive()) { + invariant.merge(requirement.module, requirement, Require::strongest); + } + } + } + for (MergedDescriptor candidate : candidates) { + for (String module : invariant.keySet()) { + candidate.requires.remove(module); + } + candidate.requires.putAll(invariant); + } + } + + private void normalizeProvidersAndFloor(List candidates, int earliestRelease) + throws MojoExecutionException { + Map> invariantProvides = new TreeMap<>(); + Map invariantExports = new TreeMap<>(); + Map invariantOpens = new TreeMap<>(); + for (MergedDescriptor candidate : candidates) { + mergeProvides(invariantProvides, candidate.provides); + for (Export export : candidate.exports.values()) { + mergeExport(invariantExports, export); + } + for (Open open : candidate.opens.values()) { + mergeOpen(invariantOpens, open); + } + } + + int floor = earliestRelease; + List reasons = new ArrayList<>(); + Map providerAvailability = new HashMap<>(); + for (Map.Entry> provides : invariantProvides.entrySet()) { + for (String provider : provides.getValue()) { + int available = firstAvailableRelease(provider, candidates); + if (available < 0) { + throw new MojoExecutionException("Cannot create module " + outputModuleName + ": provider class " + + provider + " is absent."); + } + if (available > earliestRelease) { + ServiceProvider key = new ServiceProvider(provides.getKey(), provider); + reasons.add(FloorReason.provider(key, available, providerOrigins.get(key))); + } + providerAvailability.put(new ServiceProvider(provides.getKey(), provider), available); + floor = Math.max(floor, available); + } + } + + for (String packaze : invariantExports.keySet()) { + int available = firstAvailablePackageRelease(packaze, candidates); + if (available < 0) { + throw new MojoExecutionException( + "Cannot export package " + packaze + " from " + outputModuleName + ": the package is absent."); + } + if (available > earliestRelease) { + reasons.add(FloorReason.exportedPackage(packaze, available)); + floor = Math.max(floor, available); + } + } + for (String packaze : invariantOpens.keySet()) { + int available = firstAvailablePackageRelease(packaze, candidates); + if (available < 0) { + throw new MojoExecutionException( + "Cannot open package " + packaze + " from " + outputModuleName + ": the package is absent."); + } + if (available > earliestRelease) { + reasons.add(FloorReason.openPackage(packaze, available)); + floor = Math.max(floor, available); + } + } + + Map platformTransitiveStart = new TreeMap<>(); + for (MergedDescriptor candidate : candidates) { + for (Require requirement : candidate.requires.values()) { + if (isPlatformModule(requirement.module) && requirement.isTransitive()) { + platformTransitiveStart.putIfAbsent(requirement.module, candidate.release); + } + } + } + for (Map.Entry requirement : platformTransitiveStart.entrySet()) { + if (requirement.getValue() > earliestRelease) { + reasons.add(FloorReason.requirement( + requirement.getKey(), + requirement.getValue(), + transitiveRequirementOrigins.get(requirement.getKey()))); + } + floor = Math.max(floor, requirement.getValue()); + } + + for (Map.Entry provider : providerAvailability.entrySet()) { + if (provider.getValue() < floor && !hasRootClass(provider.getKey().provider)) { + throw new MojoExecutionException("Provider " + provider.getKey().provider + " for " + + provider.getKey().service + " first available in Java " + provider.getValue() + + " cannot be exposed while " + outputModuleName + " is automatic below its Java " + floor + + " modular floor: META-INF/services cannot be versioned."); + } + } + + this.modularFloor = floor; + this.floorReasons = reasons; + for (MergedDescriptor candidate : candidates) { + candidate.provides.clear(); + mergeProvides(candidate.provides, invariantProvides); + candidate.exports.clear(); + candidate.exports.putAll(invariantExports); + candidate.opens.clear(); + candidate.opens.putAll(invariantOpens); + } + } + + private void bridgeAutomaticServices( + List candidates, int earliestRelease, ServicesResourceTransformer servicesTransformer) { + if (servicesTransformer == null || modularFloor <= earliestRelease) { + return; + } + MergedDescriptor floorDescriptor = null; + for (MergedDescriptor candidate : candidates) { + if (candidate.release >= modularFloor) { + floorDescriptor = candidate; + break; + } + } + if (floorDescriptor == null) { + return; + } + for (Map.Entry> provides : floorDescriptor.provides.entrySet()) { + for (String provider : provides.getValue()) { + if (hasRootClass(provider)) { + servicesTransformer.addServiceProvider(provides.getKey(), provider, floorDescriptor.timestamp); + } + } + } + } + + private void logRaisedFloor(int earliestRelease) { + if (modularFloor <= earliestRelease) { + return; + } + logger.warn("Raising the module descriptor floor for " + outputModuleName + " from Java " + earliestRelease + + " to Java " + modularFloor + '.'); + logger.warn("No explicit module descriptor will be effective on Java " + earliestRelease + " through " + + (modularFloor - 1) + "; the JAR will be the automatic module " + outputModuleName + + " on those releases."); + logger.warn("Automatic-Module-Name has been set to keep the module name stable."); + logger.warn("Reasons:"); + List reasons = new ArrayList<>(floorReasons); + Collections.sort(reasons); + for (FloorReason reason : reasons) { + logger.warn(" - " + reason.describe()); + } + } + + private void writeDescriptor(JarOutputStream output, String entryName, MergedDescriptor descriptor) + throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit( + descriptor.classVersion, descriptor.classAccess | Opcodes.ACC_MODULE, "module-info", null, null, null); + ModuleVisitor module = writer.visitModule(descriptor.name, descriptor.access, descriptor.version); + if (descriptor.mainClass != null) { + module.visitMainClass(toInternalName(descriptor.mainClass)); + } + for (String packaze : descriptor.packages) { + module.visitPackage(toInternalName(packaze)); + } + for (Require requirement : descriptor.requires.values()) { + module.visitRequire(requirement.module, requirement.access, requirement.version); + } + for (Export export : descriptor.exports.values()) { + module.visitExport(toInternalName(export.packaze), export.access, toArrayOrNull(export.targets)); + } + for (Open open : descriptor.opens.values()) { + module.visitOpen(toInternalName(open.packaze), open.access, toArrayOrNull(open.targets)); + } + for (String use : descriptor.uses) { + module.visitUse(toInternalName(use)); + } + for (Map.Entry> provides : descriptor.provides.entrySet()) { + String[] providers = provides.getValue().stream() + .map(ModuleInfoProcessor::toInternalName) + .toArray(String[]::new); + module.visitProvide(toInternalName(provides.getKey()), providers); + } + module.visitEnd(); + if (descriptor.targetPlatform != null) { + writer.visitAttribute(new ModuleTargetAttribute(descriptor.targetPlatform)); + } + if (descriptor.resolutionFlags != null) { + writer.visitAttribute(new ModuleResolutionAttribute(descriptor.resolutionFlags)); + } + writer.visitEnd(); + + JarEntry entry = new JarEntry(entryName); + entry.setTime(descriptor.timestamp); + output.putNextEntry(entry); + output.write(writer.toByteArray()); + } + + private SortedSet collectReleaseBreakpoints() { + SortedSet releases = new TreeSet<>(); + for (ArtifactData artifact : artifacts.values()) { + for (DescriptorEntry descriptor : artifact.descriptorsByName.values()) { + if (descriptor.included && (descriptor.physicalRelease == 0 || artifact.multiRelease)) { + releases.add(descriptor.effectiveRelease()); + } + } + } + for (Integer release : outputClasses.keySet()) { + releases.add(release == 0 ? FIRST_MODULE_RELEASE : release); + } + releases.removeIf(release -> release < FIRST_MODULE_RELEASE); + return releases; + } + + private int findEarliestPrimaryRelease(SortedSet releases) { + ArtifactData primary = artifacts.get(primaryArtifact); + for (int release : releases) { + DescriptorEntry descriptor = primary.effectiveDescriptor(release); + if (descriptor != null && descriptor.included) { + return release; + } + } + return -1; + } + + private boolean hasIncludedRootPrimaryDescriptor() { + ArtifactData primary = artifacts.get(primaryArtifact); + DescriptorEntry root = primary.descriptorsByName.get("module-info.class"); + return root != null && root.included; + } + + private SortedSet collectAllPackages(List candidates) { + SortedSet packages = new TreeSet<>(); + for (MergedDescriptor candidate : candidates) { + if (candidate.release >= modularFloor) { + packages.addAll(candidate.packages); + } + } + return packages; + } + + private Set effectivePackages(int release) { + return effectivePackages(outputPackages, release); + } + + private Set effectiveArtifactPackages(File artifact, int release) { + return effectivePackages( + artifactOutputPackages.getOrDefault(artifact, Collections.>emptyMap()), release); + } + + private Set effectivePackages(Map> packages, int release) { + Set result = new LinkedHashSet<>(); + Set root = packages.get(0); + if (root != null) { + result.addAll(root); + } + for (Map.Entry> entry : packages.entrySet()) { + if (entry.getKey() > 0 && entry.getKey() <= release) { + result.addAll(entry.getValue()); + } + } + return result; + } + + private boolean hasClass(String className, int release) { + String internalName = toInternalName(className); + if (outputClasses.getOrDefault(0, Collections.emptySet()).contains(internalName)) { + return true; + } + for (Map.Entry> entry : outputClasses.entrySet()) { + if (entry.getKey() > 0 + && entry.getKey() <= release + && entry.getValue().contains(internalName)) { + return true; + } + } + return false; + } + + private boolean hasRootClass(String className) { + return outputClasses.getOrDefault(0, Collections.emptySet()).contains(toInternalName(className)); + } + + private int firstAvailableRelease(String className, List candidates) { + for (MergedDescriptor candidate : candidates) { + if (hasClass(className, candidate.release)) { + return candidate.release; + } + } + return -1; + } + + private int firstAvailablePackageRelease(String packaze, List candidates) { + for (MergedDescriptor candidate : candidates) { + if (effectivePackages(candidate.release).contains(packaze)) { + return candidate.release; + } + } + return -1; + } + + private String relocatePackage(String packaze) { + return relocateEntity(packaze); + } + + private String relocateClass(String clazz) { + return relocateEntity(clazz); + } + + private String relocateEntity(String name) { + if (name == null) { + return null; + } + String dotted = name.replace('/', '.'); + String path = name.replace('.', '/'); + for (Relocator relocator : relocators) { + if (relocator.canRelocateClass(dotted)) { + return relocator.relocateClass(dotted).replace('/', '.'); + } else if (relocator.canRelocatePath(path)) { + return relocator.relocatePath(path).replace('/', '.'); + } + } + return dotted; + } + + private void determinePrimaryModuleName() throws MojoExecutionException { + if (primaryArtifact == null) { + return; + } + ArtifactData primary = artifacts.get(primaryArtifact); + if (primary == null || primary.descriptorsByName.isEmpty()) { + return; + } + Set names = new TreeSet<>(); + for (DescriptorEntry descriptor : primary.descriptorsByName.values()) { + if (descriptor.physicalRelease == 0 || primary.multiRelease) { + names.add(descriptor.descriptor.name); + } + } + if (names.isEmpty()) { + return; + } + if (names.size() != 1) { + throw new MojoExecutionException( + "Primary artifact " + primaryArtifact + " has conflicting module names " + names + '.'); + } + primaryModuleName = names.iterator().next(); + if (primary.automaticModuleNameExplicit + && primary.automaticModuleName != null + && !primaryModuleName.equals(primary.automaticModuleName)) { + throw new MojoExecutionException("Primary artifact " + primaryArtifact + " declares Automatic-Module-Name " + + primary.automaticModuleName + " but its descriptor declares " + primaryModuleName + '.'); + } + } + + private void determineOutputModuleName() throws MojoExecutionException { + if (primaryModuleName == null) { + return; + } + String configuredModuleName = configuration.getModuleName(); + if (configuredModuleName == null) { + outputModuleName = primaryModuleName; + } else if (!ModuleInfoConfiguration.isValidModuleName(configuredModuleName)) { + throw new MojoExecutionException( + "Invalid moduleInfo.moduleName '" + configuredModuleName + "': expected a qualified Java name."); + } else { + outputModuleName = configuredModuleName; + } + } + + private boolean isAmalgamatedModuleName(String moduleName) { + return primaryModuleName.equals(moduleName) || outputModuleName.equals(moduleName); + } + + private boolean primaryRootDescriptorRequiresVersionedOutput() { + if (primaryArtifact == null) { + return false; + } + ArtifactData primary = artifacts.get(primaryArtifact); + if (primary == null) { + return false; + } + DescriptorEntry root = primary.descriptorsByName.get("module-info.class"); + return root != null && root.effectiveRelease() > FIRST_MODULE_RELEASE; + } + + private void warnDroppedAttributes(ModuleDescriptorData descriptor, Origin origin) { + for (String attribute : descriptor.attributes) { + String warningKey = origin.describe() + ':' + attribute; + if (!warnedDroppedAttributes.add(warningKey)) { + continue; + } + if ("ModuleHashes".equals(attribute)) { + logger.warn("Dropping ModuleHashes from " + origin.describe() + + " because shading changes the module contents and invalidates the recorded hashes."); + } else { + logger.warn("Dropping unsupported module descriptor attribute " + attribute + " from " + + origin.describe() + " because it cannot be safely rewritten."); + } + } + } + + private static String deriveAutomaticModuleName(String filename) { + String name = filename; + if (name.toLowerCase().endsWith(".jar")) { + name = name.substring(0, name.length() - 4); + } + Matcher version = Pattern.compile("-(\\d+(?:[.]|$))").matcher(name); + if (version.find()) { + name = name.substring(0, version.start()); + } + return name.replaceAll("[^A-Za-z0-9]", ".").replaceAll("[.]+", ".").replaceAll("^[.]|[.]$", ""); + } + + private static boolean isPlatformModule(String module) { + return module.startsWith("java.") || module.startsWith("jdk."); + } + + private static int classRelease(int classVersion) { + int major = classVersion & 0xFFFF; + return Math.max(FIRST_MODULE_RELEASE, major - 44); + } + + private static String toInternalName(String name) { + return name == null ? null : name.replace('.', '/'); + } + + private static String[] toArrayOrNull(SortedSet values) { + return values == null ? null : values.toArray(new String[0]); + } + + private static SortedSet asSortedSet(String[] values) { + return values == null ? null : new TreeSet<>(Arrays.asList(values)); + } + + private static SortedSet removeEmbeddedTargets(SortedSet targets, Set embedded) { + if (targets == null) { + return new TreeSet<>(); + } + SortedSet result = new TreeSet<>(targets); + result.removeAll(embedded); + return result; + } + + private static SortedSet targetsOrNull(SortedSet original, SortedSet filtered) { + return original == null ? null : filtered; + } + + private static void mergeProvides( + Map> target, Map> source) { + for (Map.Entry> entry : source.entrySet()) { + target.computeIfAbsent(entry.getKey(), key -> new TreeSet<>()).addAll(entry.getValue()); + } + } + + private static final class ArtifactData { + private final File file; + private final Map descriptorsByName = new LinkedHashMap<>(); + private final Map serviceConfigurations = new LinkedHashMap<>(); + private final Map> classes = new TreeMap<>(); + private boolean multiRelease; + private String automaticModuleName; + private boolean automaticModuleNameExplicit; + + private ArtifactData(File file) { + this.file = file; + } + + private void readManifest(Manifest manifest) { + if (manifest == null) { + return; + } + Attributes attributes = manifest.getMainAttributes(); + multiRelease = "true".equalsIgnoreCase(attributes.getValue("Multi-Release")); + automaticModuleName = attributes.getValue("Automatic-Module-Name"); + automaticModuleNameExplicit = automaticModuleName != null; + } + + private void addDescriptor(String name, ModuleDescriptorData descriptor, long time) { + int release = 0; + Matcher matcher = VERSIONED_MODULE_INFO.matcher(name); + if (matcher.matches()) { + release = Integer.parseInt(matcher.group(1)); + } + descriptorsByName.put(name, new DescriptorEntry(name, release, descriptor, time)); + } + + private void addServiceConfiguration(ServiceConfiguration configuration) { + serviceConfigurations.put(configuration.name, configuration); + } + + private void addClass(String name) { + Matcher matcher = VERSIONED_CLASS.matcher(name); + int release = 0; + String className; + if (matcher.matches()) { + release = Integer.parseInt(matcher.group(1)); + className = matcher.group(2); + } else { + className = name.substring(0, name.length() - ".class".length()); + } + classes.computeIfAbsent(release, key -> new LinkedHashSet<>()).add(className); + } + + private boolean hasClass(String className, int release) { + if (classes.getOrDefault(0, Collections.emptySet()).contains(className)) { + return true; + } + if (!multiRelease) { + return false; + } + for (Map.Entry> entry : classes.entrySet()) { + if (entry.getKey() > 0 + && entry.getKey() <= release + && entry.getValue().contains(className)) { + return true; + } + } + return false; + } + + private DescriptorEntry effectiveDescriptor(int release) { + return effectiveDescriptor(release, true); + } + + private DescriptorEntry effectiveDescriptor(int release, boolean includedOnly) { + DescriptorEntry result = null; + for (DescriptorEntry descriptor : descriptorsByName.values()) { + if (includedOnly && !descriptor.included) { + continue; + } + if (descriptor.physicalRelease > 0 && !multiRelease) { + continue; + } + if (descriptor.effectiveRelease() <= release + && (result == null || descriptor.physicalRelease > result.physicalRelease)) { + result = descriptor; + } + } + return result; + } + + private String effectiveOutputModuleName(int release) { + DescriptorEntry descriptor = effectiveDescriptor(release); + return descriptor == null ? automaticModuleName : descriptor.descriptor.name; + } + + private String configuredModuleName(int release) { + DescriptorEntry descriptor = effectiveDescriptor(release, false); + return descriptor == null ? automaticModuleName : descriptor.descriptor.name; + } + } + + private static final class ServiceConfiguration { + private final String name; + private final String service; + private final SortedSet providers; + private boolean included; + + private ServiceConfiguration(String name, String service, SortedSet providers) { + this.name = name; + this.service = service; + this.providers = providers; + } + } + + private static final class RetainedClass { + private final ArtifactData origin; + private final String className; + private final int effectiveRelease; + private final Set references; + private final Set serviceUses; + private final SortedSet unresolvedServiceUses; + + private RetainedClass( + ArtifactData origin, + String className, + int effectiveRelease, + Set references, + Set serviceUses, + SortedSet unresolvedServiceUses) { + this.origin = origin; + this.className = className; + this.effectiveRelease = effectiveRelease; + this.references = references; + this.serviceUses = serviceUses; + this.unresolvedServiceUses = unresolvedServiceUses; + } + } + + private static final class DescriptorEntry { + private final String name; + private final int physicalRelease; + private final ModuleDescriptorData descriptor; + private final long time; + private boolean included; + + private DescriptorEntry(String name, int physicalRelease, ModuleDescriptorData descriptor, long time) { + this.name = name; + this.physicalRelease = physicalRelease; + this.descriptor = descriptor; + this.time = time; + } + + private int effectiveRelease() { + return Math.max( + physicalRelease == 0 ? FIRST_MODULE_RELEASE : physicalRelease, + classRelease(descriptor.classVersion)); + } + } + + private static final class ModuleDescriptorData { + private int classVersion; + private int classAccess; + private String name; + private int access; + private String version; + private String mainClass; + private String targetPlatform; + private Integer resolutionFlags; + private final Map requires = new TreeMap<>(); + private final Map exports = new TreeMap<>(); + private final Map opens = new TreeMap<>(); + private final SortedSet uses = new TreeSet<>(); + private final Map> provides = new TreeMap<>(); + private final SortedSet packages = new TreeSet<>(); + private final SortedSet attributes = new TreeSet<>(); + } + + private static final class MergedDescriptor { + private final int release; + private final long timestamp; + private int classVersion; + private int classAccess; + private String name; + private int access; + private String version; + private String mainClass; + private String targetPlatform; + private Integer resolutionFlags; + private final Map requires = new TreeMap<>(); + private final Map primaryExports = new TreeMap<>(); + private final Map primaryOpens = new TreeMap<>(); + private final Map exports = new TreeMap<>(); + private final Map opens = new TreeMap<>(); + private final SortedSet uses = new TreeSet<>(); + private final Map> provides = new TreeMap<>(); + private final SortedSet packages = new TreeSet<>(); + + private MergedDescriptor(int release, DescriptorEntry primary) { + this.release = release; + this.timestamp = primary.time; + } + + private boolean sameModuleSemantics(MergedDescriptor other) { + return other != null + && Objects.equals(name, other.name) + && access == other.access + && Objects.equals(version, other.version) + && Objects.equals(mainClass, other.mainClass) + && Objects.equals(targetPlatform, other.targetPlatform) + && Objects.equals(resolutionFlags, other.resolutionFlags) + && Objects.equals(requires, other.requires) + && Objects.equals(exports, other.exports) + && Objects.equals(opens, other.opens) + && Objects.equals(uses, other.uses) + && Objects.equals(provides, other.provides) + && Objects.equals(packages, other.packages); + } + } + + private static final class ModuleTargetAttribute extends Attribute { + private String targetPlatform; + + private ModuleTargetAttribute() { + this(null); + } + + private ModuleTargetAttribute(String targetPlatform) { + super("ModuleTarget"); + this.targetPlatform = targetPlatform; + } + + @Override + protected Attribute read( + ClassReader classReader, + int offset, + int length, + char[] charBuffer, + int codeAttributeOffset, + Label[] labels) { + return new ModuleTargetAttribute(classReader.readUTF8(offset, charBuffer)); + } + + @Override + protected ByteVector write(ClassWriter classWriter, byte[] code, int codeLength, int maxStack, int maxLocals) { + return new ByteVector().putShort(classWriter.newUTF8(targetPlatform)); + } + } + + private static final class ModuleResolutionAttribute extends Attribute { + private int resolutionFlags; + + private ModuleResolutionAttribute() { + this(0); + } + + private ModuleResolutionAttribute(int resolutionFlags) { + super("ModuleResolution"); + this.resolutionFlags = resolutionFlags; + } + + @Override + protected Attribute read( + ClassReader classReader, + int offset, + int length, + char[] charBuffer, + int codeAttributeOffset, + Label[] labels) { + return new ModuleResolutionAttribute(classReader.readUnsignedShort(offset)); + } + + @Override + protected ByteVector write(ClassWriter classWriter, byte[] code, int codeLength, int maxStack, int maxLocals) { + return new ByteVector().putShort(resolutionFlags); + } + } + + private static final class Require { + private final String module; + private final int access; + private final String version; + + private Require(String module, int access, String version) { + this.module = module; + this.access = access; + this.version = version; + } + + private boolean isTransitive() { + return (access & Opcodes.ACC_TRANSITIVE) != 0; + } + + private static Require strongest(Require left, Require right) { + int access = (left.access | right.access) & ~Opcodes.ACC_STATIC_PHASE; + if ((left.access & Opcodes.ACC_STATIC_PHASE) != 0 && (right.access & Opcodes.ACC_STATIC_PHASE) != 0) { + access |= Opcodes.ACC_STATIC_PHASE; + } + String version = left.version != null ? left.version : right.version; + return new Require(left.module, access, version); + } + + @Override + public boolean equals(Object object) { + if (!(object instanceof Require)) { + return false; + } + Require other = (Require) object; + return access == other.access + && Objects.equals(module, other.module) + && Objects.equals(version, other.version); + } + + @Override + public int hashCode() { + return Objects.hash(module, access, version); + } + } + + private abstract static class PackageDirective { + protected final String packaze; + protected final int access; + protected final SortedSet targets; + + private PackageDirective(String packaze, int access, SortedSet targets) { + this.packaze = packaze; + this.access = access; + this.targets = targets; + } + + @Override + public boolean equals(Object object) { + if (object == null || object.getClass() != getClass()) { + return false; + } + PackageDirective other = (PackageDirective) object; + return access == other.access + && Objects.equals(packaze, other.packaze) + && Objects.equals(targets, other.targets); + } + + @Override + public int hashCode() { + return Objects.hash(packaze, access, targets); + } + } + + private static final class Export extends PackageDirective { + private Export(String packaze, int access, SortedSet targets) { + super(packaze, access, targets); + } + } + + private static final class Open extends PackageDirective { + private Open(String packaze, int access, SortedSet targets) { + super(packaze, access, targets); + } + } + + private static final class ServiceProvider { + private final String service; + private final String provider; + + private ServiceProvider(String service, String provider) { + this.service = service; + this.provider = provider; + } + + @Override + public boolean equals(Object object) { + if (!(object instanceof ServiceProvider)) { + return false; + } + ServiceProvider other = (ServiceProvider) object; + return Objects.equals(service, other.service) && Objects.equals(provider, other.provider); + } + + @Override + public int hashCode() { + return Objects.hash(service, provider); + } + } + + private static final class Origin { + private final File artifact; + private final String entry; + + private Origin(File artifact, DescriptorEntry descriptor) { + this(artifact, descriptor.name); + } + + private Origin(File artifact, String entry) { + this.artifact = artifact; + this.entry = entry; + } + + private String describe() { + return artifact + "!" + entry; + } + } + + private static final class FloorReason implements Comparable { + private final String description; + + private FloorReason(String description) { + this.description = description; + } + + private static FloorReason provider(ServiceProvider provider, int release, Origin origin) { + return new FloorReason("provider " + provider.provider + " for " + provider.service + " from " + + describe(origin) + " is not present before Java " + release); + } + + private static FloorReason requirement(String module, int release, Origin origin) { + return new FloorReason("transitive platform requirement " + module + " from " + describe(origin) + + " cannot be represented before Java " + release); + } + + private static FloorReason exportedPackage(String packaze, int release) { + return new FloorReason("exported package " + packaze + " is not present before Java " + release); + } + + private static FloorReason openPackage(String packaze, int release) { + return new FloorReason("open package " + packaze + " is not present before Java " + release); + } + + private static String describe(Origin origin) { + return origin == null ? "an embedded descriptor" : origin.describe(); + } + + private String describe() { + return description; + } + + @Override + public int compareTo(FloorReason other) { + return description.compareTo(other.description); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/shade/ModuleInfoPublicBoundary.java b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoPublicBoundary.java new file mode 100644 index 00000000..3c0cfb68 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/shade/ModuleInfoPublicBoundary.java @@ -0,0 +1,51 @@ +/* + * 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.shade; + +import java.util.Locale; + +/** + * Controls which embedded package boundaries are retained in a merged module descriptor. + */ +public enum ModuleInfoPublicBoundary { + /** Retain only the primary artifact's exports and opens. */ + PRIMARY, + + /** Merge the effective exports and opens of embedded explicit and automatic modules. */ + MERGE; + + /** + * Parses a public boundary mode without regard to case. + * + * @param value public boundary mode + * @return parsed public boundary mode + * @throws IllegalArgumentException if the value is not a supported mode + */ + public static ModuleInfoPublicBoundary fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Module info public boundary must not be null."); + } + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Unknown module info public boundary '" + value + "'. Expected one of: primary, merge."); + } + } +} diff --git a/src/main/java/org/apache/maven/plugins/shade/PlatformModuleIndex.java b/src/main/java/org/apache/maven/plugins/shade/PlatformModuleIndex.java new file mode 100644 index 00000000..5ce397f4 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/shade/PlatformModuleIndex.java @@ -0,0 +1,200 @@ +/* + * 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.shade; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.apache.maven.plugin.MojoExecutionException; +import org.objectweb.asm.ClassReader; + +/** + * Release-aware ownership index for classes in a selected JDK. + */ +final class PlatformModuleIndex { + private static final int FIRST_MODULE_RELEASE = 9; + + private final File jdkHome; + + private final Map>> classOwners = new HashMap<>(); + + private final Map> modules = new HashMap<>(); + + private final int release; + + PlatformModuleIndex(File jdkHome) throws MojoExecutionException { + this.jdkHome = normalizeJdkHome(jdkHome); + this.release = readCurrentRelease(); + readHistoricalReleases(); + readCurrentReleaseClasses(); + } + + int getRelease() { + return release; + } + + SortedSet findOwners(String className, int targetRelease) { + Map> releaseOwners = classOwners.get(targetRelease); + if (releaseOwners == null) { + return new TreeSet<>(); + } + Set owners = releaseOwners.get(className); + return owners == null ? new TreeSet() : new TreeSet<>(owners); + } + + boolean hasModule(String module, int targetRelease) { + return modules.getOrDefault(targetRelease, Collections.emptySet()) + .contains(module); + } + + private int readCurrentRelease() throws MojoExecutionException { + File javaBase = new File(new File(jdkHome, "jmods"), "java.base.jmod"); + if (!javaBase.isFile()) { + throw invalidJdk("missing jmods/java.base.jmod"); + } + try (JarFile jmod = new JarFile(javaBase)) { + JarEntry descriptor = jmod.getJarEntry("classes/module-info.class"); + if (descriptor == null) { + throw invalidJdk("jmods/java.base.jmod has no module descriptor"); + } + try (InputStream input = jmod.getInputStream(descriptor)) { + return classRelease(new ClassReader(input).readShort(6)); + } + } catch (IOException | IllegalArgumentException e) { + throw new MojoExecutionException("Cannot read the module-info analysis JDK at " + jdkHome, e); + } + } + + private void readHistoricalReleases() throws MojoExecutionException { + File ctSym = new File(new File(jdkHome, "lib"), "ct.sym"); + if (!ctSym.isFile()) { + if (release > FIRST_MODULE_RELEASE) { + throw invalidJdk("missing lib/ct.sym"); + } + return; + } + try (JarFile archive = new JarFile(ctSym)) { + Enumeration entries = archive.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.isDirectory()) { + continue; + } + String[] parts = entry.getName().split("/", 3); + if (parts.length < 3) { + continue; + } + String releaseSet = parts[0]; + String module = parts[1]; + String path = parts[2]; + if ("module-info.sig".equals(path)) { + forEachRelease( + releaseSet, + value -> modules.computeIfAbsent(value, key -> new TreeSet<>()) + .add(module)); + } else if (path.endsWith(".sig")) { + String className = path.substring(0, path.length() - ".sig".length()); + forEachRelease(releaseSet, value -> addOwner(value, className, module)); + } + } + } catch (IOException e) { + throw new MojoExecutionException("Cannot read " + ctSym + " for module-info analysis", e); + } + } + + private void readCurrentReleaseClasses() throws MojoExecutionException { + File jmods = new File(jdkHome, "jmods"); + File[] files = jmods.listFiles((directory, name) -> name.endsWith(".jmod")); + if (files == null || files.length == 0) { + throw invalidJdk("the jmods directory is empty"); + } + for (File file : files) { + String module = file.getName().substring(0, file.getName().length() - ".jmod".length()); + modules.computeIfAbsent(release, key -> new TreeSet<>()).add(module); + try (JarFile jmod = new JarFile(file)) { + Enumeration entries = jmod.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + if (!entry.isDirectory() + && name.startsWith("classes/") + && name.endsWith(".class") + && !"classes/module-info.class".equals(name)) { + addOwner( + release, + name.substring("classes/".length(), name.length() - ".class".length()), + module); + } + } + } catch (IOException e) { + throw new MojoExecutionException("Cannot read " + file + " for module-info analysis", e); + } + } + } + + private void forEachRelease(String releaseSet, ReleaseConsumer consumer) { + for (int index = 0; index < releaseSet.length(); index++) { + int value = Character.digit(releaseSet.charAt(index), 36); + if (value >= FIRST_MODULE_RELEASE && value < release) { + consumer.accept(value); + } + } + } + + private void addOwner(int targetRelease, String className, String module) { + classOwners + .computeIfAbsent(targetRelease, key -> new HashMap<>()) + .computeIfAbsent(className, key -> new TreeSet<>()) + .add(module); + modules.computeIfAbsent(targetRelease, key -> new TreeSet<>()).add(module); + } + + private MojoExecutionException invalidJdk(String reason) { + return new MojoExecutionException("Invalid module-info analysis JDK at " + jdkHome + ": " + reason + + ". Configure moduleInfo.analysisJdkToolchain with a complete modular JDK."); + } + + private static File normalizeJdkHome(File configured) { + File home = configured == null ? new File(System.getProperty("java.home")) : configured; + if (!new File(home, "jmods").isDirectory() + && home.getParentFile() != null + && new File(home.getParentFile(), "jmods").isDirectory()) { + return home.getParentFile(); + } + return home; + } + + private static int classRelease(int classVersion) { + return (classVersion & 0xFFFF) - 44; + } + + private interface ReleaseConsumer { + void accept(int release); + } +} diff --git a/src/main/java/org/apache/maven/plugins/shade/ShadeRequest.java b/src/main/java/org/apache/maven/plugins/shade/ShadeRequest.java index c7781452..b014653d 100644 --- a/src/main/java/org/apache/maven/plugins/shade/ShadeRequest.java +++ b/src/main/java/org/apache/maven/plugins/shade/ShadeRequest.java @@ -19,6 +19,7 @@ package org.apache.maven.plugins.shade; import java.io.File; +import java.util.Collections; import java.util.List; import java.util.Set; @@ -34,6 +35,8 @@ public class ShadeRequest { private Set jars; + private File primaryArtifact; + private File uberJar; private List filters; @@ -44,6 +47,14 @@ public class ShadeRequest { private boolean shadeSourcesContent; + private ModuleInfoMode moduleInfoMode = ModuleInfoMode.DISCARD; + + private ModuleInfoConfiguration moduleInfoConfiguration = new ModuleInfoConfiguration(); + + private File moduleInfoAnalysisJdkHome; + + private Set dependencyAnalysisArtifacts = Collections.emptySet(); + public Set getJars() { return jars; } @@ -57,6 +68,19 @@ public void setJars(Set jars) { this.jars = jars; } + public File getPrimaryArtifact() { + return primaryArtifact; + } + + /** + * Sets the artifact whose module descriptor defines the identity and public boundary of the shaded module. + * + * @param primaryArtifact the primary artifact, or {@code null} when the output has no primary artifact + */ + public void setPrimaryArtifact(File primaryArtifact) { + this.primaryArtifact = primaryArtifact; + } + public File getUberJar() { return uberJar; } @@ -123,4 +147,58 @@ public boolean isShadeSourcesContent() { public void setShadeSourcesContent(boolean shadeSourcesContent) { this.shadeSourcesContent = shadeSourcesContent; } + + public ModuleInfoMode getModuleInfoMode() { + return moduleInfoMode; + } + + /** + * Sets how module descriptors are handled. + * + * @param moduleInfoMode module descriptor handling mode + */ + public void setModuleInfoMode(ModuleInfoMode moduleInfoMode) { + this.moduleInfoMode = moduleInfoMode == null ? ModuleInfoMode.DISCARD : moduleInfoMode; + } + + public ModuleInfoConfiguration getModuleInfoConfiguration() { + return moduleInfoConfiguration; + } + + /** + * Sets the choices used while constructing a merged descriptor. + * + * @param moduleInfoConfiguration module descriptor configuration + */ + public void setModuleInfoConfiguration(ModuleInfoConfiguration moduleInfoConfiguration) { + this.moduleInfoConfiguration = + moduleInfoConfiguration == null ? new ModuleInfoConfiguration() : moduleInfoConfiguration; + } + + public File getModuleInfoAnalysisJdkHome() { + return moduleInfoAnalysisJdkHome; + } + + /** + * Sets the JDK home used to resolve platform-module ownership for automatic-module bytecode. + * + * @param moduleInfoAnalysisJdkHome analysis JDK home, or {@code null} to use the running JDK + */ + public void setModuleInfoAnalysisJdkHome(File moduleInfoAnalysisJdkHome) { + this.moduleInfoAnalysisJdkHome = moduleInfoAnalysisJdkHome; + } + + public Set getDependencyAnalysisArtifacts() { + return dependencyAnalysisArtifacts; + } + + /** + * Sets the complete binary dependency hull used to resolve module ownership during automatic-module analysis. + * + * @param dependencyAnalysisArtifacts resolved binary artifacts, including artifacts excluded from shading + */ + public void setDependencyAnalysisArtifacts(Set dependencyAnalysisArtifacts) { + this.dependencyAnalysisArtifacts = + dependencyAnalysisArtifacts == null ? Collections.emptySet() : dependencyAnalysisArtifacts; + } } diff --git a/src/main/java/org/apache/maven/plugins/shade/mojo/ShadeMojo.java b/src/main/java/org/apache/maven/plugins/shade/mojo/ShadeMojo.java index 09e3956f..edaca412 100644 --- a/src/main/java/org/apache/maven/plugins/shade/mojo/ShadeMojo.java +++ b/src/main/java/org/apache/maven/plugins/shade/mojo/ShadeMojo.java @@ -51,6 +51,8 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.plugins.shade.ModuleInfoConfiguration; +import org.apache.maven.plugins.shade.ModuleInfoMode; import org.apache.maven.plugins.shade.ShadeRequest; import org.apache.maven.plugins.shade.Shader; import org.apache.maven.plugins.shade.filter.Filter; @@ -69,6 +71,8 @@ import org.apache.maven.project.ProjectBuildingException; import org.apache.maven.project.ProjectBuildingRequest; import org.apache.maven.project.ProjectBuildingResult; +import org.apache.maven.toolchain.Toolchain; +import org.apache.maven.toolchain.ToolchainManager; import org.codehaus.plexus.util.IOUtil; import org.codehaus.plexus.util.WriterFactory; import org.eclipse.aether.DefaultRepositorySystemSession; @@ -331,6 +335,27 @@ public class ShadeMojo extends AbstractMojo { @Parameter(property = "shadeSourcesContent", defaultValue = "false") private boolean shadeSourcesContent; + /** + * Controls how compiled Java module descriptors are handled. The {@code discard} mode preserves the historical + * behavior. The {@code merge} mode retains the primary artifact's module identity by default and its public + * boundary while incorporating requirements and service declarations from embedded artifacts. The output module + * name can be changed with {@code moduleInfo.moduleName}. + * + * @see Merging Java + * Module Descriptors + * @since 3.7.0 + */ + @Parameter(property = "shade.moduleInfoMode", defaultValue = "discard") + private String moduleInfoMode = "discard"; + + /** + * Configures how the merged descriptor represents the amalgamated shaded contents. + * + * @since 3.7.0 + */ + @Parameter + private ModuleInfoConfiguration moduleInfo = new ModuleInfoConfiguration(); + /** * When true, dependencies will be stripped down on the class level to only the transitive hull required for the * artifact. See also {@link #entryPoints}, if you wish to further optimize JAR minimization. @@ -463,6 +488,9 @@ public class ShadeMojo extends AbstractMojo { @Inject private Map shaders; + @Inject + private ToolchainManager toolchainManager; + /** * @throws MojoExecutionException in case of an error. */ @@ -481,6 +509,7 @@ public void execute() throws MojoExecutionException { Set sourceArtifacts = new LinkedHashSet<>(); Set testArtifacts = new LinkedHashSet<>(); Set testSourceArtifacts = new LinkedHashSet<>(); + Set dependencyAnalysisArtifacts = new LinkedHashSet<>(); ArtifactSelector artifactSelector = new ArtifactSelector(project.getArtifact(), artifactSet, shadedGroupFilter); @@ -502,6 +531,7 @@ public void execute() throws MojoExecutionException { + " that is not a file (does not exist or is not a file)"); } artifacts.add(extraJar); + dependencyAnalysisArtifacts.add(extraJar); } } @@ -528,7 +558,13 @@ public void execute() throws MojoExecutionException { } List processedArtifacts = processArtifactSelectors( - artifacts, artifactIds, sourceArtifacts, testArtifacts, testSourceArtifacts, artifactSelector); + artifacts, + artifactIds, + sourceArtifacts, + testArtifacts, + testSourceArtifacts, + dependencyAnalysisArtifacts, + artifactSelector); File outputJar = (outputFile != null) ? outputFile : shadedArtifactFileWithClassifier(); File sourcesJar = shadedSourceArtifactFileWithClassifier(); @@ -553,8 +589,15 @@ public void execute() throws MojoExecutionException { } } - ShadeRequest shadeRequest = - shadeRequest("jar", artifacts, outputJar, filters, relocators, resourceTransformers); + ShadeRequest shadeRequest = shadeRequest( + "jar", + artifacts, + project.getArtifact().getFile(), + outputJar, + filters, + relocators, + resourceTransformers, + dependencyAnalysisArtifacts); shader.shade(shadeRequest); @@ -566,8 +609,16 @@ public void execute() throws MojoExecutionException { } if (shadeTestJar) { - ShadeRequest shadeTestRequest = - shadeRequest("test-jar", testArtifacts, testJar, filters, relocators, resourceTransformers); + File primaryTestArtifact = shadedTestArtifactFile(); + ShadeRequest shadeTestRequest = shadeRequest( + "test-jar", + testArtifacts, + testArtifacts.contains(primaryTestArtifact) ? primaryTestArtifact : null, + testJar, + filters, + relocators, + resourceTransformers, + dependencyAnalysisArtifacts); shader.shade(shadeTestRequest); } @@ -695,19 +746,31 @@ private void createErrorOutput() { getLog().error("- You removed the configuration of the maven-jar-plugin that produces the main artifact."); } + @SuppressWarnings("checkstyle:ParameterNumber") private ShadeRequest shadeRequest( String shade, Set artifacts, + File primaryArtifact, File outputJar, List filters, List relocators, - List resourceTransformers) { + List resourceTransformers, + Set dependencyAnalysisArtifacts) + throws MojoExecutionException { ShadeRequest shadeRequest = new ShadeRequest(); shadeRequest.setJars(artifacts); + shadeRequest.setPrimaryArtifact(primaryArtifact); shadeRequest.setUberJar(outputJar); shadeRequest.setFilters(filters); shadeRequest.setRelocators(relocators); shadeRequest.setResourceTransformers(toResourceTransformers(shade, resourceTransformers)); + ModuleInfoMode parsedModuleInfoMode = ModuleInfoMode.fromString(moduleInfoMode); + shadeRequest.setModuleInfoMode(parsedModuleInfoMode); + shadeRequest.setModuleInfoConfiguration(moduleInfo); + if (parsedModuleInfoMode == ModuleInfoMode.MERGE && primaryArtifact != null) { + shadeRequest.setModuleInfoAnalysisJdkHome(resolveModuleInfoAnalysisJdkHome()); + } + shadeRequest.setDependencyAnalysisArtifacts(dependencyAnalysisArtifacts); return shadeRequest; } @@ -717,13 +780,67 @@ private ShadeRequest createShadeSourcesRequest( File testJar, List filters, List relocators, - List resourceTransformers) { - ShadeRequest shadeSourcesRequest = - shadeRequest(shade, testArtifacts, testJar, filters, relocators, resourceTransformers); + List resourceTransformers) + throws MojoExecutionException { + ShadeRequest shadeSourcesRequest = shadeRequest( + shade, + testArtifacts, + null, + testJar, + filters, + relocators, + resourceTransformers, + Collections.emptySet()); + shadeSourcesRequest.setModuleInfoMode(ModuleInfoMode.DISCARD); shadeSourcesRequest.setShadeSourcesContent(shadeSourcesContent); return shadeSourcesRequest; } + private File resolveModuleInfoAnalysisJdkHome() throws MojoExecutionException { + Toolchain toolchain = null; + Map requirements = moduleInfo.getAnalysisJdkToolchain(); + if (!requirements.isEmpty()) { + List matches = toolchainManager.getToolchains(session, "jdk", requirements); + if (matches == null || matches.isEmpty()) { + throw new MojoExecutionException( + "No JDK toolchain matches moduleInfo.analysisJdkToolchain " + requirements + '.'); + } + toolchain = matches.get(0); + } else { + toolchain = toolchainManager.getToolchainFromBuildContext("jdk", session); + } + + if (toolchain == null) { + return normalizeJdkHome(new File(System.getProperty("java.home"))); + } + + String javac = toolchain.findTool("javac"); + if (javac == null) { + throw new MojoExecutionException("The selected module-info analysis JDK toolchain has no javac tool."); + } + File executable = new File(javac); + File bin = executable.getParentFile(); + if (bin == null || bin.getParentFile() == null) { + throw new MojoExecutionException( + "Cannot determine the JDK home from the selected javac executable " + executable + '.'); + } + return normalizeJdkHome(bin.getParentFile()); + } + + private File normalizeJdkHome(File home) throws MojoExecutionException { + File candidate = home; + if (!new File(candidate, "jmods").isDirectory() + && candidate.getParentFile() != null + && new File(candidate.getParentFile(), "jmods").isDirectory()) { + candidate = candidate.getParentFile(); + } + try { + return candidate.getCanonicalFile(); + } catch (IOException e) { + throw new MojoExecutionException("Cannot resolve module-info analysis JDK home " + candidate, e); + } + } + private void setupHintedShader() throws MojoExecutionException { if (shaderHint != null) { shader = shaders.get(shaderHint); @@ -741,6 +858,7 @@ private List processArtifactSelectors( Set sourceArtifacts, Set testArtifacts, Set testSourceArtifacts, + Set dependencyAnalysisArtifacts, ArtifactSelector artifactSelector) throws MojoExecutionException { @@ -774,6 +892,15 @@ private List processArtifactSelectors( } processedArtifacts.addAll(project.getArtifacts()); + for (Artifact artifact : processedArtifacts) { + if (!"pom".equals(artifact.getType()) + && artifact.getFile() != null + && (artifact.getArtifactHandler() == null + || artifact.getArtifactHandler().isAddedToClasspath())) { + dependencyAnalysisArtifacts.add(artifact.getFile()); + } + } + // for loop over COPY; as we add to the list in this loop for (Artifact artifact : new ArrayList<>(processedArtifacts)) { if (!artifactSelector.isSelected(artifact)) { diff --git a/src/main/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformer.java b/src/main/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformer.java index b9d63b37..0762f6da 100644 --- a/src/main/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformer.java +++ b/src/main/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformer.java @@ -59,6 +59,10 @@ public class ManifestResourceTransformer extends AbstractCompatibilityTransforme private String shade; + private boolean forceMultiRelease; + + private String forceAutomaticModuleName; + public void setMainClass(String mainClass) { this.mainClass = mainClass; } @@ -71,6 +75,54 @@ public void setAdditionalAttributes(List additionalAttributes) { this.additionalAttributes = additionalAttributes; } + /** + * Forces the output manifest to retain multi-release processing semantics. + * + * @param forceMultiRelease whether {@code Multi-Release: true} must be written + */ + public void setForceMultiRelease(boolean forceMultiRelease) { + this.forceMultiRelease = forceMultiRelease; + } + + /** + * @return whether configured manifest entries explicitly disable multi-release processing + */ + public boolean isMultiReleaseExplicitlyDisabled() { + if (manifestEntries == null) { + return false; + } + for (Map.Entry entry : manifestEntries.entrySet()) { + if ("Multi-Release".equalsIgnoreCase(entry.getKey())) { + return "false".equalsIgnoreCase(String.valueOf(entry.getValue())); + } + } + return false; + } + + /** + * @return whether configured manifest entries explicitly enable multi-release processing + */ + public boolean isMultiReleaseExplicitlyEnabled() { + if (manifestEntries == null) { + return false; + } + for (Map.Entry entry : manifestEntries.entrySet()) { + if ("Multi-Release".equalsIgnoreCase(entry.getKey())) { + return "true".equalsIgnoreCase(String.valueOf(entry.getValue())); + } + } + return false; + } + + /** + * Forces a stable automatic module name for releases where no explicit descriptor is effective. + * + * @param moduleName module name + */ + public void setForceAutomaticModuleName(String moduleName) { + this.forceAutomaticModuleName = moduleName; + } + @Override public boolean canTransformResource(String resource) { return JarFile.MANIFEST_NAME.equalsIgnoreCase(resource); @@ -122,31 +174,42 @@ public boolean hasTransformedResource() { @Override public void modifyOutputStream(JarOutputStream jos) throws IOException { - // If we didn't find a manifest, then let's create one. - if (manifest == null) { - manifest = new Manifest(); - } - - Attributes attributes = manifest.getMainAttributes(); + try { + Manifest outputManifest = manifest == null ? new Manifest() : new Manifest(manifest); + Attributes attributes = outputManifest.getMainAttributes(); + if (attributes.getValue(Attributes.Name.MANIFEST_VERSION) == null) { + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + } - if (mainClass != null) { - attributes.put(Attributes.Name.MAIN_CLASS, mainClass); - } + if (mainClass != null) { + attributes.put(Attributes.Name.MAIN_CLASS, mainClass); + } - if (manifestEntries != null) { - for (Map.Entry entry : manifestEntries.entrySet()) { - if (entry.getValue() == null) { - attributes.remove(new Attributes.Name(entry.getKey())); - } else { - attributes.put(new Attributes.Name(entry.getKey()), entry.getValue()); + if (manifestEntries != null) { + for (Map.Entry entry : manifestEntries.entrySet()) { + if (entry.getValue() == null) { + attributes.remove(new Attributes.Name(entry.getKey())); + } else { + attributes.put(new Attributes.Name(entry.getKey()), entry.getValue()); + } } } - } - JarEntry jarEntry = new JarEntry(JarFile.MANIFEST_NAME); - jarEntry.setTime(time); - jos.putNextEntry(jarEntry); - manifest.write(jos); + if (forceMultiRelease) { + attributes.putValue("Multi-Release", "true"); + } + if (forceAutomaticModuleName != null) { + attributes.putValue("Automatic-Module-Name", forceAutomaticModuleName); + } + + JarEntry jarEntry = new JarEntry(JarFile.MANIFEST_NAME); + jarEntry.setTime(time); + jos.putNextEntry(jarEntry); + outputManifest.write(jos); + } finally { + forceMultiRelease = false; + forceAutomaticModuleName = null; + } } private String relocate(String originalValue, List relocators) { diff --git a/src/main/java/org/apache/maven/plugins/shade/resource/ServicesResourceTransformer.java b/src/main/java/org/apache/maven/plugins/shade/resource/ServicesResourceTransformer.java index ec8f521a..8c317089 100644 --- a/src/main/java/org/apache/maven/plugins/shade/resource/ServicesResourceTransformer.java +++ b/src/main/java/org/apache/maven/plugins/shade/resource/ServicesResourceTransformer.java @@ -47,6 +47,21 @@ public class ServicesResourceTransformer extends AbstractCompatibilityTransforme private long time = Long.MIN_VALUE; + /** + * Adds an already relocated provider to a service descriptor. + * + * @param service service type + * @param provider provider implementation + * @param time entry timestamp + */ + public void addServiceProvider(String service, String provider, long time) { + String resource = SERVICES_PATH + '/' + service; + serviceEntries.computeIfAbsent(resource, key -> new LinkedHashSet<>()).add(provider); + if (time > this.time) { + this.time = time; + } + } + @Override public boolean canTransformResource(String resource) { return resource.startsWith(SERVICES_PATH); diff --git a/src/site/markdown/examples/module-info-merging.md.vm b/src/site/markdown/examples/module-info-merging.md.vm new file mode 100644 index 00000000..2530d0f9 --- /dev/null +++ b/src/site/markdown/examples/module-info-merging.md.vm @@ -0,0 +1,203 @@ +--- +title: Merging Java Module Descriptors +author: + - The Apache Maven Team +date: 2026-07-30 +--- + + + +# Merging Java Module Descriptors + +By default, the plugin discards every root and versioned `module-info.class` from the inputs. This preserves the +historical behavior: an unmodified descriptor describes the original artifact rather than the contents of the +shaded JAR. + +Modular projects can opt into descriptor merging: + +```xml + + ... + + + + org.apache.maven.plugins + maven-shade-plugin + ${project.version} + + + package + + shade + + + merge + + com.example.shaded.application + merge + + 21 + + + + + com.example.internal + com.example.shaded.internal + + + + + + + + + ... + +``` + +Merge mode models the shaded JAR as if the retained, relocated source files had been compiled together as one module. +The primary artifact supplies the default module name and remains authoritative for the version, main class, and, by +default, the public boundary. `moduleInfo.moduleName` can replace the module name in every generated descriptor and in +the `Automatic-Module-Name` manifest entry; when omitted, the primary module name is preserved. Requirements on +modules embedded in the shaded JAR are removed. The primary descriptor's external requirements keep their modifiers. +An embedded descriptor's external `requires transitive` is changed to an ordinary requirement: embedding its +implementation does not make its former API part of the primary module's API. Embedded `static` requirements remain +static. Uses and providers are combined. Package and class names in all retained directives follow the configured +relocations. The configured module name is not relocated. + +`moduleInfo.publicBoundary` controls the public boundary: + +- `primary` (the default) retains only the primary descriptor's exports and opens. + +- `merge` also retains relocated exports and opens from embedded explicit modules. An automatic module exports and + opens all of its packages, so all of its retained packages are exported and opened in the combined module. + +This is a policy choice, not something bytecode analysis can infer. In particular, `merge` can expose packages which +were implementation details of the primary project. + +## Explicit descriptor additions + +The nested `moduleInfo` configuration can describe decisions a source-level amalgamation would put in its new +`module-info.java`: + +```xml + + primary + + + com.example.embedded.api + + + + + com.example.model + + com.example.framework + + + + + + com.example.api + true + true + + + + com.example.spi.Plugin + + + com.example.spi.DynamicPlugin + + +``` + +Additional package and class names are relocated like descriptor entries. An unqualified export or open is stronger +than a qualified one; qualified target lists are combined. Additional requirements can promote an inferred or embedded +ordinary requirement to `static`, `transitive`, or both. Requiring a module which is itself embedded is an error. + +An embedded automatic module has no descriptor from which requirements can be copied. The plugin therefore analyzes +its retained, relocated bytecode and adds ordinary requirements for referenced classes owned by external modules. +External dependency ownership is resolved from the project's complete resolved Java dependency hull, including +archives excluded from the shaded output. Non-archive dependencies are ignored. Platform ownership is resolved exactly +for each effective release from the selected JDK's `ct.sym` and `jmods` data. + +By default, analysis uses the JDK toolchain selected for the build, falling back to the JDK running Maven. A different +JDK can be selected with `moduleInfo.analysisJdkToolchain` using the same requirement keys as `jdkToolchain` in the +Compiler Plugin. The selected JDK must cover the highest class-file release retained in the shaded output. A missing +class owner, ambiguous owner, unavailable release, or too-old JDK fails the build with the affected class and required +JDK release. The plugin does not guess platform ownership from package names. + +Automatic-module provider configurations under `META-INF/services/` are converted to `provides` directives after +filtering and relocation. Calls to the public `ServiceLoader.load*` methods are analyzed and class-literal service types +are converted to `uses` directives, including values passed through local variables and branches. If a direct call +determines its service type dynamically, all possible service types must be acknowledged in `moduleInfo.dynamicUses`. +Otherwise the build fails because the automatic module's implicit service permission cannot be represented in an +explicit descriptor. `additionalUses` adds uses not visible to the analyzer; `dynamicUses` both adds the directive and +acknowledges that particular analysis boundary. + +This analysis covers bytecode-visible type references. Dependencies used only through reflection, method handles, or +class-name strings cannot be inferred. Direct method-handle references to `ServiceLoader.load*` require `dynamicUses` +for the same reason as dynamic calls. The plugin emits a warning when it analyzes an automatic module so this +limitation remains visible. + +Filtering and minimization happen before the descriptor is written. The build fails when either operation removes a +package exported or opened by the primary descriptor, its main class, or one of its provider classes. Providers from +embedded descriptors whose implementations were removed are omitted with a warning. + +## Multi-release JARs and the modular floor + +Root descriptors and descriptors under `META-INF/versions/N/` are treated as cumulative release views. The plugin +generates a new descriptor only where the effective module view changes. `ModulePackages` is rebuilt from the classes +that remain in the shaded output. + +Some directives cannot safely be added to the earliest descriptor. For example, a provider implementation or a package +which must be exported or opened may exist only in a later release directory. Providers and the public boundary +describe the combined module and therefore need stable semantics. In these cases the plugin raises the **modular floor** +to the first Java release where every invariant provider and public package is valid. A release-specific ordinary +requirement on a `java.*` or `jdk.*` module, and release-specific `uses`, remain in only the releases where they are +valid. + +Raising the floor does not raise the bytecode level of ordinary classes. Instead, no explicit descriptor is effective +before that release. On those earlier Java releases the output is an automatic module. The plugin writes +`Automatic-Module-Name` using the effective output module name so that the name stays stable, and writes a detailed +warning containing the original and new floors plus every provider or boundary change that caused the change. +Providers whose classes exist in the JAR root are also exposed through `META-INF/services` so service loading continues +to work while the JAR is automatic. A provider that exists only in a versioned directory below the new floor cannot be +bridged because `META-INF/services` cannot be versioned. The build fails with the provider and affected releases instead +of silently dropping it. + +If the primary descriptor itself requires a later class-file release, it is likewise placed in that versioned release +directory. This lets the same JAR remain an automatic module on earlier Java releases instead of failing while reading +an unsupported root descriptor. + +## Manifest and descriptor metadata + +In `merge` mode, the output has `Multi-Release: true` whenever the primary artifact or any selected embedded artifact is +multi-release. This is independent of filters and relocations because versioned archive entries are interpreted only +when the manifest enables them. An explicitly configured `Multi-Release: false` is overridden with a warning. Descriptor +merging also enables the header when it emits a versioned descriptor. The default `discard` mode does not force this +header and continues honoring explicit manifest configuration. + +The primary descriptor's `ModuleTarget` and `ModuleResolution` attributes are retained. `ModuleHashes` is dropped with a +warning because changing the module contents invalidates the recorded hashes. Other unsupported descriptor attributes +are dropped with a warning instead of being copied with stale constant-pool references. + +Source and test-source JAR requests always use `discard` because they do not contain compiled descriptors. diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 8ca10611..787cf964 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -46,6 +46,7 @@ To provide you with better understanding on some usages of the Shade Plugin, you - [Selecting Contents for Uber JAR](./examples/includes-excludes.html) - [Relocating Classes](./examples/class-relocation.html) +- [Merging Java Module Descriptors](./examples/module-info-merging.html) - [Attaching the Shaded Artifact](./examples/attached-artifact.html) - [Executable JAR](./examples/executable-jar.html) - [Resource Transformers](./examples/resource-transformers.html) diff --git a/src/site/site.xml b/src/site/site.xml index deae4ef4..2d4019cd 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -38,6 +38,7 @@ under the License. + diff --git a/src/test/java/org/apache/maven/plugins/shade/DefaultShaderTest.java b/src/test/java/org/apache/maven/plugins/shade/DefaultShaderTest.java index 63e100bd..bd847c69 100644 --- a/src/test/java/org/apache/maven/plugins/shade/DefaultShaderTest.java +++ b/src/test/java/org/apache/maven/plugins/shade/DefaultShaderTest.java @@ -38,13 +38,16 @@ import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.JarInputStream; import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import java.util.stream.Collectors; import java.util.zip.CRC32; import java.util.zip.ZipEntry; @@ -55,17 +58,27 @@ import org.apache.maven.plugins.shade.relocation.SimpleRelocator; import org.apache.maven.plugins.shade.resource.AppendingTransformer; import org.apache.maven.plugins.shade.resource.ComponentsXmlResourceTransformer; +import org.apache.maven.plugins.shade.resource.ManifestResourceTransformer; import org.apache.maven.plugins.shade.resource.ResourceTransformer; import org.apache.maven.plugins.shade.resource.ServicesResourceTransformer; import org.codehaus.plexus.util.IOUtil; import org.codehaus.plexus.util.Os; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; +import org.objectweb.asm.Attribute; +import org.objectweb.asm.ByteVector; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Handle; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.ModuleVisitor; import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; import org.slf4j.Logger; import static java.util.Arrays.asList; @@ -79,7 +92,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -93,7 +108,7 @@ public class DefaultShaderTest { new String[] {"org/codehaus/plexus/util/xml/Xpp3Dom", "org/codehaus/plexus/util/xml/pull.*"}; @TempDir - static File temporaryFolder; + File temporaryFolder; private static final String NEWLINE = "\n"; @@ -507,6 +522,1199 @@ public void testShaderWithDuplicateService() throws Exception { temporaryFolder.delete(); } + @Test + public void preservesExplicitMultiReleaseFalseInDiscardMode() throws Exception { + File primary = createJar(newFile(temporaryFolder, "primary.jar"), false, "primary.txt"); + File dependency = createJar(newFile(temporaryFolder, "dependency.jar"), true, "dependency.txt"); + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + + ManifestResourceTransformer manifestTransformer = new ManifestResourceTransformer(); + HashMap entries = new HashMap<>(); + entries.put("Multi-Release", "false"); + manifestTransformer.setManifestEntries(entries); + + ShadeRequest shadeRequest = new ShadeRequest(); + shadeRequest.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + shadeRequest.setPrimaryArtifact(primary); + shadeRequest.setFilters(Collections.emptyList()); + shadeRequest.setRelocators(Collections.emptyList()); + shadeRequest.setResourceTransformers(Collections.singletonList(manifestTransformer)); + shadeRequest.setUberJar(shadedFile); + + DefaultShader shader = newShader(); + shader.shade(shadeRequest); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals("false", shadedJar.getManifest().getMainAttributes().getValue("Multi-Release")); + } + assertFalse(warnMessages.getAllValues().stream() + .anyMatch(message -> message.contains("Multi-Release: false is overridden"))); + + temporaryFolder.delete(); + } + + @Test + public void propagatesMultiReleaseFromEmbeddedJarInMergeMode() throws Exception { + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor( + output, "app.module", new String[] {"dep.module"}, new String[] {"app/api"}, null, null); + writeClass(output, "app/api/App"); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest dependencyManifest = new Manifest(); + dependencyManifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + dependencyManifest.getMainAttributes().putValue("Multi-Release", "true"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), dependencyManifest)) { + writeModuleDescriptor(output, "dep.module", new String[0], new String[0], null, null); + writeClass(output, "META-INF/versions/11/dep/api/Dependency.class", "dep/api/Dependency", Opcodes.V11); + } + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ManifestResourceTransformer manifestTransformer = new ManifestResourceTransformer(); + HashMap entries = new HashMap<>(); + entries.put("Multi-Release", "false"); + manifestTransformer.setManifestEntries(entries); + + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.emptyList()); + request.setResourceTransformers(Collections.singletonList(manifestTransformer)); + request.setUberJar(shadedFile); + + newShader().shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals("true", shadedJar.getManifest().getMainAttributes().getValue("Multi-Release")); + } + assertThat(warnMessages.getAllValues(), hasItem(containsString("Multi-Release: false is overridden"))); + + temporaryFolder.delete(); + } + + @Test + public void infersPlatformRequirementsFromAutomaticModules() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor( + output, "app.module", new String[] {"dep.auto"}, new String[] {"app/api"}, null, null); + writeClass(output, "app/api/App"); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest dependencyManifest = new Manifest(); + dependencyManifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + dependencyManifest.getMainAttributes().putValue("Automatic-Module-Name", "dep.auto"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), dependencyManifest)) { + writeClassReferencing(output, "dep/AutomaticDependency", "java/sql/Driver"); + } + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.emptyList()); + request.setResourceTransformers(Collections.emptyList()); + request.setUberJar(shadedFile); + + newShader().shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + Set requirements = readModuleRequirements(shadedJar, "module-info.class", "app.module"); + assertTrue(requirements.contains("java.sql")); + assertFalse(requirements.contains("dep.auto")); + } + + temporaryFolder.delete(); + } + + @Test + public void mergesAutomaticModuleServicesAndInfersServiceLoaderUses() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest manifest = automaticModuleManifest("dep.auto", false); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()), manifest)) { + writeClass(output, "spi/FirstService"); + writeClass(output, "spi/SecondService"); + writeClass(output, "dep/Provider"); + writeServiceLoaderConsumer(output, "dep/Consumer", "spi/FirstService", "spi/SecondService"); + writeServiceConfiguration(output, "spi.FirstService", "dep.Provider"); + } + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ShadeRequest request = moduleMergeRequest(primary, dependency, Collections.emptySet(), shadedFile); + + newShader().shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals( + new LinkedHashSet<>(Arrays.asList("spi/FirstService", "spi/SecondService")), + readModuleUses(shadedJar, "module-info.class", "app.module")); + assertEquals( + Collections.singleton("dep/Provider"), + readModuleProviders(shadedJar, "module-info.class", "app.module", "spi/FirstService")); + } + + temporaryFolder.delete(); + } + + @Test + public void excludesFilteredAutomaticModuleServiceConfiguration() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest manifest = automaticModuleManifest("dep.auto", false); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()), manifest)) { + writeClass(output, "spi/Service"); + writeClass(output, "dep/Provider"); + writeServiceConfiguration(output, "spi.Service", "dep.Provider"); + } + + Filter filter = mock(Filter.class); + when(filter.canFilter(dependency)).thenReturn(true); + when(filter.isFiltered("META-INF/services/spi.Service")).thenReturn(true); + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ShadeRequest request = moduleMergeRequest(primary, dependency, Collections.emptySet(), shadedFile); + request.setFilters(Collections.singletonList(filter)); + + newShader().shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertTrue(readModuleProviders(shadedJar, "module-info.class", "app.module", "spi/Service") + .isEmpty()); + assertTrue(shadedJar.getJarEntry("META-INF/services/spi.Service") == null); + } + + temporaryFolder.delete(); + } + + @Test + public void failsWhenAutomaticModuleServiceLoaderUseIsDynamic() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest manifest = automaticModuleManifest("dep.auto", false); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()), manifest)) { + writeDynamicServiceLoaderConsumer(output, "dep/DynamicConsumer"); + } + + ShadeRequest request = + moduleMergeRequest(primary, dependency, Collections.emptySet(), newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + assertThat(exception.getMessage(), containsString("dep.DynamicConsumer")); + assertThat(exception.getMessage(), containsString("ServiceLoader.load")); + assertThat(exception.getMessage(), containsString("service type cannot be determined")); + + temporaryFolder.delete(); + } + + @Test + public void failsWhenAutomaticModuleUsesServiceLoaderMethodHandle() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest manifest = automaticModuleManifest("dep.auto", false); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()), manifest)) { + writeServiceLoaderMethodHandle(output, "dep/MethodHandleConsumer"); + } + + ShadeRequest request = + moduleMergeRequest(primary, dependency, Collections.emptySet(), newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + assertThat(exception.getMessage(), containsString("dep.MethodHandleConsumer")); + assertThat(exception.getMessage(), containsString("ServiceLoader method handle")); + + temporaryFolder.delete(); + } + + @Test + public void skipsNonArchiveDependencyAnalysisInputs() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + File dependency = automaticModule( + newFile(temporaryFolder, "dependency.jar"), "dep.auto", "dep/AutomaticDependency", "java/sql/Driver"); + File nativeLibrary = newFile(temporaryFolder, "libnative.so"); + Files.write(nativeLibrary.toPath(), new byte[] {0x7f, 'E', 'L', 'F'}); + + ShadeRequest request = moduleMergeRequest( + primary, dependency, Collections.singleton(nativeLibrary), newFile(temporaryFolder, "shaded.jar")); + + newShader().shade(request); + + temporaryFolder.delete(); + } + + @Test + public void rejectsBytecodeNewerThanTheAnalysisJdk() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + Assumptions.assumeTrue(runtimeFeature() < 26); + + int futureRelease = runtimeFeature() + 1; + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + File dependency = automaticMultiReleaseModule( + newFile(temporaryFolder, "dependency.jar"), + futureRelease, + "dep/FutureDependency", + "future/platform/Type"); + + ShadeRequest request = + moduleMergeRequest(primary, dependency, Collections.emptySet(), newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + + assertThat(exception.getMessage(), containsString("requires Java " + futureRelease + " platform data")); + assertThat(exception.getMessage(), containsString("analysisJdkToolchain")); + + temporaryFolder.delete(); + } + + @Test + public void failsWhenAutomaticModuleReferenceCannotBeResolved() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + + File dependency = automaticModule( + newFile(temporaryFolder, "dependency.jar"), "dep.auto", "dep/AutomaticDependency", "missing/Type"); + + ShadeRequest request = + moduleMergeRequest(primary, dependency, Collections.emptySet(), newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + assertThat(exception.getMessage(), containsString("dep.AutomaticDependency")); + assertThat(exception.getMessage(), containsString("missing.Type")); + assertThat(exception.getMessage(), containsString("not present in the shaded output")); + + temporaryFolder.delete(); + } + + @Test + public void failsWhenAutomaticModuleReferenceHasAmbiguousOwners() throws Exception { + Assumptions.assumeTrue(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + + File dependency = automaticModule( + newFile(temporaryFolder, "dependency.jar"), "dep.auto", "dep/AutomaticDependency", "external/Type"); + File externalOne = + automaticModule(newFile(temporaryFolder, "external-one.jar"), "external.one", "external/Type", null); + File externalTwo = + automaticModule(newFile(temporaryFolder, "external-two.jar"), "external.two", "external/Type", null); + + ShadeRequest request = moduleMergeRequest( + primary, + dependency, + new LinkedHashSet<>(Arrays.asList(externalOne, externalTwo)), + newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + assertThat(exception.getMessage(), containsString("external.Type")); + assertThat(exception.getMessage(), containsString("external.one")); + assertThat(exception.getMessage(), containsString("external.two")); + assertThat(exception.getMessage(), containsString("owned by multiple modules")); + + temporaryFolder.delete(); + } + + @Test + public void failsAutomaticModuleInferenceWithoutModularAnalysisJdk() throws Exception { + Assumptions.assumeFalse(isModularRuntime()); + + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[] {"dep.auto"}, new String[0], null, null); + } + File dependency = automaticModule( + newFile(temporaryFolder, "dependency.jar"), "dep.auto", "dep/AutomaticDependency", "java/sql/Driver"); + ShadeRequest request = + moduleMergeRequest(primary, dependency, Collections.emptySet(), newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + assertThat(exception.getMessage(), containsString("Invalid module-info analysis JDK")); + assertThat(exception.getMessage(), containsString("missing jmods/java.base.jmod")); + assertThat(exception.getMessage(), containsString("analysisJdkToolchain")); + + temporaryFolder.delete(); + } + + @Test + public void mergesModuleDescriptorsUsingPrimaryModuleBoundary() throws Exception { + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor( + output, + "app.module", + new String[] {"dep.module", "external.module"}, + new String[] {"app/api"}, + null, + null); + writeClass(output, "app/api/App"); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()))) { + writeModuleDescriptor( + output, + "dep.module", + new String[] {"external.module"}, + new String[] {"dep/api"}, + "spi/Service", + "dep/internal/Provider"); + writeClass(output, "dep/api/Dependency"); + writeClass(output, "dep/internal/Provider"); + writeClass(output, "dep/impl/Helper"); + } + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.singletonList(new SimpleRelocator("dep", "hidden.dep", null, null))); + request.setResourceTransformers(Collections.emptyList()); + request.setUberJar(shadedFile); + + newShader().shade(request); + + final Set requires = new LinkedHashSet<>(); + final Set exports = new LinkedHashSet<>(); + final Set packages = new LinkedHashSet<>(); + final Set providers = new LinkedHashSet<>(); + try (JarFile shadedJar = new JarFile(shadedFile); + InputStream descriptor = shadedJar.getInputStream(shadedJar.getJarEntry("module-info.class"))) { + new ClassReader(descriptor) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + assertEquals("app.module", name); + return new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitRequire(String module, int access, String version) { + requires.add(module); + } + + @Override + public void visitExport(String packaze, int access, String... modules) { + exports.add(packaze); + } + + @Override + public void visitPackage(String packaze) { + packages.add(packaze); + } + + @Override + public void visitProvide(String service, String... implementations) { + providers.addAll(Arrays.asList(implementations)); + } + }; + } + }, + 0); + + assertEquals( + "app.module", shadedJar.getManifest().getMainAttributes().getValue("Automatic-Module-Name")); + assertTrue(shadedJar.getEntry("hidden/dep/internal/Provider.class") != null); + assertTrue(shadedJar.getEntry("hidden/dep/impl/Helper.class") != null); + } + + assertTrue(requires.contains("java.base")); + assertTrue(requires.contains("external.module")); + assertFalse(requires.contains("dep.module")); + assertEquals(Collections.singleton("app/api"), exports); + assertEquals( + new LinkedHashSet<>( + Arrays.asList("app/api", "hidden/dep/api", "hidden/dep/impl", "hidden/dep/internal")), + packages); + assertEquals(Collections.singleton("hidden/dep/internal/Provider"), providers); + + temporaryFolder.delete(); + } + + @Test + public void raisesModuleFloorForLaterProvidersAndPlatformRequirements() throws Exception { + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor( + output, "app.module", new String[] {"dep.module"}, new String[] {"app/api"}, null, null); + writeClass(output, "app/api/App"); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest dependencyManifest = new Manifest(); + dependencyManifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + dependencyManifest.getMainAttributes().putValue("Multi-Release", "true"); + dependencyManifest.getMainAttributes().putValue("Automatic-Module-Name", "dep.module"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), dependencyManifest)) { + writeModuleDescriptor( + output, + "module-info.class", + Opcodes.V9, + "dep.module", + new String[0], + null, + new String[0], + "spi/Service", + "dep/internal/StableProvider"); + writeModuleDescriptor( + output, + "META-INF/versions/17/module-info.class", + Opcodes.V17, + "dep.module", + new String[0], + "jdk.incubator.vector", + new String[0], + "spi/Service", + "dep/internal/StableProvider", + "dep/versioned/LaterProvider"); + writeClass(output, "dep/internal/StableProvider"); + writeClass( + output, + "META-INF/versions/17/dep/versioned/LaterProvider.class", + "dep/versioned/LaterProvider", + Opcodes.V17); + } + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.singletonList(new SimpleRelocator("dep", "hidden.dep", null, null))); + request.setResourceTransformers(Collections.emptyList()); + request.setUberJar(shadedFile); + + newShader().shade(request); + + final Set requirements = new LinkedHashSet<>(); + final Set packages = new LinkedHashSet<>(); + final Set providers = new LinkedHashSet<>(); + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals("true", shadedJar.getManifest().getMainAttributes().getValue("Multi-Release")); + assertEquals( + "app.module", shadedJar.getManifest().getMainAttributes().getValue("Automatic-Module-Name")); + assertTrue(shadedJar.getJarEntry("module-info.class") == null); + assertTrue(shadedJar.getJarEntry("META-INF/versions/17/module-info.class") != null); + assertTrue(shadedJar.getJarEntry("META-INF/versions/17/hidden/dep/versioned/LaterProvider.class") != null); + + try (InputStream descriptor = + shadedJar.getInputStream(shadedJar.getJarEntry("META-INF/versions/17/module-info.class"))) { + new ClassReader(descriptor) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + assertEquals("app.module", name); + return new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitRequire(String module, int access, String version) { + if (!"java.base".equals(module)) { + requirements.add(module); + assertEquals(0, access & Opcodes.ACC_TRANSITIVE); + } + } + + @Override + public void visitPackage(String packaze) { + packages.add(packaze); + } + + @Override + public void visitProvide(String service, String... implementations) { + providers.addAll(Arrays.asList(implementations)); + } + }; + } + }, + 0); + } + + JarEntry service = shadedJar.getJarEntry("META-INF/services/spi.Service"); + assertTrue(service != null); + List serviceProviders = new BufferedReader( + new InputStreamReader(shadedJar.getInputStream(service), StandardCharsets.UTF_8)) + .lines() + .collect(Collectors.toList()); + assertEquals(Collections.singletonList("hidden.dep.internal.StableProvider"), serviceProviders); + } + + assertEquals(Collections.singleton("jdk.incubator.vector"), requirements); + assertEquals( + new LinkedHashSet<>(Arrays.asList("app/api", "hidden/dep/internal", "hidden/dep/versioned")), packages); + assertEquals( + new LinkedHashSet<>( + Arrays.asList("hidden/dep/internal/StableProvider", "hidden/dep/versioned/LaterProvider")), + providers); + assertThat( + warnMessages.getAllValues(), + hasItems( + containsString("Raising the module descriptor floor for app.module from Java 9 to Java 17"), + containsString("provider hidden.dep.versioned.LaterProvider for spi.Service"))); + assertFalse(warnMessages.getAllValues().stream() + .anyMatch(message -> message.contains("transitive platform requirement jdk.incubator.vector"))); + + temporaryFolder.delete(); + } + + @Test + public void failsWhenVersionedProviderCannotBeBridgedBelowRaisedFloor() throws Exception { + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor( + output, "app.module", new String[] {"dep.module"}, new String[] {"app/api"}, null, null); + writeClass(output, "app/api/App"); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + Manifest dependencyManifest = new Manifest(); + dependencyManifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + dependencyManifest.getMainAttributes().putValue("Multi-Release", "true"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), dependencyManifest)) { + writeModuleDescriptor( + output, "module-info.class", Opcodes.V9, "dep.module", new String[0], null, new String[0], null); + writeModuleDescriptor( + output, + "META-INF/versions/11/module-info.class", + Opcodes.V11, + "dep.module", + new String[0], + null, + new String[0], + "spi/Service", + "dep/versioned/Provider11"); + writeModuleDescriptor( + output, + "META-INF/versions/17/module-info.class", + Opcodes.V17, + "dep.module", + new String[0], + null, + new String[0], + "spi/Service", + "dep/versioned/Provider11", + "dep/versioned/Provider17"); + writeClass( + output, + "META-INF/versions/11/dep/versioned/Provider11.class", + "dep/versioned/Provider11", + Opcodes.V11); + writeClass( + output, + "META-INF/versions/17/dep/versioned/Provider17.class", + "dep/versioned/Provider17", + Opcodes.V17); + } + + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.emptyList()); + request.setResourceTransformers(Collections.emptyList()); + request.setUberJar(newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + assertThat(exception.getMessage(), containsString("dep.versioned.Provider11")); + assertThat(exception.getMessage(), containsString("cannot be exposed while app.module is automatic")); + assertThat(exception.getMessage(), containsString("META-INF/services cannot be versioned")); + + temporaryFolder.delete(); + } + + @Test + public void writesRootAndVersionedModuleDescriptors() throws Exception { + File primary = newFile(temporaryFolder, "primary.jar"); + Manifest primaryManifest = new Manifest(); + primaryManifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + primaryManifest.getMainAttributes().putValue("Multi-Release", "true"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()), primaryManifest)) { + writeModuleDescriptor( + output, + "module-info.class", + Opcodes.V9, + "app.module", + new String[] {"dep.module"}, + null, + new String[] {"app/api"}, + null); + writeModuleDescriptor( + output, + "META-INF/versions/17/module-info.class", + Opcodes.V17, + "app.module", + new String[] {"dep.module", "jdk.unsupported"}, + null, + new String[] {"app/api"}, + null); + writeClass(output, "app/api/App"); + writeClass( + output, "META-INF/versions/17/app/versioned/Feature.class", "app/versioned/Feature", Opcodes.V17); + } + + File dependency = newFile(temporaryFolder, "dependency.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()))) { + writeModuleDescriptor(output, "dep.module", new String[0], new String[0], null, null); + writeClass(output, "dep/api/Dependency"); + } + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Arrays.asList( + new SimpleRelocator("app.versioned", "hidden.app.versioned", null, null), + new SimpleRelocator("dep", "hidden.dep", null, null))); + request.setResourceTransformers(Collections.emptyList()); + ModuleInfoConfiguration moduleInfo = new ModuleInfoConfiguration(); + moduleInfo.setModuleName("shaded.app.module"); + request.setModuleInfoConfiguration(moduleInfo); + request.setUberJar(shadedFile); + + newShader().shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals("true", shadedJar.getManifest().getMainAttributes().getValue("Multi-Release")); + assertEquals( + "shaded.app.module", + shadedJar.getManifest().getMainAttributes().getValue("Automatic-Module-Name")); + assertEquals( + Collections.singleton("java.base"), + readModuleRequirements(shadedJar, "module-info.class", "shaded.app.module")); + assertEquals( + new LinkedHashSet<>(Arrays.asList("java.base", "jdk.unsupported")), + readModuleRequirements(shadedJar, "META-INF/versions/17/module-info.class", "shaded.app.module")); + Set expectedPackages = + new LinkedHashSet<>(Arrays.asList("app/api", "hidden/app/versioned", "hidden/dep/api")); + assertEquals(expectedPackages, readModulePackages(shadedJar, "module-info.class", "shaded.app.module")); + assertEquals( + expectedPackages, + readModulePackages(shadedJar, "META-INF/versions/17/module-info.class", "shaded.app.module")); + assertTrue(shadedJar.getJarEntry("META-INF/versions/17/hidden/app/versioned/Feature.class") != null); + assertTrue(shadedJar.getJarEntry("hidden/dep/api/Dependency.class") != null); + assertTrue(shadedJar.getJarEntry("META-INF/versions/17/app/versioned/Feature.class") == null); + assertTrue(shadedJar.getJarEntry("dep/api/Dependency.class") == null); + } + + temporaryFolder.delete(); + } + + @Test + public void failsWhenFilteringInvalidatesPrimaryModuleExports() throws Exception { + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeModuleDescriptor(output, "app.module", new String[0], new String[] {"app/api"}, null, null); + writeClass(output, "app/api/App"); + } + + Filter filter = mock(Filter.class); + when(filter.canFilter(primary)).thenReturn(true); + when(filter.isFiltered("app/api/App.class")).thenReturn(true); + + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Collections.singleton(primary))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.singletonList(filter)); + request.setRelocators(Collections.emptyList()); + request.setResourceTransformers(Collections.emptyList()); + request.setUberJar(newFile(temporaryFolder, "shaded.jar")); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> newShader().shade(request)); + assertThat( + exception.getMessage(), + containsString("exported package app.api from the primary descriptor is absent")); + + temporaryFolder.delete(); + } + + @Test + public void preservesModuleTargetAndResolutionButDropsHashes() throws Exception { + File primary = newFile(temporaryFolder, "primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V9, Opcodes.ACC_MODULE, "module-info", null, null, null); + ModuleVisitor module = writer.visitModule("app.module", 0, null); + module.visitRequire("java.base", Opcodes.ACC_MANDATED, null); + module.visitExport("app/api", 0); + module.visitPackage("app/api"); + module.visitEnd(); + writer.visitAttribute(new TestModuleTargetAttribute("linux-amd64")); + writer.visitAttribute(new TestModuleResolutionAttribute(1)); + writer.visitAttribute(new TestModuleHashesAttribute()); + writer.visitEnd(); + output.putNextEntry(new JarEntry("module-info.class")); + output.write(writer.toByteArray()); + writeClass(output, "app/api/App"); + } + + File shadedFile = newFile(temporaryFolder, "shaded.jar"); + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Collections.singleton(primary))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.emptyList()); + request.setResourceTransformers(Collections.emptyList()); + request.setUberJar(shadedFile); + + newShader().shade(request); + + final TestModuleTargetAttribute[] target = new TestModuleTargetAttribute[1]; + final TestModuleResolutionAttribute[] resolution = new TestModuleResolutionAttribute[1]; + final Set otherAttributes = new LinkedHashSet<>(); + try (JarFile shadedJar = new JarFile(shadedFile); + InputStream descriptor = shadedJar.getInputStream(shadedJar.getJarEntry("module-info.class"))) { + new ClassReader(descriptor) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public void visitAttribute(Attribute attribute) { + if (attribute instanceof TestModuleTargetAttribute) { + target[0] = (TestModuleTargetAttribute) attribute; + } else if (attribute instanceof TestModuleResolutionAttribute) { + resolution[0] = (TestModuleResolutionAttribute) attribute; + } else { + otherAttributes.add(attribute.type); + } + } + }, + new Attribute[] { + new TestModuleTargetAttribute(), + new TestModuleResolutionAttribute(), + new TestModuleHashesAttribute() + }, + 0); + } + + assertEquals("linux-amd64", target[0].targetPlatform); + assertEquals(1, resolution[0].resolutionFlags); + assertFalse(otherAttributes.contains("ModuleHashes")); + assertThat(warnMessages.getAllValues(), hasItem(containsString("Dropping ModuleHashes"))); + + temporaryFolder.delete(); + } + + private File createJar(File file, boolean multiRelease, String entryName) throws IOException { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + if (multiRelease) { + manifest.getMainAttributes().putValue("Multi-Release", "true"); + } + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(file.toPath()), manifest)) { + output.putNextEntry(new JarEntry(entryName)); + output.write(entryName.getBytes(StandardCharsets.UTF_8)); + } + return file; + } + + private File automaticModule(File file, String moduleName, String className, String referencedClass) + throws IOException { + Manifest manifest = automaticModuleManifest(moduleName, false); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(file.toPath()), manifest)) { + if (referencedClass == null) { + writeClass(output, className); + } else { + writeClassReferencing(output, className, referencedClass); + } + } + return file; + } + + private File automaticMultiReleaseModule(File file, int release, String className, String referencedClass) + throws IOException { + Manifest manifest = automaticModuleManifest("dep.auto", true); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(file.toPath()), manifest)) { + writeClassReferencing( + output, + "META-INF/versions/" + release + '/' + className + ".class", + className, + referencedClass, + release + 44); + } + return file; + } + + private Manifest automaticModuleManifest(String moduleName, boolean multiRelease) { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Automatic-Module-Name", moduleName); + if (multiRelease) { + manifest.getMainAttributes().putValue("Multi-Release", "true"); + } + return manifest; + } + + private ShadeRequest moduleMergeRequest( + File primary, File dependency, Set dependencyAnalysisArtifacts, File output) { + ShadeRequest request = new ShadeRequest(); + request.setJars(new LinkedHashSet<>(Arrays.asList(primary, dependency))); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setDependencyAnalysisArtifacts(dependencyAnalysisArtifacts); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.emptyList()); + request.setResourceTransformers(Collections.emptyList()); + request.setUberJar(output); + return request; + } + + private boolean isModularRuntime() { + return !System.getProperty("java.specification.version").startsWith("1."); + } + + private int runtimeFeature() { + String version = System.getProperty("java.specification.version"); + return Integer.parseInt(version.startsWith("1.") ? version.substring(2) : version); + } + + private void writeModuleDescriptor( + JarOutputStream output, + String moduleName, + String[] requires, + String[] exports, + String service, + String provider) + throws IOException { + writeModuleDescriptor( + output, "module-info.class", Opcodes.V9, moduleName, requires, null, exports, service, provider); + } + + @SuppressWarnings("checkstyle:ParameterNumber") + private void writeModuleDescriptor( + JarOutputStream output, + String entryName, + int classVersion, + String moduleName, + String[] requires, + String transitiveRequirement, + String[] exports, + String service, + String... providers) + throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit(classVersion, Opcodes.ACC_MODULE, "module-info", null, null, null); + ModuleVisitor module = writer.visitModule(moduleName, 0, null); + module.visitRequire("java.base", Opcodes.ACC_MANDATED, null); + for (String requirement : requires) { + module.visitRequire(requirement, 0, null); + } + if (transitiveRequirement != null) { + module.visitRequire(transitiveRequirement, Opcodes.ACC_TRANSITIVE, null); + } + for (String exportedPackage : exports) { + module.visitExport(exportedPackage, 0); + module.visitPackage(exportedPackage); + } + if (service != null) { + module.visitUse(service); + module.visitProvide(service, providers); + for (String provider : providers) { + module.visitPackage(provider.substring(0, provider.lastIndexOf('/'))); + } + } + module.visitEnd(); + writer.visitEnd(); + + output.putNextEntry(new JarEntry(entryName)); + output.write(writer.toByteArray()); + } + + private void writeClass(JarOutputStream output, String name) throws IOException { + writeClass(output, name + ".class", name, Opcodes.V9); + } + + private void writeClassReferencing(JarOutputStream output, String name, String referencedClass) throws IOException { + writeClassReferencing(output, name + ".class", name, referencedClass, Opcodes.V9); + } + + private void writeClassReferencing( + JarOutputStream output, String entryName, String name, String referencedClass, int classVersion) + throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit(classVersion, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + writer.visitField(Opcodes.ACC_PRIVATE, "reference", 'L' + referencedClass + ';', null, null) + .visitEnd(); + writer.visitEnd(); + output.putNextEntry(new JarEntry(entryName)); + output.write(writer.toByteArray()); + } + + private void writeServiceLoaderConsumer( + JarOutputStream output, String name, String firstService, String secondService) throws IOException { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V9, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "load", "(Z)V", null, null); + method.visitCode(); + Label second = new Label(); + Label join = new Label(); + method.visitVarInsn(Opcodes.ILOAD, 0); + method.visitJumpInsn(Opcodes.IFEQ, second); + method.visitLdcInsn(Type.getObjectType(firstService)); + method.visitJumpInsn(Opcodes.GOTO, join); + method.visitLabel(second); + method.visitLdcInsn(Type.getObjectType(secondService)); + method.visitLabel(join); + method.visitVarInsn(Opcodes.ASTORE, 1); + method.visitVarInsn(Opcodes.ALOAD, 1); + method.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/util/ServiceLoader", + "load", + "(Ljava/lang/Class;)Ljava/util/ServiceLoader;", + false); + method.visitInsn(Opcodes.POP); + method.visitLdcInsn(Type.getObjectType(firstService)); + method.visitInsn(Opcodes.ACONST_NULL); + method.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/util/ServiceLoader", + "load", + "(Ljava/lang/Class;Ljava/lang/ClassLoader;)Ljava/util/ServiceLoader;", + false); + method.visitInsn(Opcodes.POP); + method.visitInsn(Opcodes.ACONST_NULL); + method.visitLdcInsn(Type.getObjectType(secondService)); + method.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/util/ServiceLoader", + "load", + "(Ljava/lang/ModuleLayer;Ljava/lang/Class;)Ljava/util/ServiceLoader;", + false); + method.visitInsn(Opcodes.POP); + method.visitLdcInsn(Type.getObjectType(firstService)); + method.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/util/ServiceLoader", + "loadInstalled", + "(Ljava/lang/Class;)Ljava/util/ServiceLoader;", + false); + method.visitInsn(Opcodes.POP); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(0, 0); + method.visitEnd(); + writer.visitEnd(); + output.putNextEntry(new JarEntry(name + ".class")); + output.write(writer.toByteArray()); + } + + private void writeDynamicServiceLoaderConsumer(JarOutputStream output, String name) throws IOException { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V9, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + MethodVisitor method = + writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "load", "(Ljava/lang/Class;)V", null, null); + method.visitCode(); + method.visitVarInsn(Opcodes.ALOAD, 0); + method.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/util/ServiceLoader", + "load", + "(Ljava/lang/Class;)Ljava/util/ServiceLoader;", + false); + method.visitInsn(Opcodes.POP); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(0, 0); + method.visitEnd(); + writer.visitEnd(); + output.putNextEntry(new JarEntry(name + ".class")); + output.write(writer.toByteArray()); + } + + private void writeServiceLoaderMethodHandle(JarOutputStream output, String name) throws IOException { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V9, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + MethodVisitor method = + writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "reference", "()V", null, null); + method.visitCode(); + method.visitLdcInsn(new Handle( + Opcodes.H_INVOKESTATIC, + "java/util/ServiceLoader", + "load", + "(Ljava/lang/Class;)Ljava/util/ServiceLoader;", + false)); + method.visitInsn(Opcodes.POP); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(0, 0); + method.visitEnd(); + writer.visitEnd(); + output.putNextEntry(new JarEntry(name + ".class")); + output.write(writer.toByteArray()); + } + + private void writeServiceConfiguration(JarOutputStream output, String service, String... providers) + throws IOException { + output.putNextEntry(new JarEntry("META-INF/services/" + service)); + output.write((String.join("\n", providers) + '\n').getBytes(StandardCharsets.UTF_8)); + } + + private void writeClass(JarOutputStream output, String entryName, String name, int classVersion) + throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit(classVersion, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + writer.visitEnd(); + output.putNextEntry(new JarEntry(entryName)); + output.write(writer.toByteArray()); + } + + private Set readModuleRequirements(JarFile jar, String entryName, String expectedModuleName) + throws IOException { + Set requirements = new LinkedHashSet<>(); + JarEntry entry = requireNonNull(jar.getJarEntry(entryName), entryName + " in " + jar.getName()); + try (InputStream descriptor = jar.getInputStream(entry)) { + new ClassReader(descriptor) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + assertEquals(expectedModuleName, name); + return new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitRequire(String module, int access, String version) { + requirements.add(module); + } + }; + } + }, + 0); + } + return requirements; + } + + private Set readModulePackages(JarFile jar, String entryName, String expectedModuleName) + throws IOException { + Set packages = new LinkedHashSet<>(); + JarEntry entry = requireNonNull(jar.getJarEntry(entryName), entryName + " in " + jar.getName()); + try (InputStream descriptor = jar.getInputStream(entry)) { + new ClassReader(descriptor) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + assertEquals(expectedModuleName, name); + return new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitPackage(String packaze) { + packages.add(packaze); + } + }; + } + }, + 0); + } + return packages; + } + + private Set readModuleUses(JarFile jar, String entryName, String expectedModuleName) throws IOException { + Set uses = new LinkedHashSet<>(); + JarEntry entry = requireNonNull(jar.getJarEntry(entryName), entryName + " in " + jar.getName()); + try (InputStream descriptor = jar.getInputStream(entry)) { + new ClassReader(descriptor) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + assertEquals(expectedModuleName, name); + return new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitUse(String service) { + uses.add(service); + } + }; + } + }, + 0); + } + return uses; + } + + private Set readModuleProviders( + JarFile jar, String entryName, String expectedModuleName, String expectedService) throws IOException { + Set providers = new LinkedHashSet<>(); + JarEntry entry = requireNonNull(jar.getJarEntry(entryName), entryName + " in " + jar.getName()); + try (InputStream descriptor = jar.getInputStream(entry)) { + new ClassReader(descriptor) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + assertEquals(expectedModuleName, name); + return new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitProvide(String service, String... implementations) { + if (expectedService.equals(service)) { + providers.addAll(Arrays.asList(implementations)); + } + } + }; + } + }, + 0); + } + return providers; + } + @Test public void testShaderWithSmallEntries() throws Exception { File temporaryFolder = Files.createTempDirectory("junit").toFile(); @@ -587,6 +1795,75 @@ private void shaderWithPattern(String shadedPattern, File jar, String[] excludes s.shade(shadeRequest); } + private static final class TestModuleTargetAttribute extends Attribute { + private String targetPlatform; + + private TestModuleTargetAttribute() { + this(null); + } + + private TestModuleTargetAttribute(String targetPlatform) { + super("ModuleTarget"); + this.targetPlatform = targetPlatform; + } + + @Override + protected Attribute read( + ClassReader classReader, + int offset, + int length, + char[] charBuffer, + int codeAttributeOffset, + Label[] labels) { + return new TestModuleTargetAttribute(classReader.readUTF8(offset, charBuffer)); + } + + @Override + protected ByteVector write(ClassWriter classWriter, byte[] code, int codeLength, int maxStack, int maxLocals) { + return new ByteVector().putShort(classWriter.newUTF8(targetPlatform)); + } + } + + private static final class TestModuleResolutionAttribute extends Attribute { + private int resolutionFlags; + + private TestModuleResolutionAttribute() { + this(0); + } + + private TestModuleResolutionAttribute(int resolutionFlags) { + super("ModuleResolution"); + this.resolutionFlags = resolutionFlags; + } + + @Override + protected Attribute read( + ClassReader classReader, + int offset, + int length, + char[] charBuffer, + int codeAttributeOffset, + Label[] labels) { + return new TestModuleResolutionAttribute(classReader.readUnsignedShort(offset)); + } + + @Override + protected ByteVector write(ClassWriter classWriter, byte[] code, int codeLength, int maxStack, int maxLocals) { + return new ByteVector().putShort(resolutionFlags); + } + } + + private static final class TestModuleHashesAttribute extends Attribute { + private TestModuleHashesAttribute() { + super("ModuleHashes"); + } + + @Override + protected ByteVector write(ClassWriter classWriter, byte[] code, int codeLength, int maxStack, int maxLocals) { + return new ByteVector().putShort(classWriter.newUTF8("SHA-256")).putShort(0); + } + } + private DefaultShader newShader() { return new DefaultShader(mockLogger()); } @@ -603,6 +1880,7 @@ private Logger mockLogger() { when(logger.isWarnEnabled()).thenReturn(true); doNothing().when(logger).debug(debugMessages.capture()); doNothing().when(logger).warn(warnMessages.capture()); + doNothing().when(logger).warn(warnMessages.capture(), any(Object.class)); return logger; } diff --git a/src/test/java/org/apache/maven/plugins/shade/ModuleInfoConfigurationTest.java b/src/test/java/org/apache/maven/plugins/shade/ModuleInfoConfigurationTest.java new file mode 100644 index 00000000..db6431e8 --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/shade/ModuleInfoConfigurationTest.java @@ -0,0 +1,630 @@ +/* + * 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.shade; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.shade.filter.Filter; +import org.apache.maven.plugins.shade.relocation.SimpleRelocator; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.ModuleVisitor; +import org.objectweb.asm.Opcodes; +import org.slf4j.Logger; + +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ModuleInfoConfigurationTest { + @TempDir + File temporaryFolder; + + @Test + public void overridesOutputModuleNameAndRemovesOriginalSelfRequirement() throws Exception { + File primary = primaryModule("dep.module"); + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()))) { + writeDescriptor(output, "dep.module", new String[] {"app.module"}, null); + } + + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setModuleName("shaded.app.module"); + File shadedFile = newFile("shaded.jar"); + ShadeRequest request = moduleRequest(primary, shadedFile, dependency); + request.setModuleInfoConfiguration(configuration); + + shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals("shaded.app.module", readModuleName(shadedJar)); + assertEquals( + "shaded.app.module", + shadedJar.getManifest().getMainAttributes().getValue("Automatic-Module-Name")); + Set requirements = + readRequirementAccess(shadedJar, "shaded.app.module").keySet(); + assertFalse(requirements.contains("app.module")); + assertFalse(requirements.contains("dep.module")); + } + } + + @Test + public void rejectsInvalidOutputModuleNames() throws Exception { + File primary = primaryModule(); + int index = 0; + for (String invalid : Arrays.asList("", "bad-name", "int.module", "_")) { + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setModuleName(invalid); + ShadeRequest request = moduleRequest(primary, newFile("invalid-" + index++ + ".jar")); + request.setModuleInfoConfiguration(configuration); + + MojoExecutionException exception = assertThrows(MojoExecutionException.class, () -> shade(request)); + assertTrue(exception.getMessage().contains("Invalid moduleInfo.moduleName '" + invalid + "'")); + } + } + + @Test + public void mergesExplicitAndAutomaticModuleBoundariesWhenRequested() throws Exception { + Assumptions.assumeFalse(System.getProperty("java.specification.version").startsWith("1.")); + + File primary = primaryModule("dep.module", "automatic.module"); + + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()))) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V9, Opcodes.ACC_MODULE, "module-info", null, null, null); + ModuleVisitor module = writer.visitModule("dep.module", 0, null); + module.visitRequire("java.base", Opcodes.ACC_MANDATED, null); + module.visitExport("dep/api", 0); + module.visitOpen("dep/reflect", 0); + module.visitPackage("dep/api"); + module.visitPackage("dep/reflect"); + module.visitEnd(); + writer.visitEnd(); + writeEntry(output, "module-info.class", writer.toByteArray()); + writeClass(output, "dep/api/Dependency"); + writeClass(output, "dep/reflect/ReflectiveType"); + writeClass(output, "dep/internal/HiddenType"); + } + + File automatic = newFile("automatic.jar"); + Manifest manifest = automaticModuleManifest("automatic.module"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(automatic.toPath()), manifest)) { + writeClass(output, "automatic/api/PublicType"); + } + + File shadedFile = newFile("shaded.jar"); + ShadeRequest request = moduleRequest(primary, shadedFile, dependency, automatic); + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setPublicBoundary("merge"); + request.setModuleInfoConfiguration(configuration); + request.setRelocators(Collections.singletonList(new SimpleRelocator("dep", "hidden.dep", null, null))); + + shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals( + new LinkedHashSet<>(Arrays.asList("app/api", "automatic/api", "hidden/dep/api")), + readPackages(shadedJar, Directive.EXPORT)); + assertEquals( + new LinkedHashSet<>(Arrays.asList("automatic/api", "hidden/dep/reflect")), + readPackages(shadedJar, Directive.OPEN)); + assertFalse(readPackages(shadedJar, Directive.EXPORT).contains("hidden/dep/internal")); + } + } + + @Test + public void downgradesEmbeddedTransitiveRequirementsUnlessExplicitlyPromoted() throws Exception { + File primary = newFile("primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeDescriptor(output, "app.module", new String[] {"dep.module"}, "external.primary", "app/api"); + writeClass(output, "app/api/App"); + } + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()))) { + writeDescriptor(output, "dep.module", new String[0], "external.embedded"); + } + + File defaultOutput = newFile("default.jar"); + ShadeRequest request = moduleRequest(primary, defaultOutput, dependency); + shade(request); + + try (JarFile shadedJar = new JarFile(defaultOutput)) { + Map access = readRequirementAccess(shadedJar); + assertTrue((access.get("external.primary") & Opcodes.ACC_TRANSITIVE) != 0); + assertEquals(0, access.get("external.embedded") & Opcodes.ACC_TRANSITIVE); + } + + ModuleInfoConfiguration.Requirement promotion = new ModuleInfoConfiguration.Requirement(); + promotion.setModule("external.embedded"); + promotion.setTransitive(true); + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setAdditionalRequires(Collections.singletonList(promotion)); + request.setModuleInfoConfiguration(configuration); + File promotedOutput = newFile("promoted.jar"); + request.setUberJar(promotedOutput); + shade(request); + + try (JarFile shadedJar = new JarFile(promotedOutput)) { + assertTrue((readRequirementAccess(shadedJar).get("external.embedded") & Opcodes.ACC_TRANSITIVE) != 0); + } + } + + @Test + public void appliesDescriptorOverridesAndAcknowledgesDynamicServiceUses() throws Exception { + Assumptions.assumeFalse(System.getProperty("java.specification.version").startsWith("1.")); + + File primary = newFile("primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeDescriptor(output, "app.module", new String[] {"dep.auto"}, null); + writeClass(output, "app/internal/InternalType"); + } + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), automaticModuleManifest("dep.auto"))) { + writeDynamicServiceLoaderConsumer(output, "dep/DynamicConsumer"); + } + File external = newFile("external.jar"); + try (JarOutputStream output = new JarOutputStream( + Files.newOutputStream(external.toPath()), automaticModuleManifest("external.module"))) { + writeClass(output, "spi/DynamicService"); + } + + ModuleInfoConfiguration.PackageDirective additionalExport = new ModuleInfoConfiguration.PackageDirective(); + additionalExport.setPackageName("app.internal"); + ModuleInfoConfiguration.PackageDirective additionalOpen = new ModuleInfoConfiguration.PackageDirective(); + additionalOpen.setPackageName("app.internal"); + ModuleInfoConfiguration.Requirement additionalRequire = new ModuleInfoConfiguration.Requirement(); + additionalRequire.setModule("external.optional"); + additionalRequire.setStaticRequirement(true); + additionalRequire.setTransitive(true); + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setAdditionalExports(Collections.singletonList(additionalExport)); + configuration.setAdditionalOpens(Collections.singletonList(additionalOpen)); + configuration.setAdditionalRequires(Collections.singletonList(additionalRequire)); + configuration.setDynamicUses(Collections.singleton("spi.DynamicService")); + + File shadedFile = newFile("shaded.jar"); + ShadeRequest request = moduleRequest(primary, shadedFile, dependency); + request.setDependencyAnalysisArtifacts(Collections.singleton(external)); + request.setModuleInfoConfiguration(configuration); + shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals(Collections.singleton("app/internal"), readPackages(shadedJar, Directive.EXPORT)); + assertEquals(Collections.singleton("app/internal"), readPackages(shadedJar, Directive.OPEN)); + assertEquals(Collections.singleton("spi/DynamicService"), readUses(shadedJar)); + int access = readRequirementAccess(shadedJar).get("external.optional"); + assertTrue((access & Opcodes.ACC_STATIC_PHASE) != 0); + assertTrue((access & Opcodes.ACC_TRANSITIVE) != 0); + assertTrue(readRequirementAccess(shadedJar).containsKey("external.module")); + } + } + + @Test + public void keepsExternalRequirementMatchingInactiveAutomaticModuleName() throws Exception { + File primary = primaryModule("embedded.real", "dormant"); + File dependency = newFile("dormant.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()))) { + writeDescriptor(output, "embedded.real", new String[0], null); + } + + ModuleInfoConfiguration.Requirement external = new ModuleInfoConfiguration.Requirement(); + external.setModule("dormant"); + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setAdditionalRequires(Collections.singletonList(external)); + + File shadedFile = newFile("shaded.jar"); + ShadeRequest request = moduleRequest(primary, shadedFile, dependency); + request.setModuleInfoConfiguration(configuration); + + shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + Set requirements = readRequirementAccess(shadedJar).keySet(); + assertTrue(requirements.contains("dormant")); + assertFalse(requirements.contains("embedded.real")); + } + } + + @Test + public void openPrimaryModuleDoesNotEmitExplicitOpens() throws Exception { + Assumptions.assumeFalse(System.getProperty("java.specification.version").startsWith("1.")); + + File primary = newFile("primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeDescriptor( + output, + "module-info.class", + Opcodes.ACC_OPEN, + "app.module", + new String[] {"dep.auto"}, + null, + "app/api"); + writeClass(output, "app/api/App"); + } + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), automaticModuleManifest("dep.auto"))) { + writeClass(output, "dep/internal/Dependency"); + } + + ModuleInfoConfiguration.PackageDirective additionalOpen = new ModuleInfoConfiguration.PackageDirective(); + additionalOpen.setPackageName("app.api"); + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setPublicBoundary("merge"); + configuration.setAdditionalOpens(Collections.singletonList(additionalOpen)); + + File shadedFile = newFile("shaded.jar"); + ShadeRequest request = moduleRequest(primary, shadedFile, dependency); + request.setModuleInfoConfiguration(configuration); + shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertTrue((readModuleAccess(shadedJar) & Opcodes.ACC_OPEN) != 0); + assertTrue(readPackages(shadedJar, Directive.OPEN).isEmpty()); + } + assertModuleFinderAccepts(shadedFile); + } + + @Test + public void embeddedOpenModuleContributesAllPackagesAsOpens() throws Exception { + File primary = primaryModule("embedded.open"); + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(dependency.toPath()))) { + writeDescriptor(output, "module-info.class", Opcodes.ACC_OPEN, "embedded.open", new String[0], null); + writeClass(output, "dep/api/PublicType"); + writeClass(output, "dep/internal/HiddenType"); + } + + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setPublicBoundary("merge"); + + File shadedFile = newFile("shaded.jar"); + ShadeRequest request = moduleRequest(primary, shadedFile, dependency); + request.setModuleInfoConfiguration(configuration); + shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + assertEquals( + new LinkedHashSet<>(Arrays.asList("dep/api", "dep/internal")), + readPackages(shadedJar, Directive.OPEN)); + } + } + + @Test + public void analyzesClassesWhenEmbeddedDescriptorIsFiltered() throws Exception { + Assumptions.assumeFalse(System.getProperty("java.specification.version").startsWith("1.")); + + File primary = primaryModule("dep.module"); + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), automaticModuleManifest("dep.auto"))) { + writeDescriptor(output, "dep.module", new String[0], null); + writeClassReferencing(output, "dep/Dependency", "java/sql/Driver"); + } + + Filter filter = mock(Filter.class); + when(filter.canFilter(dependency)).thenReturn(true); + when(filter.isFiltered("module-info.class")).thenReturn(true); + + File shadedFile = newFile("shaded.jar"); + ShadeRequest request = moduleRequest(primary, shadedFile, dependency); + request.setFilters(Collections.singletonList(filter)); + shade(request); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + Set requirements = readRequirementAccess(shadedJar).keySet(); + assertTrue(requirements.contains("java.sql")); + assertFalse(requirements.contains("dep.module")); + } + } + + @Test + public void analyzesClassesWhenVersionedDescriptorIsNotMultiRelease() throws Exception { + Assumptions.assumeFalse(System.getProperty("java.specification.version").startsWith("1.")); + + File primary = primaryModule("dep.auto"); + File dependency = newFile("dependency.jar"); + try (JarOutputStream output = + new JarOutputStream(Files.newOutputStream(dependency.toPath()), automaticModuleManifest("dep.auto"))) { + writeDescriptor(output, "META-INF/versions/9/module-info.class", "inactive.module", new String[0], null); + writeClassReferencing(output, "dep/Dependency", "java/sql/Driver"); + } + + File shadedFile = newFile("shaded.jar"); + shade(moduleRequest(primary, shadedFile, dependency)); + + try (JarFile shadedJar = new JarFile(shadedFile)) { + Set requirements = readRequirementAccess(shadedJar).keySet(); + assertTrue(requirements.contains("java.sql")); + assertFalse(requirements.contains("dep.auto")); + } + } + + private File primaryModule(String... dependencies) throws IOException { + File primary = newFile("primary.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(primary.toPath()))) { + writeDescriptor(output, "app.module", dependencies, null, "app/api"); + writeClass(output, "app/api/App"); + } + return primary; + } + + private File newFile(String name) throws IOException { + Path file = temporaryFolder.toPath().resolve(name); + Files.createFile(file); + return file.toFile(); + } + + private ShadeRequest moduleRequest(File primary, File output, File... dependencies) { + Set jars = new LinkedHashSet<>(); + jars.add(primary); + jars.addAll(Arrays.asList(dependencies)); + ShadeRequest request = new ShadeRequest(); + request.setJars(jars); + request.setPrimaryArtifact(primary); + request.setModuleInfoMode(ModuleInfoMode.MERGE); + request.setFilters(Collections.emptyList()); + request.setRelocators(Collections.emptyList()); + request.setResourceTransformers(Collections.emptyList()); + request.setDependencyAnalysisArtifacts(Collections.emptySet()); + request.setUberJar(output); + return request; + } + + private void shade(ShadeRequest request) throws Exception { + new DefaultShader(mock(Logger.class)).shade(request); + } + + private static void writeDescriptor( + JarOutputStream output, String moduleName, String[] requires, String transitive, String... exports) + throws IOException { + writeDescriptor(output, "module-info.class", moduleName, requires, transitive, exports); + } + + private static void writeDescriptor( + JarOutputStream output, + String entryName, + String moduleName, + String[] requires, + String transitive, + String... exports) + throws IOException { + writeDescriptor(output, entryName, 0, moduleName, requires, transitive, exports); + } + + private static void writeDescriptor( + JarOutputStream output, + String entryName, + int moduleAccess, + String moduleName, + String[] requires, + String transitive, + String... exports) + throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V9, Opcodes.ACC_MODULE, "module-info", null, null, null); + ModuleVisitor module = writer.visitModule(moduleName, moduleAccess, null); + module.visitRequire("java.base", Opcodes.ACC_MANDATED, null); + for (String requirement : requires) { + module.visitRequire(requirement, 0, null); + } + if (transitive != null) { + module.visitRequire(transitive, Opcodes.ACC_TRANSITIVE, null); + } + for (String packaze : exports) { + module.visitExport(packaze, 0); + module.visitPackage(packaze); + } + module.visitEnd(); + writer.visitEnd(); + writeEntry(output, entryName, writer.toByteArray()); + } + + private static void writeClass(JarOutputStream output, String name) throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V9, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + writer.visitEnd(); + writeEntry(output, name + ".class", writer.toByteArray()); + } + + private static void writeClassReferencing(JarOutputStream output, String name, String referencedClass) + throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V9, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + writer.visitField(Opcodes.ACC_PRIVATE, "dependency", 'L' + referencedClass + ';', null, null) + .visitEnd(); + writer.visitEnd(); + writeEntry(output, name + ".class", writer.toByteArray()); + } + + private static void writeDynamicServiceLoaderConsumer(JarOutputStream output, String name) throws IOException { + ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(Opcodes.V9, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + MethodVisitor method = + writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "load", "(Ljava/lang/Class;)V", null, null); + method.visitCode(); + method.visitVarInsn(Opcodes.ALOAD, 0); + method.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/util/ServiceLoader", + "load", + "(Ljava/lang/Class;)Ljava/util/ServiceLoader;", + false); + method.visitInsn(Opcodes.POP); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(0, 0); + method.visitEnd(); + writer.visitEnd(); + writeEntry(output, name + ".class", writer.toByteArray()); + } + + private static Manifest automaticModuleManifest(String name) { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Automatic-Module-Name", name); + return manifest; + } + + private static void writeEntry(JarOutputStream output, String name, byte[] bytes) throws IOException { + output.putNextEntry(new JarEntry(name)); + output.write(bytes); + output.closeEntry(); + } + + private static Set readPackages(JarFile jar, Directive directive) throws IOException { + Set packages = new LinkedHashSet<>(); + readModule(jar, new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitExport(String packaze, int access, String... modules) { + if (directive == Directive.EXPORT) { + packages.add(packaze); + } + } + + @Override + public void visitOpen(String packaze, int access, String... modules) { + if (directive == Directive.OPEN) { + packages.add(packaze); + } + } + }); + return packages; + } + + private static int readModuleAccess(JarFile jar) throws IOException { + int[] moduleAccess = new int[1]; + readModule(jar, new ModuleVisitor(Opcodes.ASM9) {}, moduleAccess); + return moduleAccess[0]; + } + + private static void assertModuleFinderAccepts(File jar) throws Exception { + Class moduleFinder = Class.forName("java.lang.module.ModuleFinder"); + Object finder = moduleFinder.getMethod("of", Path[].class).invoke(null, (Object) new Path[] {jar.toPath()}); + moduleFinder.getMethod("findAll").invoke(finder); + } + + private static Set readUses(JarFile jar) throws IOException { + Set uses = new LinkedHashSet<>(); + readModule(jar, new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitUse(String service) { + uses.add(service); + } + }); + return uses; + } + + private static Map readRequirementAccess(JarFile jar) throws IOException { + return readRequirementAccess(jar, "app.module"); + } + + private static Map readRequirementAccess(JarFile jar, String expectedModuleName) + throws IOException { + Map requirements = new HashMap<>(); + readModule(jar, expectedModuleName, new ModuleVisitor(Opcodes.ASM9) { + @Override + public void visitRequire(String module, int access, String version) { + requirements.put(module, access); + } + }); + return requirements; + } + + private static void readModule(JarFile jar, ModuleVisitor visitor) throws IOException { + readModule(jar, "app.module", visitor, null); + } + + private static void readModule(JarFile jar, ModuleVisitor visitor, int[] moduleAccess) throws IOException { + readModule(jar, "app.module", visitor, moduleAccess); + } + + private static void readModule(JarFile jar, String expectedModuleName, ModuleVisitor visitor) throws IOException { + readModule(jar, expectedModuleName, visitor, null); + } + + private static void readModule(JarFile jar, String expectedModuleName, ModuleVisitor visitor, int[] moduleAccess) + throws IOException { + JarEntry entry = requireNonNull(jar.getJarEntry("module-info.class"), "module-info.class in " + jar.getName()); + try (InputStream input = jar.getInputStream(entry)) { + new ClassReader(input) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + assertEquals(expectedModuleName, name); + if (moduleAccess != null) { + moduleAccess[0] = access; + } + return visitor; + } + }, + 0); + } + } + + private static String readModuleName(JarFile jar) throws IOException { + String[] moduleName = new String[1]; + JarEntry entry = requireNonNull(jar.getJarEntry("module-info.class"), "module-info.class in " + jar.getName()); + try (InputStream input = jar.getInputStream(entry)) { + new ClassReader(input) + .accept( + new ClassVisitor(Opcodes.ASM9) { + @Override + public ModuleVisitor visitModule(String name, int access, String version) { + moduleName[0] = name; + return null; + } + }, + 0); + } + return moduleName[0]; + } + + private enum Directive { + EXPORT, + OPEN + } +} diff --git a/src/test/java/org/apache/maven/plugins/shade/ModuleInfoModeTest.java b/src/test/java/org/apache/maven/plugins/shade/ModuleInfoModeTest.java new file mode 100644 index 00000000..afd16bad --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/shade/ModuleInfoModeTest.java @@ -0,0 +1,47 @@ +/* + * 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.shade; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ModuleInfoModeTest { + @Test + void shouldParseModesWithoutRegardToCase() { + assertEquals(ModuleInfoMode.DISCARD, ModuleInfoMode.fromString("discard")); + assertEquals(ModuleInfoMode.MERGE, ModuleInfoMode.fromString("MeRgE")); + assertEquals(ModuleInfoPublicBoundary.PRIMARY, ModuleInfoPublicBoundary.fromString("primary")); + assertEquals(ModuleInfoPublicBoundary.MERGE, ModuleInfoPublicBoundary.fromString("MeRgE")); + } + + @Test + void shouldRejectUnknownModes() { + IllegalArgumentException modeException = + assertThrows(IllegalArgumentException.class, () -> ModuleInfoMode.fromString("keep")); + assertEquals("Unknown module info mode 'keep'. Expected one of: discard, merge.", modeException.getMessage()); + + IllegalArgumentException boundaryException = + assertThrows(IllegalArgumentException.class, () -> ModuleInfoPublicBoundary.fromString("all")); + assertEquals( + "Unknown module info public boundary 'all'. Expected one of: primary, merge.", + boundaryException.getMessage()); + } +} diff --git a/src/test/java/org/apache/maven/plugins/shade/PlatformModuleIndexTest.java b/src/test/java/org/apache/maven/plugins/shade/PlatformModuleIndexTest.java new file mode 100644 index 00000000..ea74fc4d --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/shade/PlatformModuleIndexTest.java @@ -0,0 +1,124 @@ +/* + * 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.shade; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Collections; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.apache.maven.plugin.MojoExecutionException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.ModuleVisitor; +import org.objectweb.asm.Opcodes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PlatformModuleIndexTest { + @TempDir + File temporaryFolder; + + @Test + public void indexesHistoricalAndCurrentPlatformOwnership() throws Exception { + File jdkHome = syntheticJdk(); + + PlatformModuleIndex index = new PlatformModuleIndex(jdkHome); + + assertEquals(21, index.getRelease()); + assertEquals(singletonSortedSet("java.sql"), index.findOwners("java/sql/Connection", 9)); + assertEquals(singletonSortedSet("java.xml"), index.findOwners("javax/xml/parsers/Parser", 10)); + assertTrue(index.findOwners("java/sql/Connection", 10).isEmpty()); + assertEquals(singletonSortedSet("java.base"), index.findOwners("java/lang/Object", 21)); + assertTrue(index.hasModule("java.sql", 9)); + assertTrue(index.hasModule("java.xml", 10)); + assertFalse(index.hasModule("java.xml", 9)); + assertTrue(index.hasModule("java.base", 21)); + } + + @Test + public void rejectsJdkWithoutHistoricalPlatformSignatures() throws Exception { + File jdkHome = newFolder("jdk"); + File jmods = new File(jdkHome, "jmods"); + assertTrue(jmods.mkdirs()); + writeJmod(new File(jmods, "java.base.jmod"), "java.base", Opcodes.V21, "java/lang/Object"); + + MojoExecutionException exception = + assertThrows(MojoExecutionException.class, () -> new PlatformModuleIndex(jdkHome)); + + assertTrue(exception.getMessage().contains("missing lib/ct.sym")); + assertTrue(exception.getMessage().contains(jdkHome.getAbsolutePath())); + } + + private File syntheticJdk() throws IOException { + File jdkHome = newFolder("jdk"); + File jmods = new File(jdkHome, "jmods"); + File lib = new File(jdkHome, "lib"); + assertTrue(jmods.mkdirs()); + assertTrue(lib.mkdirs()); + + writeJmod(new File(jmods, "java.base.jmod"), "java.base", Opcodes.V21, "java/lang/Object"); + writeJmod(new File(jmods, "java.sql.jmod"), "java.sql", Opcodes.V21, "java/sql/Connection"); + + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(new File(lib, "ct.sym").toPath()))) { + writeEmptyEntry(output, "9/java.sql/module-info.sig"); + writeEmptyEntry(output, "9/java.sql/java/sql/Connection.sig"); + writeEmptyEntry(output, "A/java.xml/module-info.sig"); + writeEmptyEntry(output, "A/java.xml/javax/xml/parsers/Parser.sig"); + } + return jdkHome; + } + + private File newFolder(String name) throws IOException { + return Files.createDirectory(temporaryFolder.toPath().resolve(name)).toFile(); + } + + private static void writeJmod(File file, String moduleName, int classVersion, String className) throws IOException { + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(file.toPath()))) { + ClassWriter writer = new ClassWriter(0); + writer.visit(classVersion, Opcodes.ACC_MODULE, "module-info", null, null, null); + ModuleVisitor module = writer.visitModule(moduleName, 0, null); + module.visitEnd(); + writer.visitEnd(); + output.putNextEntry(new JarEntry("classes/module-info.class")); + output.write(writer.toByteArray()); + output.closeEntry(); + + writeEmptyEntry(output, "classes/" + className + ".class"); + } + } + + private static void writeEmptyEntry(JarOutputStream output, String name) throws IOException { + output.putNextEntry(new JarEntry(name)); + output.write(new byte[] {0}); + output.closeEntry(); + } + + private static SortedSet singletonSortedSet(String value) { + return new TreeSet<>(Collections.singleton(value)); + } +} diff --git a/src/test/java/org/apache/maven/plugins/shade/mojo/ShadeMojoTest.java b/src/test/java/org/apache/maven/plugins/shade/mojo/ShadeMojoTest.java index 31b596b8..b7110a65 100644 --- a/src/test/java/org/apache/maven/plugins/shade/mojo/ShadeMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/shade/mojo/ShadeMojoTest.java @@ -20,6 +20,7 @@ import java.io.File; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; @@ -38,6 +39,8 @@ import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.testing.AbstractMojoTestCase; +import org.apache.maven.plugins.shade.ModuleInfoConfiguration; +import org.apache.maven.plugins.shade.ModuleInfoMode; import org.apache.maven.plugins.shade.ShadeRequest; import org.apache.maven.plugins.shade.Shader; import org.apache.maven.plugins.shade.filter.Filter; @@ -48,6 +51,8 @@ import org.apache.maven.plugins.shade.resource.ResourceTransformer; import org.apache.maven.project.MavenProject; import org.apache.maven.repository.internal.MavenRepositorySystemUtils; +import org.apache.maven.toolchain.Toolchain; +import org.apache.maven.toolchain.ToolchainManager; import org.codehaus.plexus.ContainerConfiguration; import org.codehaus.plexus.PlexusConstants; import org.eclipse.aether.DefaultRepositorySystemSession; @@ -62,6 +67,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -107,6 +113,70 @@ public String toString() { assertEquals(asList(testsTfr1, testsTfr2), m.invoke(mojo, "tests", asList(testsTfr1, testsTfr2, defaultTfr))); } + public void testSourceRequestsDiscardModuleInfo() throws Exception { + ShadeMojo mojo = new ShadeMojo(); + setVariableValueToObject(mojo, "moduleInfoMode", "mErGe"); + Method method = ShadeMojo.class.getDeclaredMethod( + "createShadeSourcesRequest", String.class, Set.class, File.class, List.class, List.class, List.class); + method.setAccessible(true); + + ShadeRequest request = (ShadeRequest) method.invoke( + mojo, + "sources-jar", + Collections.emptySet(), + new File("target/sources.jar"), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + + assertEquals(ModuleInfoMode.DISCARD, request.getModuleInfoMode()); + assertTrue(request.getDependencyAnalysisArtifacts().isEmpty()); + } + + public void testSelectsConfiguredModuleInfoAnalysisJdkToolchain() throws Exception { + ShadeMojo mojo = new ShadeMojo(); + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setAnalysisJdkToolchain(Collections.singletonMap("version", "21")); + MavenSession session = mock(MavenSession.class); + ToolchainManager manager = mock(ToolchainManager.class); + Toolchain toolchain = mock(Toolchain.class); + File javaHome = new File(System.getProperty("java.home")).getCanonicalFile(); + when(manager.getToolchains(session, "jdk", configuration.getAnalysisJdkToolchain())) + .thenReturn(Collections.singletonList(toolchain)); + when(toolchain.findTool("javac")).thenReturn(new File(new File(javaHome, "bin"), "javac").getPath()); + setVariableValueToObject(mojo, "moduleInfo", configuration); + setVariableValueToObject(mojo, "session", session); + setVariableValueToObject(mojo, "toolchainManager", manager); + + Method method = ShadeMojo.class.getDeclaredMethod("resolveModuleInfoAnalysisJdkHome"); + method.setAccessible(true); + assertEquals(javaHome, method.invoke(mojo)); + verify(manager).getToolchains(session, "jdk", configuration.getAnalysisJdkToolchain()); + } + + public void testRejectsMissingModuleInfoAnalysisJdkToolchain() throws Exception { + ShadeMojo mojo = new ShadeMojo(); + ModuleInfoConfiguration configuration = new ModuleInfoConfiguration(); + configuration.setAnalysisJdkToolchain(Collections.singletonMap("version", "99")); + MavenSession session = mock(MavenSession.class); + ToolchainManager manager = mock(ToolchainManager.class); + when(manager.getToolchains(session, "jdk", configuration.getAnalysisJdkToolchain())) + .thenReturn(Collections.emptyList()); + setVariableValueToObject(mojo, "moduleInfo", configuration); + setVariableValueToObject(mojo, "session", session); + setVariableValueToObject(mojo, "toolchainManager", manager); + + Method method = ShadeMojo.class.getDeclaredMethod("resolveModuleInfoAnalysisJdkHome"); + method.setAccessible(true); + try { + method.invoke(mojo); + fail("Expected a missing toolchain failure"); + } catch (InvocationTargetException e) { + assertTrue(e.getCause().getMessage().contains("No JDK toolchain matches")); + assertTrue(e.getCause().getMessage().contains("version=99")); + } + } + public void testShaderWithDefaultShadedPattern() throws Exception { shaderWithPattern(null, new File("target/foo-default.jar")); } diff --git a/src/test/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformerTest.java b/src/test/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformerTest.java index 211b015c..c49ef922 100644 --- a/src/test/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformerTest.java +++ b/src/test/java/org/apache/maven/plugins/shade/resource/ManifestResourceTransformerTest.java @@ -37,6 +37,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; public class ManifestResourceTransformerTest { private ManifestResourceTransformer transformer; @@ -168,6 +169,30 @@ public void rewriteAdditionalAttributes() throws Exception { } } + @Test + public void forcedModuleAttributesApplyToOneOutputOnly() throws Exception { + transformer.setForceAutomaticModuleName("example.module"); + transformer.setForceMultiRelease(true); + + Attributes first = writeManifest().getMainAttributes(); + assertEquals("example.module", first.getValue("Automatic-Module-Name")); + assertEquals("true", first.getValue("Multi-Release")); + + Attributes second = writeManifest().getMainAttributes(); + assertNull(second.getValue("Automatic-Module-Name")); + assertNull(second.getValue("Multi-Release")); + } + + private Manifest writeManifest() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (JarOutputStream jarOutputStream = new JarOutputStream(out)) { + transformer.modifyOutputStream(jarOutputStream); + } + try (JarInputStream jarInputStream = new JarInputStream(new ByteArrayInputStream(out.toByteArray()))) { + return jarInputStream.getManifest(); + } + } + private ByteArrayOutputStream transform(final Manifest manifest, List relocators) throws IOException { final ByteArrayOutputStream mboas = new ByteArrayOutputStream(); try (OutputStream mos = mboas) {