From 2116a3b23d967913ce072ea88a895a87c3ad0b20 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Fri, 24 Nov 2023 09:12:45 +0100 Subject: [PATCH] review: archive-decompressor supports multiple archive and compression formats - Generic support for all commons-compress archive and compression formats - Improved support for nested directories on Windows - Added validations and tests for non-happy-path scenarios Signed-off-by: Marc Nuri --- .../common/archive/ArchiveDecompressor.java | 129 ++++++++++++++++++ .../archive/JKubeArchiveDecompressor.java | 122 ----------------- .../archive/ArchiveDecompressorTest.java | 110 +++++++++++++++ .../archive/JKubeArchiveDecompressorTest.java | 79 ----------- .../archive-decompressor}/foo.xz | Bin .../archive-decompressor/invalid-archive.txt | 1 + .../invalid-archive.txt.gz | Bin 0 -> 57 bytes .../archive-decompressor}/nested-archive.tgz | Bin .../archive-decompressor}/nested-archive.zip | Bin .../pack-v0.31.0-linux.tgz | Bin .../pack-v0.31.0-windows.zip | Bin 11 files changed, 240 insertions(+), 201 deletions(-) create mode 100644 jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressor.java delete mode 100644 jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressor.java create mode 100644 jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressorTest.java delete mode 100644 jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressorTest.java rename jkube-kit/common/src/test/resources/{archives => archive/archive-decompressor}/foo.xz (100%) create mode 100644 jkube-kit/common/src/test/resources/archive/archive-decompressor/invalid-archive.txt create mode 100644 jkube-kit/common/src/test/resources/archive/archive-decompressor/invalid-archive.txt.gz rename jkube-kit/common/src/test/resources/{archives => archive/archive-decompressor}/nested-archive.tgz (100%) rename jkube-kit/common/src/test/resources/{archives => archive/archive-decompressor}/nested-archive.zip (100%) rename jkube-kit/common/src/test/resources/{archives => archive/archive-decompressor}/pack-v0.31.0-linux.tgz (100%) rename jkube-kit/common/src/test/resources/{archives => archive/archive-decompressor}/pack-v0.31.0-windows.zip (100%) diff --git a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressor.java b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressor.java new file mode 100644 index 0000000000..ce346e32d3 --- /dev/null +++ b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressor.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2019 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at: + * + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.jkube.kit.common.archive; + +import org.apache.commons.compress.archivers.ArchiveEntry; +import org.apache.commons.compress.archivers.ArchiveException; +import org.apache.commons.compress.archivers.ArchiveInputStream; +import org.apache.commons.compress.archivers.ArchiveStreamFactory; +import org.apache.commons.compress.compressors.CompressorException; +import org.apache.commons.compress.compressors.CompressorInputStream; +import org.apache.commons.compress.compressors.CompressorStreamFactory; +import org.eclipse.jkube.kit.common.util.FileUtil; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; + +public class ArchiveDecompressor { + + private static final String ERROR_MESSAGE = "Unsupported archive file provided"; + + private ArchiveDecompressor() { } + + /** + * Extracts a given compressed or archive {@link File} to specified target directory. + * + * @param inputFile compressed or archive input file. + * @param targetDirectory target directory to extract the archive to. + * @throws IOException in case a failure occurs while trying to extract the file. + */ + public static void extractArchive(File inputFile, File targetDirectory) throws IOException { + try (InputStream fis = Files.newInputStream(inputFile.toPath())) { + extractArchive(fis, targetDirectory); + } + } + + /** + * Extracts a given compressed or archive {@link InputStream} to specified target directory. + * + * @param archiveInputStream compressed or archive input stream. + * @param targetDirectory target directory to extract the archive to. + * @throws IOException in case a failure occurs while trying to extract the stream. + */ + public static void extractArchive(InputStream archiveInputStream, File targetDirectory) throws IOException { + try (BufferedInputStream bis = new BufferedInputStream(archiveInputStream)) { + if (isCompressedFile(bis)) { + extractCompressedFile(bis, targetDirectory); + } else if (isArchive(bis)) { + extractArchiveContents(bis, targetDirectory); + } else { + throw new IllegalArgumentException(ERROR_MESSAGE); + } + } + } + + private static void extractCompressedFile(InputStream is, File targetDirectory) throws IOException { + try ( + CompressorInputStream cis = new CompressorStreamFactory().createCompressorInputStream(is); + BufferedInputStream bis = new BufferedInputStream(cis) + ) { + if (isArchive(bis)) { + extractArchiveContents(bis, targetDirectory); + } else { + throw new IllegalArgumentException(ERROR_MESSAGE); + } + } catch (CompressorException ex) { + throw new IllegalArgumentException(ERROR_MESSAGE, ex); + } + } + + private static void extractArchiveContents(InputStream is, File targetDirectory) throws IOException { + if (targetDirectory.exists() && !targetDirectory.isDirectory()) { + throw new IllegalArgumentException("Target directory is not a directory"); + } else if (targetDirectory.exists()) { + FileUtil.cleanDirectory(targetDirectory); + } + FileUtil.createDirectory(targetDirectory); + try (ArchiveInputStream ais = new ArchiveStreamFactory().createArchiveInputStream(is)) { + ArchiveEntry entry; + while ((entry = ais.getNextEntry()) != null) { + final File extractTo = new File(targetDirectory, fileName(entry.getName())); + if (extractTo.getCanonicalPath().startsWith(targetDirectory.getCanonicalPath())) { + if (entry.isDirectory()) { + FileUtil.createDirectory(extractTo); + } else { + Files.copy(ais, extractTo.toPath()); + } + } + } + } catch (ArchiveException ex) { + throw new IllegalArgumentException(ERROR_MESSAGE, ex); + } + } + + private static boolean isCompressedFile(InputStream inputStream) { + try { + CompressorStreamFactory.detect(inputStream); + return true; + } catch(CompressorException ex) { + return false; + } + } + + private static boolean isArchive(InputStream inputStream) { + try { + ArchiveStreamFactory.detect(inputStream); + return true; + } catch (ArchiveException ex) { + return false; + } + } + + private static String fileName(String originalName) { + return originalName.replace('/', File.separatorChar); + } +} diff --git a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressor.java b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressor.java deleted file mode 100644 index 51a019419c..0000000000 --- a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressor.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) 2019 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at: - * - * https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package org.eclipse.jkube.kit.common.archive; - -import org.apache.commons.compress.archivers.ArchiveEntry; -import org.apache.commons.compress.archivers.ArchiveException; -import org.apache.commons.compress.archivers.ArchiveStreamFactory; -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; -import org.apache.commons.compress.compressors.CompressorException; -import org.apache.commons.compress.compressors.CompressorStreamFactory; -import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; -import org.eclipse.jkube.kit.common.util.FileUtil; - -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -public class JKubeArchiveDecompressor { - private JKubeArchiveDecompressor() { } - - /** - * Extracts a given archive file to specified target directory - * - * @param inputArchiveFile input archive file - * @param targetDirectory target folder where you want to extract - * @throws IOException in case of failure while trying to create any directory - */ - public static void extractArchive(File inputArchiveFile, File targetDirectory) throws IOException { - if (targetDirectory.exists()) { - FileUtil.cleanDirectory(targetDirectory); - } - Files.createDirectory(targetDirectory.toPath()); - - if (isArchiveCompressedWithGZipAlgorithm(inputArchiveFile)) { - extractTarArchive(inputArchiveFile, targetDirectory.toPath()); - } else if (isArchiveZip(inputArchiveFile)) { - extractZipArchive(inputArchiveFile, targetDirectory.toPath()); - } else { - throw new IllegalStateException("Unsupported archive file provided"); - } - } - - private static boolean isArchiveCompressedWithGZipAlgorithm(File inputArchiveFile) throws IOException { - BufferedInputStream bufferedInputStream = new BufferedInputStream(Files.newInputStream(inputArchiveFile.toPath())); - try { - String s = CompressorStreamFactory.detect(bufferedInputStream); - return s.equals(CompressorStreamFactory.GZIP); - } catch (CompressorException ignored) { - // Unknown Compressor stream signature found, ignore - } - return false; - } - - private static boolean isArchiveZip(File inputArchiveFile) throws IOException { - BufferedInputStream bufferedInputStream = new BufferedInputStream(Files.newInputStream(inputArchiveFile.toPath())); - try { - String s = ArchiveStreamFactory.detect(bufferedInputStream); - return s.equals(ArchiveStreamFactory.ZIP); - } catch (ArchiveException ignored) { - // Unknown Archive stream signature found, ignore - } - return false; - } - - private static void extractTarArchive(File downloadedArchive, Path targetExtractionDir) throws IOException { - try (BufferedInputStream inputStream = new BufferedInputStream(Files.newInputStream(downloadedArchive.toPath())); - TarArchiveInputStream tar = new TarArchiveInputStream(new GzipCompressorInputStream(inputStream))) { - ArchiveEntry entry; - while ((entry = tar.getNextEntry()) != null) { - Path extractTo = targetExtractionDir.resolve(entry.getName()); - if (entry.isDirectory()) { - Files.createDirectories(extractTo); - } else { - Files.copy(tar, extractTo); - } - } - } - } - - private static void extractZipArchive(File downloadedArchive, Path targetExtractionDir) throws IOException { - byte[] buffer = new byte[1024]; - try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(downloadedArchive.toPath()))) { - ZipEntry zipEntry = zis.getNextEntry(); - while (zipEntry != null) { - File newFile = new File(targetExtractionDir.toFile(), zipEntry.getName()); - if (!newFile.getCanonicalPath().startsWith(targetExtractionDir.toFile().getCanonicalPath())) { - throw new IOException("Entry is outside of target dir: " + targetExtractionDir); - } - if (zipEntry.isDirectory()) { - if (!newFile.isDirectory() && !newFile.mkdirs()) { - throw new IOException("Failed to create directory " + newFile); - } - } else { - try (FileOutputStream fos = new FileOutputStream(newFile)) { - int len; - while ((len = zis.read(buffer)) > 0) { - fos.write(buffer, 0, len); - } - } - } - zipEntry = zis.getNextEntry(); - } - zis.closeEntry(); - } - } -} diff --git a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressorTest.java b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressorTest.java new file mode 100644 index 0000000000..ce5016fc28 --- /dev/null +++ b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/ArchiveDecompressorTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2019 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at: + * + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.jkube.kit.common.archive; + +import org.eclipse.jkube.kit.common.assertj.FileAssertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class ArchiveDecompressorTest { + + @TempDir + private File tempDir; + + @ParameterizedTest + @CsvSource({ + "/archive/archive-decompressor/pack-v0.31.0-linux.tgz,pack", + "/archive/archive-decompressor/pack-v0.31.0-windows.zip,pack.exe" + }) + void extractArchive_whenArchiveWithSingleFileProvided_thenExtractToSpecifiedDir(String filePath, String expectedFileInExtractedArchiveName) throws IOException { + // Given + File input = new File(ArchiveDecompressorTest.class.getResource(filePath).getFile()); + + // When + ArchiveDecompressor.extractArchive(input, tempDir); + + // Then + FileAssertions.assertThat(tempDir) + .exists() + .fileTree() + .containsExactlyInAnyOrder(expectedFileInExtractedArchiveName); + } + + @ParameterizedTest + @CsvSource({ + "/archive/archive-decompressor/nested-archive.tgz,nested,nested/folder,nested/folder/artifact", + "/archive/archive-decompressor/nested-archive.zip,nested,nested/folder,nested/folder/artifact.exe" + }) + void extractArchive_whenArchiveWithNestedDir_thenExtractToSpecifiedDir(String filePath, String parentDir, String artifactParentDir, String artifact) throws IOException { + // Given + File input = new File(ArchiveDecompressorTest.class.getResource(filePath).getFile()); + + // When + ArchiveDecompressor.extractArchive(input, tempDir); + + // Then + FileAssertions.assertThat(tempDir) + .exists() + .fileTree() + .containsExactlyInAnyOrder(parentDir, artifactParentDir, artifact); + } + + @Test + void extractArchive_whenUnsupportedArchiveProvided_thenThrowException() { + // Given + File input = new File(ArchiveDecompressorTest.class.getResource("/archive/archive-decompressor/foo.xz").getFile()); + + // When + assertThatIllegalArgumentException() + .isThrownBy(() -> ArchiveDecompressor.extractArchive(input, tempDir)) + .withMessage("Unsupported archive file provided"); + } + + @Test + void extractArchive_whenInvalidArchiveProvided_throwsException() throws IOException { + try (final InputStream input = ArchiveDecompressorTest.class.getResourceAsStream("/archive/archive-decompressor/invalid-archive.txt")) { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArchiveDecompressor.extractArchive(input, tempDir)) + .withMessage("Unsupported archive file provided"); + } + } + + @Test + void extractArchive_whenInvalidCompressedArchiveProvided_throwsException() throws IOException { + try (final InputStream input = ArchiveDecompressorTest.class.getResourceAsStream("/archive/archive-decompressor/invalid-archive.txt.gz")) { + assertThatIllegalArgumentException() + .isThrownBy(() -> ArchiveDecompressor.extractArchive(input, tempDir)) + .withMessage("Unsupported archive file provided"); + } + } + + @Test + void extractArchive_whenTargetDirectoryExistsAsFile_throwsException() throws IOException { + try (final InputStream input = ArchiveDecompressorTest.class.getResourceAsStream("/archive/archive-decompressor/nested-archive.tgz")) { + final File targetDirectory = Files.createFile(tempDir.toPath().resolve("target-as-file")).toFile(); + assertThatIllegalArgumentException() + .isThrownBy(() -> ArchiveDecompressor.extractArchive(input, targetDirectory)) + .withMessage("Target directory is not a directory"); + } + } +} diff --git a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressorTest.java b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressorTest.java deleted file mode 100644 index 452884085e..0000000000 --- a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/archive/JKubeArchiveDecompressorTest.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2019 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at: - * - * https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package org.eclipse.jkube.kit.common.archive; - -import org.eclipse.jkube.kit.common.assertj.FileAssertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import java.io.File; -import java.io.IOException; - -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; - -class JKubeArchiveDecompressorTest { - @TempDir - private File temporaryFolder; - - @ParameterizedTest - @CsvSource({ - "/archives/pack-v0.31.0-linux.tgz,pack", - "/archives/pack-v0.31.0-windows.zip,pack.exe" - }) - void extractArchive_whenArchiveWithSingleFileProvided_thenExtractToSpecifiedDir(String filePath, String expectedFileInExtractedArchiveName) throws IOException { - // Given - File input = new File(getClass().getResource(filePath).getFile()); - - // When - JKubeArchiveDecompressor.extractArchive(input, temporaryFolder); - - // Then - FileAssertions.assertThat(temporaryFolder) - .exists() - .fileTree() - .containsExactlyInAnyOrder(expectedFileInExtractedArchiveName); - } - - @ParameterizedTest - @CsvSource({ - "/archives/nested-archive.tgz,nested,nested/folder,nested/folder/artifact", - "/archives/nested-archive.zip,nested,nested/folder,nested/folder/artifact.exe" - }) - void extractArchive_whenArchiveWithNestedDir_thenExtractToSpecifiedDir(String filePath, String parentDir, String artifactParentDir, String artifact) throws IOException { - // Given - File input = new File(getClass().getResource(filePath).getFile()); - - // When - JKubeArchiveDecompressor.extractArchive(input, temporaryFolder); - - // Then - FileAssertions.assertThat(temporaryFolder) - .exists() - .fileTree() - .containsExactlyInAnyOrder(parentDir, artifactParentDir, artifact); - } - - @Test - void extractArchive_whenUnsupportedArchiveProvided_thenThrowException() { - // Given - File input = new File(getClass().getResource("/archives/foo.xz").getFile()); - - // When - assertThatIllegalStateException() - .isThrownBy(() -> JKubeArchiveDecompressor.extractArchive(input, temporaryFolder)) - .withMessage("Unsupported archive file provided"); - } -} diff --git a/jkube-kit/common/src/test/resources/archives/foo.xz b/jkube-kit/common/src/test/resources/archive/archive-decompressor/foo.xz similarity index 100% rename from jkube-kit/common/src/test/resources/archives/foo.xz rename to jkube-kit/common/src/test/resources/archive/archive-decompressor/foo.xz diff --git a/jkube-kit/common/src/test/resources/archive/archive-decompressor/invalid-archive.txt b/jkube-kit/common/src/test/resources/archive/archive-decompressor/invalid-archive.txt new file mode 100644 index 0000000000..194f876a7a --- /dev/null +++ b/jkube-kit/common/src/test/resources/archive/archive-decompressor/invalid-archive.txt @@ -0,0 +1 @@ +This is plain text diff --git a/jkube-kit/common/src/test/resources/archive/archive-decompressor/invalid-archive.txt.gz b/jkube-kit/common/src/test/resources/archive/archive-decompressor/invalid-archive.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..5a3dcb07e94395410d871d45b2828423d44862fd GIT binary patch literal 57 zcmb2|=HT#;Oh{#5&de)I%*jmAO)N^z$Sh0ME2$`9;68amCyZfH)0`t`Y+5H>=@Qao Na9F%3K$wAn0RT5O6ZQZA literal 0 HcmV?d00001 diff --git a/jkube-kit/common/src/test/resources/archives/nested-archive.tgz b/jkube-kit/common/src/test/resources/archive/archive-decompressor/nested-archive.tgz similarity index 100% rename from jkube-kit/common/src/test/resources/archives/nested-archive.tgz rename to jkube-kit/common/src/test/resources/archive/archive-decompressor/nested-archive.tgz diff --git a/jkube-kit/common/src/test/resources/archives/nested-archive.zip b/jkube-kit/common/src/test/resources/archive/archive-decompressor/nested-archive.zip similarity index 100% rename from jkube-kit/common/src/test/resources/archives/nested-archive.zip rename to jkube-kit/common/src/test/resources/archive/archive-decompressor/nested-archive.zip diff --git a/jkube-kit/common/src/test/resources/archives/pack-v0.31.0-linux.tgz b/jkube-kit/common/src/test/resources/archive/archive-decompressor/pack-v0.31.0-linux.tgz similarity index 100% rename from jkube-kit/common/src/test/resources/archives/pack-v0.31.0-linux.tgz rename to jkube-kit/common/src/test/resources/archive/archive-decompressor/pack-v0.31.0-linux.tgz diff --git a/jkube-kit/common/src/test/resources/archives/pack-v0.31.0-windows.zip b/jkube-kit/common/src/test/resources/archive/archive-decompressor/pack-v0.31.0-windows.zip similarity index 100% rename from jkube-kit/common/src/test/resources/archives/pack-v0.31.0-windows.zip rename to jkube-kit/common/src/test/resources/archive/archive-decompressor/pack-v0.31.0-windows.zip