View Javadoc
1   /**
2    *
3    * Copyright 2018 The Apache Software Foundation
4    *
5    * Licensed under the Apache License, Version 2.0 (the "License");
6    * you may not use this file except in compliance with the License.
7    * You may obtain a copy of the License at
8    *
9    * http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.codehaus.plexus.archiver.jar;
18  
19  import javax.inject.Named;
20  
21  import java.io.File;
22  import java.io.IOException;
23  import java.io.PrintStream;
24  import java.lang.reflect.Method;
25  import java.nio.file.Files;
26  import java.nio.file.Path;
27  import java.nio.file.StandardCopyOption;
28  import java.nio.file.attribute.FileTime;
29  import java.util.ArrayList;
30  import java.util.Calendar;
31  import java.util.Enumeration;
32  import java.util.List;
33  import java.util.Locale;
34  import java.util.TimeZone;
35  import java.util.regex.Pattern;
36  import java.util.zip.ZipEntry;
37  import java.util.zip.ZipFile;
38  import java.util.zip.ZipOutputStream;
39  
40  import org.apache.commons.compress.parallel.InputStreamSupplier;
41  import org.apache.commons.io.output.NullPrintStream;
42  import org.codehaus.plexus.archiver.ArchiverException;
43  import org.codehaus.plexus.archiver.zip.ConcurrentJarCreator;
44  import org.codehaus.plexus.util.IOUtil;
45  
46  /**
47   * A {@link ModularJarArchiver} implementation that uses
48   * the {@code jar} tool provided by
49   * {@code java.util.spi.ToolProvider} to create
50   * modular JAR files.
51   *
52   * <p>
53   * The basic JAR archive is created by {@link JarArchiver}
54   * and the {@code jar} tool is used to upgrade it to modular JAR.
55   *
56   * <p>
57   * If the JAR file does not contain module descriptor
58   * or the JDK does not provide the {@code jar} tool
59   * (for example JDK prior to Java 9), then the
60   * archive created by {@link JarArchiver}
61   * is left unchanged.
62   */
63  @Named("mjar")
64  public class JarToolModularJarArchiver extends ModularJarArchiver {
65      private static final String MODULE_DESCRIPTOR_FILE_NAME = "module-info.class";
66  
67      private static final Pattern MRJAR_VERSION_AREA = Pattern.compile("META-INF/versions/\\d+/");
68  
69      private Object jarTool;
70  
71      private boolean moduleDescriptorFound;
72  
73      private boolean hasJarDateOption;
74  
75      public JarToolModularJarArchiver() {
76          try {
77              Class<?> toolProviderClass = Class.forName("java.util.spi.ToolProvider");
78              Object jarToolOptional =
79                      toolProviderClass.getMethod("findFirst", String.class).invoke(null, "jar");
80  
81              jarTool = jarToolOptional.getClass().getMethod("get").invoke(jarToolOptional);
82          } catch (ReflectiveOperationException | SecurityException e) {
83              // Ignore. It is expected that the jar tool
84              // may not be available.
85          }
86      }
87  
88      @Override
89      protected void zipFile(
90              InputStreamSupplier is,
91              ConcurrentJarCreator zOut,
92              String vPath,
93              long lastModified,
94              File fromArchive,
95              int mode,
96              String symlinkDestination,
97              boolean addInParallel)
98              throws IOException, ArchiverException {
99          if (jarTool != null && isModuleDescriptor(vPath)) {
100             getLogger().debug("Module descriptor found: " + vPath);
101 
102             moduleDescriptorFound = true;
103         }
104 
105         super.zipFile(is, zOut, vPath, lastModified, fromArchive, mode, symlinkDestination, addInParallel);
106     }
107 
108     @Override
109     protected void postCreateArchive() throws ArchiverException {
110         if (!moduleDescriptorFound) {
111             // no need to update the JAR archive
112             return;
113         }
114 
115         try {
116             getLogger().debug("Using the jar tool to " + "update the archive to modular JAR.");
117 
118             final Method jarRun =
119                     jarTool.getClass().getMethod("run", PrintStream.class, PrintStream.class, String[].class);
120 
121             if (getLastModifiedTime() != null) {
122                 hasJarDateOption = isJarDateOptionSupported(jarRun);
123                 getLogger().debug("jar tool --date option is supported: " + hasJarDateOption);
124             }
125 
126             Integer result = (Integer) jarRun.invoke(jarTool, System.out, System.err, getJarToolArguments());
127 
128             if (result != null && result != 0) {
129                 throw new ArchiverException(
130                         "Could not create modular JAR file. " + "The JDK jar tool exited with " + result);
131             }
132 
133             if (!hasJarDateOption && getLastModifiedTime() != null) {
134                 getLogger().debug("Fix last modified time zip entries.");
135                 // --date option not supported, fallback to rewrite the JAR file
136                 // https://github.com/codehaus-plexus/plexus-archiver/issues/164
137                 fixLastModifiedTimeZipEntries();
138             }
139         } catch (IOException | ReflectiveOperationException | SecurityException e) {
140             throw new ArchiverException("Exception occurred " + "while creating modular JAR file", e);
141         }
142     }
143 
144     /**
145      * Fallback to rewrite the JAR file with the correct timestamp if the {@code --date} option is not available.
146      */
147     private void fixLastModifiedTimeZipEntries() throws IOException {
148         long timeMillis = getLastModifiedTime().toMillis();
149         Path destFile = getDestFile().toPath();
150         Path tmpZip = Files.createTempFile(destFile.getParent(), null, null);
151         try (ZipFile zipFile = new ZipFile(getDestFile());
152                 ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(tmpZip))) {
153             Enumeration<? extends ZipEntry> entries = zipFile.entries();
154             while (entries.hasMoreElements()) {
155                 ZipEntry entry = entries.nextElement();
156                 // Not using setLastModifiedTime(FileTime) as it sets the extended timestamp
157                 // which is not compatible with the jar tool output.
158                 entry.setTime(timeMillis);
159                 out.putNextEntry(entry);
160                 if (!entry.isDirectory()) {
161                     IOUtil.copy(zipFile.getInputStream(entry), out);
162                 }
163                 out.closeEntry();
164             }
165         }
166         Files.move(tmpZip, destFile, StandardCopyOption.REPLACE_EXISTING);
167     }
168 
169     /**
170      * Returns {@code true} if {@code path}
171      * is a module descriptor.
172      */
173     private boolean isModuleDescriptor(String path) {
174         if (path.endsWith(MODULE_DESCRIPTOR_FILE_NAME)) {
175             String prefix = path.substring(0, path.lastIndexOf(MODULE_DESCRIPTOR_FILE_NAME));
176 
177             // the path is a module descriptor if it located
178             // into the root of the archive or into the
179             // version area of a multi-release JAR file
180             return prefix.isEmpty() || MRJAR_VERSION_AREA.matcher(prefix).matches();
181         } else {
182             return false;
183         }
184     }
185 
186     /**
187      * Prepares the arguments for the jar tool.
188      * It takes into account the module version,
189      * main class, etc.
190      */
191     private String[] getJarToolArguments() throws IOException {
192         // We add empty temporary directory to the JAR file.
193         // It may look strange at first, but to update a JAR file
194         // you need to add new files[1]. If we add empty directory
195         // it will be ignored (not added to the archive), but
196         // the module descriptor will be updated and validated.
197         //
198         // [1] There are some exceptions (such as when the main class
199         // is updated) but we need at least empty directory
200         // to ensure it will work in all cases.
201         File tempEmptyDir = Files.createTempDirectory(null).toFile();
202         tempEmptyDir.deleteOnExit();
203 
204         List<String> args = new ArrayList<>();
205 
206         args.add("--update");
207         args.add("--file");
208         args.add(getDestFile().getAbsolutePath());
209 
210         String mainClass = getModuleMainClass() != null ? getModuleMainClass() : getManifestMainClass();
211 
212         if (mainClass != null) {
213             args.add("--main-class");
214             args.add(mainClass);
215         }
216 
217         if (getModuleVersion() != null) {
218             args.add("--module-version");
219             args.add(getModuleVersion());
220         }
221 
222         if (!isCompress()) {
223             args.add("--no-compress");
224         }
225 
226         if (hasJarDateOption) {
227             // The --date option already normalize the time, so revert to the local time
228             FileTime localTime = revertToLocalTime(getLastModifiedTime());
229             args.add("--date");
230             args.add(localTime.toString());
231         }
232 
233         args.add("-C");
234         args.add(tempEmptyDir.getAbsolutePath());
235         args.add(".");
236 
237         return args.toArray(new String[0]);
238     }
239 
240     private static FileTime revertToLocalTime(FileTime time) {
241         long restoreToLocalTime = time.toMillis();
242         Calendar cal = Calendar.getInstance(TimeZone.getDefault(), Locale.ROOT);
243         cal.setTimeInMillis(restoreToLocalTime);
244         restoreToLocalTime = restoreToLocalTime + (cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET));
245         return FileTime.fromMillis(restoreToLocalTime);
246     }
247 
248     /**
249      * Check support for {@code --date} option introduced since Java 17.0.3 (JDK-8279925).
250      *
251      * @return true if the JAR tool supports the {@code --date} option
252      */
253     private boolean isJarDateOptionSupported(Method runMethod) {
254         try {
255             // Test the output code validating the --date option.
256             String[] args = {"--date", "2099-12-31T23:59:59Z", "--version"};
257 
258             PrintStream nullPrintStream = NullPrintStream.NULL_PRINT_STREAM;
259             Integer result = (Integer) runMethod.invoke(jarTool, nullPrintStream, nullPrintStream, args);
260 
261             return result != null && result.intValue() == 0;
262         } catch (ReflectiveOperationException | SecurityException e) {
263             return false;
264         }
265     }
266 }