View Javadoc
1   /**
2    *
3    * Copyright 2004 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.zip;
18  
19  import java.io.ByteArrayInputStream;
20  import java.io.File;
21  import java.io.IOException;
22  import java.io.OutputStream;
23  import java.io.UncheckedIOException;
24  import java.nio.ByteBuffer;
25  import java.nio.charset.Charset;
26  import java.nio.charset.StandardCharsets;
27  import java.nio.file.Files;
28  import java.nio.file.attribute.FileTime;
29  import java.util.Calendar;
30  import java.util.Deque;
31  import java.util.Hashtable;
32  import java.util.Locale;
33  import java.util.TimeZone;
34  import java.util.concurrent.ExecutionException;
35  import java.util.zip.CRC32;
36  
37  import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
38  import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
39  import org.apache.commons.compress.archivers.zip.ZipEncoding;
40  import org.apache.commons.compress.archivers.zip.ZipEncodingHelper;
41  import org.apache.commons.compress.parallel.InputStreamSupplier;
42  import org.codehaus.plexus.archiver.AbstractArchiver;
43  import org.codehaus.plexus.archiver.ArchiveEntry;
44  import org.codehaus.plexus.archiver.Archiver;
45  import org.codehaus.plexus.archiver.ArchiverException;
46  import org.codehaus.plexus.archiver.ResourceIterator;
47  import org.codehaus.plexus.archiver.UnixStat;
48  import org.codehaus.plexus.archiver.exceptions.EmptyArchiveException;
49  import org.codehaus.plexus.archiver.util.ResourceUtils;
50  import org.codehaus.plexus.archiver.util.Streams;
51  import org.codehaus.plexus.components.io.functions.SymlinkDestinationSupplier;
52  import org.codehaus.plexus.components.io.resources.PlexusIoResource;
53  import org.codehaus.plexus.util.FileUtils;
54  
55  import static org.codehaus.plexus.archiver.util.Streams.bufferedOutputStream;
56  import static org.codehaus.plexus.archiver.util.Streams.fileOutputStream;
57  
58  @SuppressWarnings({"UnusedDeclaration"})
59  public abstract class AbstractZipArchiver extends AbstractArchiver {
60  
61      private String comment;
62  
63      /**
64       * Encoding to use for filenames, defaults to the platform's
65       * default encoding.
66       */
67      private String encoding = "UTF8";
68  
69      private boolean doCompress = true;
70  
71      private boolean recompressAddedZips = true;
72  
73      private boolean doUpdate = false;
74  
75      // shadow of the above if the value is altered in execute
76      private boolean savedDoUpdate = false;
77  
78      protected String archiveType = "zip";
79  
80      private boolean doFilesonly = false;
81  
82      protected final Hashtable<String, String> entries = new Hashtable<String, String>();
83  
84      protected final AddedDirs addedDirs = new AddedDirs();
85  
86      private static final long EMPTY_CRC = new CRC32().getValue();
87  
88      protected boolean doubleFilePass = false;
89  
90      protected boolean skipWriting = false;
91  
92      /**
93       * @deprecated Use {@link Archiver#setDuplicateBehavior(String)} instead.
94       */
95      @Deprecated
96      protected final String duplicate = Archiver.DUPLICATES_SKIP;
97  
98      /**
99       * true when we are adding new files into the Zip file, as opposed
100      * to adding back the unchanged files
101      */
102     protected boolean addingNewFiles = false;
103 
104     // Renamed version of original file, if it exists
105     private File renamedFile = null;
106 
107     private File zipFile;
108 
109     private boolean success;
110 
111     private ConcurrentJarCreator zOut;
112 
113     protected ZipArchiveOutputStream zipArchiveOutputStream;
114 
115     public String getComment() {
116         return comment;
117     }
118 
119     public void setComment(String comment) {
120         this.comment = comment;
121     }
122 
123     public String getEncoding() {
124         return encoding;
125     }
126 
127     public void setEncoding(String encoding) {
128         this.encoding = encoding;
129     }
130 
131     public void setCompress(boolean compress) {
132         this.doCompress = compress;
133     }
134 
135     public boolean isCompress() {
136         return doCompress;
137     }
138 
139     public boolean isRecompressAddedZips() {
140         return recompressAddedZips;
141     }
142 
143     public void setRecompressAddedZips(boolean recompressAddedZips) {
144         this.recompressAddedZips = recompressAddedZips;
145     }
146 
147     public void setUpdateMode(boolean update) {
148         this.doUpdate = update;
149         savedDoUpdate = doUpdate;
150     }
151 
152     public boolean isInUpdateMode() {
153         return doUpdate;
154     }
155 
156     /**
157      * If true, emulate Sun's jar utility by not adding parent directories;
158      * optional, defaults to false.
159      *
160      * @param f true to emilate sun jar utility
161      */
162     public void setFilesonly(boolean f) {
163         doFilesonly = f;
164     }
165 
166     public boolean isFilesonly() {
167         return doFilesonly;
168     }
169 
170     @Override
171     protected void execute() throws ArchiverException, IOException {
172         if (!checkForced()) {
173             return;
174         }
175 
176         if (doubleFilePass) {
177             skipWriting = true;
178             createArchiveMain();
179             skipWriting = false;
180             createArchiveMain();
181         } else {
182             createArchiveMain();
183         }
184 
185         finalizeZipOutputStream(zOut);
186     }
187 
188     protected void finalizeZipOutputStream(ConcurrentJarCreator zOut) throws IOException, ArchiverException {}
189 
190     private void createArchiveMain() throws ArchiverException, IOException {
191         //noinspection deprecation
192         if (!Archiver.DUPLICATES_SKIP.equals(duplicate)) {
193             //noinspection deprecation
194             setDuplicateBehavior(duplicate);
195         }
196 
197         ResourceIterator iter = getResources();
198         if (!iter.hasNext() && !hasVirtualFiles()) {
199             throw new EmptyArchiveException("archive cannot be empty");
200         }
201 
202         zipFile = getDestFile();
203 
204         if (zipFile == null) {
205             throw new ArchiverException("You must set the destination " + archiveType + "file.");
206         }
207 
208         if (zipFile.exists() && !zipFile.isFile()) {
209             throw new ArchiverException(zipFile + " isn't a file.");
210         }
211 
212         if (zipFile.exists() && !zipFile.canWrite()) {
213             throw new ArchiverException(zipFile + " is read-only.");
214         }
215 
216         // Whether or not an actual update is required -
217         // we don't need to update if the original file doesn't exist
218         addingNewFiles = true;
219 
220         if (doUpdate && !zipFile.exists()) {
221             doUpdate = false;
222             getLogger().debug("ignoring update attribute as " + archiveType + " doesn't exist.");
223         }
224 
225         success = false;
226 
227         if (doUpdate) {
228             renamedFile = FileUtils.createTempFile("zip", ".tmp", zipFile.getParentFile());
229             renamedFile.deleteOnExit();
230 
231             try {
232                 FileUtils.rename(zipFile, renamedFile);
233             } catch (SecurityException e) {
234                 getLogger().debug(e.toString());
235                 throw new ArchiverException(
236                         "Not allowed to rename old file (" + zipFile.getAbsolutePath() + ") to temporary file", e);
237             } catch (IOException e) {
238                 getLogger().debug(e.toString());
239                 throw new ArchiverException(
240                         "Unable to rename old file (" + zipFile.getAbsolutePath() + ") to temporary file", e);
241             }
242         }
243 
244         String action = doUpdate ? "Updating " : "Building ";
245 
246         getLogger().info(action + archiveType + ": " + zipFile.getAbsolutePath());
247 
248         if (!skipWriting) {
249             zipArchiveOutputStream = new ZipArchiveOutputStream(bufferedOutputStream(fileOutputStream(zipFile, "zip")));
250             zipArchiveOutputStream.setEncoding(encoding);
251             zipArchiveOutputStream.setCreateUnicodeExtraFields(this.getUnicodeExtraFieldPolicy());
252             zipArchiveOutputStream.setMethod(
253                     doCompress ? ZipArchiveOutputStream.DEFLATED : ZipArchiveOutputStream.STORED);
254 
255             zOut = new ConcurrentJarCreator(
256                     recompressAddedZips, Runtime.getRuntime().availableProcessors());
257         }
258         initZipOutputStream(zOut);
259 
260         // Add the new files to the archive.
261         addResources(iter, zOut);
262 
263         // If we've been successful on an update, delete the
264         // temporary file
265         if (doUpdate) {
266             if (!renamedFile.delete()) {
267                 getLogger().warn("Warning: unable to delete temporary file " + renamedFile.getName());
268             }
269         }
270         success = true;
271     }
272 
273     /**
274      * Gets the {@code UnicodeExtraFieldPolicy} to apply.
275      *
276      * @return {@link ZipArchiveOutputStream.UnicodeExtraFieldPolicy#NEVER}, if the effective encoding is
277      * UTF-8; {@link ZipArchiveOutputStream.UnicodeExtraFieldPolicy#ALWAYS}, if the effective encoding is not
278      * UTF-8.
279      *
280      * @see #getEncoding()
281      */
282     private ZipArchiveOutputStream.UnicodeExtraFieldPolicy getUnicodeExtraFieldPolicy() {
283         // Copied from ZipEncodingHelper.isUTF8()
284         String effectiveEncoding = this.getEncoding();
285 
286         if (effectiveEncoding == null) {
287             effectiveEncoding = Charset.defaultCharset().name();
288         }
289 
290         boolean utf8 = StandardCharsets.UTF_8.name().equalsIgnoreCase(effectiveEncoding);
291 
292         if (!utf8) {
293             for (String alias : StandardCharsets.UTF_8.aliases()) {
294                 if (alias.equalsIgnoreCase(effectiveEncoding)) {
295                     utf8 = true;
296                     break;
297                 }
298             }
299         }
300 
301         // Using ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NOT_ENCODEABLE as a fallback makes no sense here.
302         // If the encoding is UTF-8 and a name is not encodeable using UTF-8, the Info-ZIP Unicode Path extra field
303         // is not encodeable as well. If the effective encoding is not UTF-8, we always add the extra field. If it is
304         // UTF-8, we never add the extra field.
305         return utf8
306                 ? ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NEVER
307                 : ZipArchiveOutputStream.UnicodeExtraFieldPolicy.ALWAYS;
308     }
309 
310     /**
311      * Add the given resources.
312      *
313      * @param resources the resources to add
314      * @param zOut the stream to write to
315      */
316     @SuppressWarnings({"JavaDoc"})
317     protected final void addResources(ResourceIterator resources, ConcurrentJarCreator zOut)
318             throws IOException, ArchiverException {
319         while (resources.hasNext()) {
320             ArchiveEntry entry = resources.next();
321             String name = entry.getName();
322             name = name.replace(File.separatorChar, '/');
323 
324             if ("".equals(name)) {
325                 continue;
326             }
327 
328             if (entry.getResource().isDirectory() && !name.endsWith("/")) {
329                 name = name + "/";
330             }
331 
332             addParentDirs(entry, null, name, zOut);
333 
334             if (entry.getResource().isFile()) {
335                 zipFile(entry, zOut, name);
336             } else {
337                 zipDir(entry.getResource(), zOut, name, entry.getMode(), encoding);
338             }
339         }
340     }
341 
342     /**
343      * Ensure all parent dirs of a given entry have been added.
344      * <p/>
345      * This method is computed in terms of the potentially remapped entry (that may be disconnected from the file system)
346      * we do not *relly* know the entry's connection to the file system so establishing the attributes of the parents can
347      * be impossible and is not really supported.
348      */
349     @SuppressWarnings({"JavaDoc"})
350     private void addParentDirs(ArchiveEntry archiveEntry, File baseDir, String entry, ConcurrentJarCreator zOut)
351             throws IOException {
352         if (!doFilesonly && getIncludeEmptyDirs()) {
353             Deque<String> directories = addedDirs.asStringDeque(entry);
354 
355             while (!directories.isEmpty()) {
356                 String dir = directories.pop();
357                 File f;
358                 if (baseDir != null) {
359                     f = new File(baseDir, dir);
360                 } else {
361                     f = new File(dir);
362                 }
363                 // the
364                 // At this point we could do something like read the atr
365                 final PlexusIoResource res = new AnonymousResource(f);
366                 zipDir(res, zOut, dir, archiveEntry.getDefaultDirMode(), encoding);
367             }
368         }
369     }
370 
371     /**
372      * Adds a new entry to the archive, takes care of duplicates as well.
373      *
374      * @param in the stream to read data for the entry from.
375      * @param zOut the stream to write to.
376      * @param vPath the name this entry shall have in the archive.
377      * @param lastModified last modification time for the entry.
378      * @param fromArchive the original archive we are copying this
379      * @param symlinkDestination
380      * @param addInParallel Indicates if the entry should be add in parallel.
381      * If set to {@code false} it is added synchronously.
382      * If the entry is symbolic link this parameter is ignored.
383      */
384     @SuppressWarnings({"JavaDoc"})
385     protected void zipFile(
386             InputStreamSupplier in,
387             ConcurrentJarCreator zOut,
388             String vPath,
389             long lastModified,
390             File fromArchive,
391             int mode,
392             String symlinkDestination,
393             boolean addInParallel)
394             throws IOException, ArchiverException {
395         getLogger().debug("adding entry " + vPath);
396 
397         entries.put(vPath, vPath);
398 
399         if (!skipWriting) {
400             ZipArchiveEntry ze = new ZipArchiveEntry(vPath);
401             setZipEntryTime(ze, lastModified);
402 
403             ze.setMethod(doCompress ? ZipArchiveEntry.DEFLATED : ZipArchiveEntry.STORED);
404             ze.setUnixMode(UnixStat.FILE_FLAG | mode);
405 
406             if (ze.isUnixSymlink()) {
407                 final byte[] bytes = encodeArchiveEntry(symlinkDestination, getEncoding());
408                 InputStreamSupplier payload = () -> new ByteArrayInputStream(bytes);
409                 zOut.addArchiveEntry(ze, payload, true);
410             } else {
411                 zOut.addArchiveEntry(ze, in, addInParallel);
412             }
413         }
414     }
415 
416     /**
417      * Method that gets called when adding from java.io.File instances.
418      * <p>
419      * This implementation delegates to the six-arg version.</p>
420      *
421      * @param entry the file to add to the archive
422      * @param zOut the stream to write to
423      * @param vPath the name this entry shall have in the archive
424      */
425     @SuppressWarnings({"JavaDoc"})
426     protected void zipFile(final ArchiveEntry entry, ConcurrentJarCreator zOut, String vPath)
427             throws IOException, ArchiverException {
428         final PlexusIoResource resource = entry.getResource();
429         if (ResourceUtils.isSame(resource, getDestFile())) {
430             throw new ArchiverException("A zip file cannot include itself");
431         }
432 
433         final boolean b = entry.getResource() instanceof SymlinkDestinationSupplier;
434         String symlinkTarget = b ? ((SymlinkDestinationSupplier) entry.getResource()).getSymlinkDestination() : null;
435         InputStreamSupplier in = () -> {
436             try {
437                 return entry.getInputStream();
438             } catch (IOException e) {
439                 throw new UncheckedIOException(e);
440             }
441         };
442         try {
443             zipFile(
444                     in,
445                     zOut,
446                     vPath,
447                     resource.getLastModified(),
448                     null,
449                     entry.getMode(),
450                     symlinkTarget,
451                     !entry.shouldAddSynchronously());
452         } catch (IOException e) {
453             throw new ArchiverException("IOException when zipping r" + entry.getName() + ": " + e.getMessage(), e);
454         }
455     }
456 
457     /**
458      * Set the ZipEntry dostime using the date if specified otherwise the original time.
459      *
460      * <p>Zip archives store file modification times with a granularity of two seconds, so the times will either be
461      * rounded up or down. If you round down, the archive will always seem out-of-date when you rerun the task, so the
462      * default is to round up. Rounding up may lead to a different type of problems like JSPs inside a web archive that
463      * seem to be slightly more recent than precompiled pages, rendering precompilation useless.
464      * plexus-archiver chooses to round up.
465      *
466      * @param zipEntry to set the last modified time
467      * @param lastModifiedTime to set in the zip entry only if {@link #getLastModifiedTime()} returns null
468      */
469     protected void setZipEntryTime(ZipArchiveEntry zipEntry, long lastModifiedTime) {
470         if (getLastModifiedTime() != null) {
471             lastModifiedTime = getLastModifiedTime().toMillis();
472         }
473 
474         zipEntry.setTime(lastModifiedTime + 1999);
475     }
476 
477     protected void zipDir(PlexusIoResource dir, ConcurrentJarCreator zOut, String vPath, int mode, String encodingToUse)
478             throws IOException {
479         if (addedDirs.update(vPath)) {
480             return;
481         }
482 
483         getLogger().debug("adding directory " + vPath);
484 
485         if (!skipWriting) {
486             final boolean isSymlink = dir instanceof SymlinkDestinationSupplier;
487 
488             if (isSymlink && vPath.endsWith(File.separator)) {
489                 vPath = vPath.substring(0, vPath.length() - 1);
490             }
491 
492             ZipArchiveEntry ze = new ZipArchiveEntry(vPath);
493 
494             /*
495              * ZipOutputStream.putNextEntry expects the ZipEntry to
496              * know its size and the CRC sum before you start writing
497              * the data when using STORED mode - unless it is seekable.
498              *
499              * This forces us to process the data twice.
500              */
501             if (isSymlink) {
502                 mode = UnixStat.LINK_FLAG | mode;
503             }
504 
505             if (dir != null && dir.isExisting()) {
506                 setZipEntryTime(ze, dir.getLastModified());
507             } else {
508                 // ZIPs store time with a granularity of 2 seconds, round up
509                 setZipEntryTime(ze, System.currentTimeMillis());
510             }
511             if (!isSymlink) {
512                 ze.setSize(0);
513                 ze.setMethod(ZipArchiveEntry.STORED);
514                 // This is faintly ridiculous:
515                 ze.setCrc(EMPTY_CRC);
516             }
517             ze.setUnixMode(mode);
518 
519             if (!isSymlink) {
520                 zOut.addArchiveEntry(ze, () -> Streams.EMPTY_INPUTSTREAM, true);
521             } else {
522                 String symlinkDestination = ((SymlinkDestinationSupplier) dir).getSymlinkDestination();
523                 final byte[] bytes = encodeArchiveEntry(symlinkDestination, encodingToUse);
524                 ze.setMethod(ZipArchiveEntry.DEFLATED);
525                 zOut.addArchiveEntry(ze, () -> new ByteArrayInputStream(bytes), true);
526             }
527         }
528     }
529 
530     private byte[] encodeArchiveEntry(String payload, String encoding) throws IOException {
531         ZipEncoding enc = ZipEncodingHelper.getZipEncoding(encoding);
532         ByteBuffer encodedPayloadByteBuffer = enc.encode(payload);
533         byte[] encodedPayloadBytes = new byte[encodedPayloadByteBuffer.limit()];
534         encodedPayloadByteBuffer.get(encodedPayloadBytes);
535 
536         return encodedPayloadBytes;
537     }
538 
539     /**
540      * Create an empty zip file
541      *
542      * @param zipFile The file
543      *
544      * @return true for historic reasons
545      */
546     @SuppressWarnings({"JavaDoc"})
547     protected boolean createEmptyZip(File zipFile) throws ArchiverException {
548         // In this case using java.util.zip will not work
549         // because it does not permit a zero-entry archive.
550         // Must create it manually.
551         getLogger().info("Note: creating empty " + archiveType + " archive " + zipFile);
552 
553         try (OutputStream os = Files.newOutputStream(zipFile.toPath())) {
554             // Cf. PKZIP specification.
555             byte[] empty = new byte[22];
556             empty[0] = 80; // P
557             empty[1] = 75; // K
558             empty[2] = 5;
559             empty[3] = 6;
560             // remainder zeros
561             os.write(empty);
562         } catch (IOException ioe) {
563             throw new ArchiverException("Could not create empty ZIP archive " + "(" + ioe.getMessage() + ")", ioe);
564         }
565         return true;
566     }
567 
568     /**
569      * Do any clean up necessary to allow this instance to be used again.
570      * <p>
571      * When we get here, the Zip file has been closed and all we
572      * need to do is to reset some globals.</p>
573      * <p>
574      * This method will only reset globals that have been changed
575      * during execute(), it will not alter the attributes or nested
576      * child elements. If you want to reset the instance so that you
577      * can later zip a completely different set of files, you must use
578      * the reset method.</p>
579      *
580      * @see #reset
581      */
582     @Override
583     protected void cleanUp() throws IOException {
584         super.cleanUp();
585         addedDirs.clear();
586         entries.clear();
587         addingNewFiles = false;
588         doUpdate = savedDoUpdate;
589         success = false;
590         zOut = null;
591         renamedFile = null;
592         zipFile = null;
593     }
594 
595     /**
596      * Makes this instance reset all attributes to their default
597      * values and forget all children.
598      *
599      * @see #cleanUp
600      */
601     public void reset() {
602         setDestFile(null);
603         //        duplicate = "add";
604         archiveType = "zip";
605         doCompress = true;
606         doUpdate = false;
607         doFilesonly = false;
608         encoding = null;
609     }
610 
611     /**
612      * method for subclasses to override
613      *
614      * @param zOut The output stream
615      */
616     protected void initZipOutputStream(ConcurrentJarCreator zOut) throws ArchiverException, IOException {}
617 
618     /**
619      * method for subclasses to override
620      */
621     @Override
622     public boolean isSupportingForced() {
623         return true;
624     }
625 
626     @Override
627     protected boolean revert(StringBuffer messageBuffer) {
628         int initLength = messageBuffer.length();
629 
630         // delete a bogus ZIP file (but only if it's not the original one)
631         if ((!doUpdate || renamedFile != null) && !zipFile.delete()) {
632             messageBuffer.append(" (and the archive is probably corrupt but I could not delete it)");
633         }
634 
635         if (doUpdate && renamedFile != null) {
636             try {
637                 FileUtils.rename(renamedFile, zipFile);
638             } catch (IOException e) {
639                 messageBuffer.append(" (and I couldn't rename the temporary file ");
640                 messageBuffer.append(renamedFile.getName());
641                 messageBuffer.append(" back)");
642             }
643         }
644 
645         return messageBuffer.length() == initLength;
646     }
647 
648     @Override
649     protected void close() throws IOException {
650         // Close the output stream.
651         try {
652             if (zipArchiveOutputStream != null) {
653                 if (zOut != null) {
654                     zOut.writeTo(zipArchiveOutputStream);
655                 }
656                 zipArchiveOutputStream.close();
657             }
658         } catch (IOException ex) {
659             // If we're in this finally clause because of an
660             // exception, we don't really care if there's an
661             // exception when closing the stream. E.g. if it
662             // throws "ZIP file must have at least one entry",
663             // because an exception happened before we added
664             // any files, then we must swallow this
665             // exception. Otherwise, the error that's reported
666             // will be the close() error, which is not the
667             // real cause of the problem.
668             if (success) {
669                 throw ex;
670             }
671 
672         } catch (InterruptedException e) {
673             IOException ex = new IOException("InterruptedException exception");
674             ex.initCause(e.getCause());
675             throw ex;
676         } catch (ExecutionException e) {
677             IOException ex = new IOException("Execution exception");
678             ex.initCause(e.getCause());
679             throw ex;
680         }
681     }
682 
683     @Override
684     protected String getArchiveType() {
685         return archiveType;
686     }
687 
688     @Override
689     protected FileTime normalizeLastModifiedTime(FileTime lastModifiedTime) {
690         // timestamp of zip entries at zip storage level ignores timezone: managed in ZipEntry.setTime,
691         // that turns javaToDosTime: need to revert the operation here to get reproducible
692         // zip entry time
693         return FileTime.fromMillis(dosToJavaTime(lastModifiedTime.toMillis()));
694     }
695 
696     /**
697      * Converts DOS time to Java time (number of milliseconds since epoch).
698      *
699      * @see java.util.zip.ZipEntry#setTime
700      * @see java.util.zip.ZipUtils#dosToJavaTime
701      */
702     private static long dosToJavaTime(long dosTime) {
703         Calendar cal = Calendar.getInstance(TimeZone.getDefault(), Locale.ROOT);
704         cal.setTimeInMillis(dosTime);
705         return dosTime - (cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET));
706     }
707 }