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}