001/*
002 * Licensed under the Apache License, Version 2.0 (the "License");
003 * you may not use this file except in compliance with the License.
004 * You may obtain a copy of the License at
005 *
006 *     http://www.apache.org/licenses/LICENSE-2.0
007 *
008 * Unless required by applicable law or agreed to in writing, software
009 * distributed under the License is distributed on an "AS IS" BASIS,
010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
011 * See the License for the specific language governing permissions and
012 * limitations under the License.
013 */
014package org.gbif.utils.file;
015
016import java.io.BufferedInputStream;
017import java.io.BufferedOutputStream;
018import java.io.File;
019import java.io.FileFilter;
020import java.io.FileInputStream;
021import java.io.FileOutputStream;
022import java.io.IOException;
023import java.io.InputStream;
024import java.io.OutputStream;
025import java.io.RandomAccessFile;
026import java.nio.file.Files;
027import java.util.ArrayList;
028import java.util.Arrays;
029import java.util.Collection;
030import java.util.Comparator;
031import java.util.HashSet;
032import java.util.List;
033import java.util.Set;
034import java.util.stream.Collectors;
035import java.util.zip.GZIPInputStream;
036import java.util.zip.ZipEntry;
037import java.util.zip.ZipException;
038import java.util.zip.ZipInputStream;
039import java.util.zip.ZipOutputStream;
040
041import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
042import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
043import org.apache.commons.io.FileUtils;
044import org.apache.commons.io.IOUtils;
045import org.apache.commons.io.filefilter.HiddenFileFilter;
046import org.apache.commons.io.filefilter.TrueFileFilter;
047import org.apache.commons.lang3.StringUtils;
048import org.slf4j.Logger;
049import org.slf4j.LoggerFactory;
050
051@SuppressWarnings("unused")
052public class CompressionUtil {
053
054  private CompressionUtil() {
055    throw new UnsupportedOperationException("Can't initialize class");
056  }
057
058  public static class UnsupportedCompressionType extends RuntimeException {
059
060    public UnsupportedCompressionType() {}
061
062    public UnsupportedCompressionType(String message) {
063      super(message);
064    }
065
066    public UnsupportedCompressionType(String message, Throwable cause) {
067      super(message, cause);
068    }
069  }
070
071  private static final Logger LOG = LoggerFactory.getLogger(CompressionUtil.class);
072  private static final int BUFFER = 2048;
073  private static final String APPLE_RESOURCE_FORK = "__MACOSX";
074  private static final byte[] TAR_MAGIC_BYTES = new byte[] {'u', 's', 't', 'a', 'r'};
075
076  /**
077   * Tries to decompress a file into a newly created temporary directory, trying gzip or zip regardless of the filename
078   * or its suffix.
079   *
080   * @return folder containing all decompressed files
081   */
082  public static File decompressFile(File compressedFile)
083      throws IOException, UnsupportedCompressionType {
084    // create empty tmp dir
085    File dir = File.createTempFile("gbif-", null);
086    if (dir.exists() && !dir.delete()) {
087      throw new IOException("Couldn't delete temporary directory");
088    }
089
090    if (!dir.mkdirs()) {
091      throw new IOException("Couldn't create temporary directory for decompression");
092    }
093
094    // decompress
095    decompressFile(dir, compressedFile);
096
097    return dir;
098  }
099
100  /**
101   * Defaults keeping subDirectories to false.
102   *
103   * @see org.gbif.utils.file.CompressionUtil#decompressFile(File, File, String, boolean)
104   */
105  public static List<File> decompressFile(File directory, File compressedFile)
106      throws IOException, UnsupportedCompressionType {
107    return decompressFile(directory, compressedFile, false);
108  }
109
110  /**
111   * Defaults keeping subDirectories to false, but provides unzipped file name.
112   *
113   * @see org.gbif.utils.file.CompressionUtil#decompressFile(File, File, String, boolean)
114   */
115  public static List<File> decompressFile(
116      File directory, File compressedFile, String unzippedFileName)
117      throws IOException, UnsupportedCompressionType {
118    return decompressFile(directory, compressedFile, unzippedFileName, false);
119  }
120
121  /**
122   * Defaults keeping subDirectories to false and unzipped file name to null.
123   *
124   * @see org.gbif.utils.file.CompressionUtil#decompressFile(File, File, String, boolean)
125   */
126  public static List<File> decompressFile(
127      File directory, File compressedFile, boolean keepSubdirectories)
128      throws IOException, UnsupportedCompressionType {
129    return decompressFile(directory, compressedFile, null, keepSubdirectories);
130  }
131
132  /**
133   * Tries to decompress a file using TAR+gzip, gzip, TAR or Zip regardless of the filename or its suffix.
134   *
135   * @param directory      directory where archive's contents will be decompressed to
136   * @param compressedFile compressed file
137   * @param unzippedFileName unzipped file name (in case of one file)
138   *
139   * @return list of files that have been extracted or null an empty list if archive couldn't be decompressed
140   *
141   * @throws IOException                if problem occurred reading compressed file, or directory couldn't be written
142   *                                    to
143   * @throws UnsupportedCompressionType if the compression type wasn't recognized
144   */
145  public static List<File> decompressFile(
146      File directory, File compressedFile, String unzippedFileName, boolean keepSubdirectories)
147      throws IOException, UnsupportedCompressionType {
148    List<File> files = null;
149
150    // Test before trying gzip format
151    if (isGzipFormat(compressedFile)) {
152      try {
153        LOG.debug("Uncompressing {} with gzip compression to {}", compressedFile, directory);
154        files = untgzFile(directory, compressedFile);
155      } catch (IOException e) {
156        LOG.debug("Not .tar.gz compression, trying .gz");
157        files = ungzipFile(directory, compressedFile, unzippedFileName, false);
158      } catch (Exception e) {
159        LOG.error("Not gzip compression");
160      }
161    }
162
163    // Test before trying TAR format
164    if (isTarFormat(compressedFile)) {
165      try {
166        LOG.debug("Uncompressing {} with TAR compression to {}", compressedFile, directory);
167        files = untarFile(directory, compressedFile);
168      } catch (Exception e) {
169        LOG.debug("Not TAR compression");
170      }
171    }
172
173    // Then try zip
174    if (files == null) {
175      try {
176        LOG.debug("Uncompressing {} with Zip compression to {}", compressedFile, directory);
177        files = unzipFile(directory, compressedFile, keepSubdirectories);
178      } catch (ZipException e) {
179        LOG.debug("Not Zip compression");
180        throw new UnsupportedCompressionType("Unknown compression type. Neither gzip nor Zip", e);
181      }
182    }
183
184    if (files.isEmpty()) {
185      LOG.warn("No files extracted from {}, tried TGZ, TAR and Zip compression.", compressedFile);
186    }
187
188    return files;
189  }
190
191  /**
192   * Check the file's first two bytes, to see if they are the gzip magic number.
193   * @param compressedFile compressed file
194   * @return               true if the file is in gzip format
195   * @throws IOException   if a problem occurred reading compressed file
196   */
197  public static boolean isGzipFormat(File compressedFile) throws IOException {
198    try (RandomAccessFile file = new RandomAccessFile(compressedFile, "r")) {
199      return GZIPInputStream.GZIP_MAGIC == (file.read() & 0xff | ((file.read() << 8) & 0xff00));
200    }
201  }
202
203  /**
204   * Check the file is a Tape ARchive (TAR).
205   * @param compressedFile compressed file
206   * @return               true if the file is a TAR
207   * @throws IOException   if a problem occurred reading compressed file
208   */
209  public static boolean isTarFormat(File compressedFile) throws IOException {
210    try (RandomAccessFile file = new RandomAccessFile(compressedFile, "r")) {
211      // TAR files contain "ustar\0" or "ustar " at byte 257.
212      // https://www.gnu.org/software/tar/manual/html_node/Standard.html
213      byte[] at257 = new byte[5];
214      file.seek(257);
215      file.read(at257, 0, 5);
216      return Arrays.equals(at257, TAR_MAGIC_BYTES);
217    } catch (Exception e) {
218      LOG.debug("Exc", e);
219    }
220    return false;
221  }
222
223  /**
224   * Extracts a gzipped TAR file. Directory structure and hidden files (i.e. files starting with a dot) are ignored.
225   *
226   * @param directory where the file should be extracted to
227   * @param tgzFile   to extract
228   *
229   * @return a list of all created files
230   */
231  public static List<File> untgzFile(File directory, File tgzFile) throws IOException {
232    return untarStream(directory, new GZIPInputStream(Files.newInputStream(tgzFile.toPath())));
233  }
234
235  /**
236   * Extracts a plain TAR file. Directory structure and hidden files (i.e. files starting with a dot) are ignored.
237   *
238   * @param directory where the file should be extracted to
239   * @param tarFile   to extract
240   *
241   * @return a list of all created files
242   */
243  public static List<File> untarFile(File directory, File tarFile) throws IOException {
244    return untarStream(directory, Files.newInputStream(tarFile.toPath()));
245  }
246
247  /**
248   * Extracts a TAR stream. Directory structure and hidden files (i.e. files starting with a dot) are ignored.
249   *
250   * @param directory where the file should be extracted to
251   * @param tarStream to extract
252   *
253   * @return a list of all created files
254   */
255  private static List<File> untarStream(File directory, InputStream tarStream) throws IOException {
256    List<File> files = new ArrayList<>();
257    try (TarArchiveInputStream in = new TarArchiveInputStream(tarStream)) {
258      TarArchiveEntry entry;
259      while ((entry = in.getNextTarEntry()) != null) {
260        if (entry.isDirectory()) {
261          LOG.debug("TAR archive contains directories which are being ignored");
262          continue;
263        }
264        String fn = new File(entry.getName()).getName();
265        if (fn.startsWith(".")) {
266          LOG.debug("TAR archive contains a hidden file {} which is being ignored", fn);
267          continue;
268        }
269        File targetFile = new File(directory, fn);
270        if (targetFile.exists()) {
271          LOG.warn("TAR archive contains duplicate filename {}, only the first was extracted", fn);
272          continue;
273        }
274        LOG.debug("Extracting file: {} to: {}", entry.getName(), targetFile.getAbsolutePath());
275        try (FileOutputStream out = new FileOutputStream(targetFile)) {
276          IOUtils.copy(in, out);
277        }
278        files.add(targetFile);
279      }
280    }
281    return files;
282  }
283
284  /**
285   * Gunzip a file.  Use this method with isTarred false if the gzip contains a single file.  If it's a gzip
286   * of a TAR pass true to isTarred (or call {@link org.gbif.utils.file.CompressionUtil#untgzFile(File, File)}
287   * which is what this method just redirects to for isTarred).
288   *
289   * @param directory the output directory for the uncompressed file(s)
290   * @param gzipFile  the gzip file
291   * @param isTarred  true if the gzip contains a TAR
292   *
293   * @return a List of the uncompressed file name(s)
294   *
295   * @throws IOException if reading or writing fails
296   */
297  public static List<File> ungzipFile(File directory, File gzipFile, boolean isTarred)
298      throws IOException {
299    if (isTarred) return untgzFile(directory, gzipFile);
300
301    String unzippedName = gzipFile.getName().substring(0, gzipFile.getName().lastIndexOf("."));
302    return ungzipFile(directory, gzipFile, unzippedName, isTarred);
303  }
304
305  /**
306   * Gunzip a file.  Similar to {@link org.gbif.utils.file.CompressionUtil#ungzipFile(File, File, boolean)},
307   * but with ability to control unzipped file name.
308   *
309   * @param directory the output directory for the uncompressed file(s)
310   * @param gzipFile  the gzip file
311   * @param unzippedName  the unzipped file
312   * @param isTarred  true if the gzip contains a TAR
313   *
314   * @return a List of the uncompressed file name(s)
315   *
316   * @throws IOException if reading or writing fails
317   */
318  public static List<File> ungzipFile(
319      File directory, File gzipFile, String unzippedName, boolean isTarred) throws IOException {
320    if (isTarred) return untgzFile(directory, gzipFile);
321
322    List<File> files = new ArrayList<>();
323    BufferedOutputStream dest = null;
324    try (GZIPInputStream in = new GZIPInputStream(Files.newInputStream(gzipFile.toPath()))) {
325
326      // assume that the gzip filename is the filename + .gz
327      String unzippedNameResult;
328      if (StringUtils.isEmpty(unzippedName)) {
329        unzippedNameResult = gzipFile.getName().substring(0, gzipFile.getName().lastIndexOf("."));
330      } else {
331        unzippedNameResult = unzippedName;
332      }
333
334      File outputFile = new File(directory, unzippedNameResult);
335      LOG.debug("Extracting file: {} to: {}", unzippedNameResult, outputFile.getAbsolutePath());
336      FileOutputStream fos = new FileOutputStream(outputFile);
337
338      dest = new BufferedOutputStream(fos, BUFFER);
339      int count;
340      byte[] data = new byte[BUFFER];
341      while ((count = in.read(data, 0, BUFFER)) != -1) {
342        dest.write(data, 0, count);
343      }
344      files.add(outputFile);
345    } finally {
346      if (dest != null) {
347        dest.flush();
348        dest.close();
349      }
350    }
351
352    return files;
353  }
354
355  /**
356   * Defaults keepSubdirectories to false.
357   *
358   * @see org.gbif.utils.file.CompressionUtil#unzipFile(java.io.File, java.io.File, boolean)
359   */
360  public static List<File> unzipFile(File directory, File zipFile) throws IOException {
361    return unzipFile(directory, zipFile, false);
362  }
363
364  /**
365   * Zip a directory with all files but skipping included subdirectories.
366   * Only files directly within the directory are added to the archive.
367   *
368   * @param dir     the directory to zip
369   * @param zipFile the zipped file
370   */
371  public static void zipDir(File dir, File zipFile) throws IOException {
372    zipDir(dir, zipFile, false);
373  }
374
375  /**
376   * Zip a directory with all files. Files in Subdirectories will be included if the inclSubdirs is true.
377   *
378   * @param dir     the directory to zip
379   * @param zipFile the zipped file
380   * @param inclSubdirs if true includes all subdirectories recursively
381   */
382  public static void zipDir(File dir, File zipFile, boolean inclSubdirs) throws IOException {
383    Collection<File> files = org.apache.commons.io.FileUtils.listFiles(dir, null, inclSubdirs);
384    zipFiles(files, dir, zipFile);
385  }
386
387  public static void zipFile(File file, File zipFile) throws IOException {
388    Set<File> files = new HashSet<>();
389    files.add(file);
390    zipFiles(files, file.getParentFile(), zipFile);
391  }
392
393  /**
394   * Creates a zip archive from a given collection of files.
395   * In order to preserve paths in the archive a rootContext can be specified which will be removed from the individual
396   * zip entries. For example a rootContext of /home/freak with a file /home/freak/photo/birthday.jpg to be zipped
397   * will result in a zip entry with a path photo/birthday.jpg.
398   *
399   * @param files to be included in the zip archive
400   * @param rootContext optional path to be removed from each file
401   * @param zipFile the zip file to be created
402   * @throws IOException
403   */
404  public static void zipFiles(Collection<File> files, File rootContext, File zipFile)
405      throws IOException {
406    if (files.isEmpty()) {
407      LOG.info("no files to zip.");
408    } else {
409      try {
410        BufferedInputStream origin = null;
411        FileOutputStream dest = new FileOutputStream(zipFile);
412        ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(dest));
413        // out.setMethod(ZipOutputStream.DEFLATED);
414        byte[] data = new byte[BUFFER];
415        for (File f : files) {
416          LOG.debug("Adding file {} to archive", f);
417          FileInputStream fi = new FileInputStream(f);
418          origin = new BufferedInputStream(fi, BUFFER);
419
420          String zipPath =
421              StringUtils.removeStart(
422                  f.getAbsolutePath(), rootContext.getAbsolutePath() + File.separator);
423          ZipEntry entry = new ZipEntry(zipPath);
424          out.putNextEntry(entry);
425          int count;
426          while ((count = origin.read(data, 0, BUFFER)) != -1) {
427            out.write(data, 0, count);
428          }
429          origin.close();
430        }
431        out.finish();
432        out.close();
433      } catch (IOException e) {
434        LOG.error("IOException while zipping files: {}", files);
435        throw e;
436      }
437    }
438  }
439
440  /**
441   * Extracts a zipped file into a target directory. If the file is wrapped in a root directory, this is removed by
442   * default. Other subdirectories are ignored according to the parameter keepSubdirectories.
443   * </br>
444   * The following types of files are also ignored by default:
445   * i) hidden files (i.e. files starting with a dot)
446   * ii) Apple resource fork (__MACOSX), including its subdirectories and subfiles
447   *
448   * @param directory          where the zipped file and its subdirectories should be extracted to
449   * @param zipFile            to extract
450   * @param keepSubdirectories whether to preserve subdirectories or not
451   *
452   * @return a list of all created files and directories extracted to target directory
453   */
454  public static List<File> unzipFile(File directory, File zipFile, boolean keepSubdirectories)
455      throws IOException {
456    LOG.debug(
457        "Unzipping archive "
458            + zipFile.getName()
459            + " into directory: "
460            + directory.getAbsolutePath());
461
462    // This is changed from using ZipFile to a ZipInputStream since Java 8u192 can't open certain
463    // Zip64 files.
464    // https://bugs.openjdk.java.net/browse/JDK-8186464
465    try (FileInputStream fInput = new FileInputStream(zipFile);
466        ZipInputStream zipInput = new ZipInputStream(fInput)) {
467      ZipEntry entry;
468
469      while ((entry = zipInput.getNextEntry()) != null) {
470        // ignore resource fork directories and subfiles
471        if (entry.getName().toUpperCase().contains(APPLE_RESOURCE_FORK)) {
472          LOG.debug("Ignoring resource fork file: " + entry.getName());
473        }
474        // ignore directories and hidden directories (e.g. .svn) (based on flag)
475        else if (entry.isDirectory()) {
476          if (isHiddenFile(new File(entry.getName()))) {
477            LOG.debug("Ignoring hidden directory: " + entry.getName());
478          } else if (keepSubdirectories) {
479            new File(directory, entry.getName()).mkdir();
480          } else {
481            LOG.debug("Ignoring (sub)directory: " + entry.getName());
482          }
483        }
484        // ignore hidden files
485        else {
486          if (isHiddenFile(new File(entry.getName()))) {
487            LOG.debug("Ignoring hidden file: " + entry.getName());
488          } else {
489            File targetFile =
490                (keepSubdirectories)
491                    ? new File(directory, entry.getName())
492                    : new File(directory, new File(entry.getName()).getName());
493            // ensure parent folder always exists, and extract file
494            createParentFolder(targetFile);
495
496            LOG.debug("Extracting file: {} to: {}", entry.getName(), targetFile.getAbsolutePath());
497            try (OutputStream out =
498                new BufferedOutputStream(Files.newOutputStream(targetFile.toPath()))) {
499              IOUtils.copy(zipInput, out);
500            }
501          }
502        }
503      }
504    }
505    // remove the wrapping root directory and flatten structure
506    if (keepSubdirectories) {
507      removeRootDirectory(directory);
508    }
509
510    File[] files = directory.listFiles();
511
512    return (files == null) ? new ArrayList<>() : Arrays.asList(files);
513  }
514
515  /**
516   * @return true if file is a hidden file or directory, or if any of its parent directories are hidden checking
517   * recursively
518   */
519  private static boolean isHiddenFile(File f) {
520    if (f.getName().startsWith(".")) {
521      return true;
522    } else if (f.getParentFile() != null) {
523      return isHiddenFile(f.getParentFile());
524    }
525    return false;
526  }
527
528  /**
529   * Removes a wrapping root directory and flatten its structure by moving all that root directory's files and
530   * subdirectories up to the same level as the root directory.
531   */
532  @SuppressWarnings("ResultOfMethodCallIgnored")
533  private static void removeRootDirectory(File directory) {
534    File[] rootFiles = directory.listFiles((FileFilter) HiddenFileFilter.VISIBLE);
535    if (rootFiles == null) {
536      LOG.error("Failed to retrieve root directory from {}", directory.getAbsolutePath());
537      return;
538    }
539
540    if (rootFiles.length != 1) {
541      LOG.error("More than one root directory at {}", directory.getAbsolutePath());
542      return;
543    }
544
545    File root = rootFiles[0];
546    if (root.isDirectory()) {
547      LOG.debug(
548          "Removing single root folder {} found in decompressed archive", root.getAbsoluteFile());
549      Collection<File> filesAndDirs =
550          FileUtils.listFilesAndDirs(root, TrueFileFilter.TRUE, TrueFileFilter.TRUE);
551      // directories shouldn't be in the end, sort
552      List<File> sortedFilesAndDirs =
553          filesAndDirs.stream()
554              .sorted(Comparator.comparing(File::getAbsolutePath))
555              .collect(Collectors.toList());
556
557      for (File f : sortedFilesAndDirs) {
558        File f2 = new File(directory, f.getName());
559        f.renameTo(f2);
560      }
561      root.delete();
562    }
563  }
564
565  /**
566   * Make parent folder.
567   *
568   * @param file destination file
569   */
570  private static void createParentFolder(File file) {
571    File parent = new File(file.getParent());
572    if (!parent.exists()) {
573      LOG.debug(
574          (parent.mkdirs())
575              ? "Created parent directory: " + parent.getAbsolutePath()
576              : "Failed to create parent directory: " + parent.getAbsolutePath());
577    }
578  }
579}