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