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         PosixFileAttributeView view =
157                 Files.getFileAttributeView(destFile, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
158 
159         PosixFileAttributes posixFileAttributes = null;
160         if (view != null) {
161             posixFileAttributes = view.readAttributes();
162         }
163 
164         FileAttribute<?>[] attributes;
165         if (posixFileAttributes != null) {
166             attributes = new FileAttribute<?>[1];
167             attributes[0] = PosixFilePermissions.asFileAttribute(posixFileAttributes.permissions());
168         } else {
169             attributes = new FileAttribute<?>[0];
170         }
171         Path tmpZip = Files.createTempFile(destFile.getParent(), null, null, attributes);
172         try {
173             try (ZipFile zipFile = new ZipFile(getDestFile());
174                     ZipOutputStream out = new ZipOutputStream(Streams.fileOutputStream(tmpZip))) {
175                 Enumeration<? extends ZipEntry> entries = zipFile.entries();
176                 while (entries.hasMoreElements()) {
177                     ZipEntry entry = entries.nextElement();
178                     // Not using setLastModifiedTime(FileTime) as it sets the extended timestamp
179                     // which is not compatible with the jar tool output.
180                     entry.setTime(timeMillis);
181                     out.putNextEntry(entry);
182                     if (!entry.isDirectory()) {
183                         IOUtil.copy(zipFile.getInputStream(entry), out);
184                     }
185                     out.closeEntry();
186                 }
187             }
188             Files.move(tmpZip, destFile, StandardCopyOption.REPLACE_EXISTING);
189         } catch (IOException e) {
190             // Clean up temporary file if an error occurs
191             try {
192                 Files.delete(tmpZip);
193             } catch (IOException ioe) {
194                 e.addSuppressed(ioe);
195             }
196             throw e;
197         }
198     }
199 
200     /**
201      * Returns {@code true} if {@code path}
202      * is a module descriptor.
203      */
204     private boolean isModuleDescriptor(String path) {
205         if (path.endsWith(MODULE_DESCRIPTOR_FILE_NAME)) {
206             String prefix = path.substring(0, path.lastIndexOf(MODULE_DESCRIPTOR_FILE_NAME));
207 
208             // the path is a module descriptor if it located
209             // into the root of the archive or into the
210             // version area of a multi-release JAR file
211             return prefix.isEmpty() || MRJAR_VERSION_AREA.matcher(prefix).matches();
212         } else {
213             return false;
214         }
215     }
216 
217     /**
218      * Prepares the arguments for the jar tool.
219      * It takes into account the module version,
220      * main class, etc.
221      */
222     private String[] getJarToolArguments() throws IOException {
223         // We add empty temporary directory to the JAR file.
224         // It may look strange at first, but to update a JAR file
225         // you need to add new files[1]. If we add empty directory
226         // it will be ignored (not added to the archive), but
227         // the module descriptor will be updated and validated.
228         //
229         // [1] There are some exceptions (such as when the main class
230         // is updated) but we need at least empty directory
231         // to ensure it will work in all cases.
232         File tempEmptyDir = Files.createTempDirectory(null).toFile();
233         tempEmptyDir.deleteOnExit();
234 
235         List<String> args = new ArrayList<>();
236 
237         args.add("--update");
238         args.add("--file");
239         args.add(getDestFile().getAbsolutePath());
240 
241         String mainClass = getModuleMainClass() != null ? getModuleMainClass() : getManifestMainClass();
242 
243         if (mainClass != null) {
244             args.add("--main-class");
245             args.add(mainClass);
246         }
247 
248         if (getModuleVersion() != null) {
249             args.add("--module-version");
250             args.add(getModuleVersion());
251         }
252 
253         if (!isCompress()) {
254             args.add("--no-compress");
255         }
256 
257         if (hasJarDateOption) {
258             // The --date option already normalize the time, so revert to the local time
259             FileTime localTime = revertToLocalTime(getLastModifiedTime());
260             args.add("--date");
261             args.add(localTime.toString());
262         }
263 
264         args.add("-C");
265         args.add(tempEmptyDir.getAbsolutePath());
266         args.add(".");
267 
268         return args.toArray(new String[0]);
269     }
270 
271     private static FileTime revertToLocalTime(FileTime time) {
272         long restoreToLocalTime = time.toMillis();
273         Calendar cal = Calendar.getInstance(TimeZone.getDefault(), Locale.ROOT);
274         cal.setTimeInMillis(restoreToLocalTime);
275         restoreToLocalTime = restoreToLocalTime + (cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET));
276         return FileTime.fromMillis(restoreToLocalTime);
277     }
278 
279     /**
280      * Check support for {@code --date} option introduced since Java 17.0.3 with <a href="https://bugs.openjdk.org/browse/JDK-8277755">JDK-8277755</a>.
281      *
282      * @return true if the JAR tool supports the {@code --date} option
283      */
284     private boolean isJarDateOptionSupported(Method runMethod) {
285         try {
286             // Test the output code validating the --date option.
287             String[] args = {"--date", "2099-12-31T23:59:59Z", "--version"};
288 
289             PrintStream nullPrintStream = NullPrintStream.INSTANCE;
290             Integer result = (Integer) runMethod.invoke(jarTool, nullPrintStream, nullPrintStream, args);
291 
292             return result != null && result.intValue() == 0;
293         } catch (ReflectiveOperationException | SecurityException e) {
294             return false;
295         }
296     }
297 }