/*
 * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file is distributed
 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */
package com.amazonaws.codepipeline.jenkinsplugin;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.when;

import hudson.model.AbstractBuild;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.zip.CRC32;

import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.io.FileUtils;
import org.apache.tools.zip.ZipOutputStream;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.RunnerBuilder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import com.amazonaws.codepipeline.jenkinsplugin.CodePipelineStateModel.CompressionType;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.S3Object;

@RunWith(ExtractionToolsTest.class)
@Suite.SuiteClasses({
        ExtractionToolsTest.GetCompressionTypeTest.class,
        ExtractionToolsTest.DecompressFileTest.class,
        ExtractionToolsTest.ExtractionPathTraversalTest.class
})
public class ExtractionToolsTest extends Suite {

    private static final String FILE_PATH = Paths.get("A", "File").toString();

    public ExtractionToolsTest(final Class<?> klass, final RunnerBuilder builder)
            throws InitializationError {
        super(klass, builder);
    }

    private static class TestBase {
        public void setUp() throws IOException {
            MockitoAnnotations.initMocks(this);
            TestUtils.initializeTestingFolders();
        }

        public void tearDown() throws IOException {
            TestUtils.cleanUpTestingFolders();
        }
    }

    public static class GetCompressionTypeTest extends TestBase {
        private CodePipelineStateModel model;

        @Mock
        private S3Object obj;
        @Mock
        private AbstractBuild<?, ?> mockBuild;
        @Mock
        private ObjectMetadata metadata;

        @Before
        public void setUp() throws IOException {
            super.setUp();

            model = new CodePipelineStateModel();
            model.setCompressionType(CompressionType.None);

            when(obj.getObjectMetadata()).thenReturn(metadata);
        }

        @After
        public void tearDown() throws IOException {
            super.tearDown();
        }

        @Test
        public void getCompressionTypeZipSuccess() {
            when(obj.getKey()).thenReturn(Paths.get(FILE_PATH, "Yes.zip").toString());

            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.Zip, compressionType);
        }

        @Test
        public void getCompressionTypeTarSuccess() {
            when(obj.getKey()).thenReturn(Paths.get(FILE_PATH, "Yes.tar").toString());

            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.Tar, compressionType);
        }

        @Test
        public void getCompressionTypeTarGzSuccess() {
            when(obj.getKey()).thenReturn(Paths.get(FILE_PATH, "Yes.tar.gz").toString());

            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.TarGz, compressionType);
        }

        @Test
        public void noCompressionTypeFoundNullFailure() {
            when(obj.getKey()).thenReturn(Paths.get(FILE_PATH, "Yes.notanextension").toString());
            when(metadata.getContentType()).thenReturn("notanextension");

            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.None, compressionType);
        }

        @Test
        public void getCompressionTypeZipFromMetadataSuccess() {
            when(obj.getKey()).thenReturn("A" + File.separator + "File" + File.separator + "XK321K");
            when(metadata.getContentType()).thenReturn("application/zip");
            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.Zip, compressionType);
        }

        @Test
        public void getCompressionTypeTarFromMetadataSuccess() {
            when(obj.getKey()).thenReturn("A" + File.separator + "File" + File.separator + "XK321K");
            when(metadata.getContentType()).thenReturn("application/tar");

            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.Tar, compressionType);
        }

        @Test
        public void getCompressionTypeTarGzFromMetadataSuccess() {
            when(obj.getKey()).thenReturn("A" + File.separator + "File" + File.separator + "XK321K");
            when(metadata.getContentType()).thenReturn("application/gzip");
            final CompressionType compressionType = ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.TarGz, compressionType);
        }

        @Test
        public void getCompressionTypeTarGzXFromMetadataSuccess() {
            when(obj.getKey()).thenReturn("A" + File.separator + "File" + File.separator + "XK321K");
            when(metadata.getContentType()).thenReturn("application/x-gzip");

            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.TarGz, compressionType);
        }

        @Test
        public void getCompressionTypeTarXFromMetadataSuccess() {
            when(obj.getKey()).thenReturn("A" + File.separator + "File" + File.separator + "XK321K");
            when(metadata.getContentType()).thenReturn("application/x-tar");

            final CompressionType compressionType =
                    ExtractionTools.getCompressionType(obj, null);

            assertEquals(CompressionType.Tar, compressionType);
        }
    }

    public static class DecompressFileTest extends TestBase {
        private static final String ARCHIVE_PREFIX = "decompress-test";

        private Path testDir;
        private Path compressedFile;
        private Path decompressDestination;

        @Before
        public void setUp() throws IOException {
            super.setUp();

            testDir = Paths.get(TestUtils.TEST_DIR);
            decompressDestination = Files.createTempDirectory(ARCHIVE_PREFIX);

            try (final PrintWriter writer = new PrintWriter(
                        Paths.get(TestUtils.TEST_DIR, "bbb.txt").toFile())) {
                writer.println("Some test data for the file");
            }
        }

        @After
        public void tearDown() throws IOException {
            super.tearDown();

            if (compressedFile != null) {
                Files.deleteIfExists(compressedFile);
            }
            FileUtils.deleteDirectory(decompressDestination.toFile());
        }

        @Test
        public void canDecompressZipFile() {
            try {
                compressedFile = Files.createTempFile(ARCHIVE_PREFIX, ".zip");
                try (final ZipArchiveOutputStream outputStream = new ZipArchiveOutputStream(
                            new BufferedOutputStream(new FileOutputStream(compressedFile.toFile())))) {
                    // Deflated is the default compression method
                    zipDirectory(testDir, outputStream, ZipOutputStream.DEFLATED);
                }

                ExtractionTools.decompressFile(
                        compressedFile.toFile(),
                        decompressDestination.toFile(),
                        CompressionType.Zip,
                        null);

                assertEquals(getFileNames(testDir), getFileNames(decompressDestination));
            } catch (final IOException e) {
                fail(e.getMessage());
            }
        }

        @Test
        public void canDecompressZipFileWithStoredCompressionMethod() {
            try {
                compressedFile = Files.createTempFile(ARCHIVE_PREFIX, ".zip");
                try (final ZipArchiveOutputStream outputStream = new ZipArchiveOutputStream(
                            new BufferedOutputStream(new FileOutputStream(compressedFile.toFile())))) {
                    zipDirectory(testDir, outputStream, ZipOutputStream.STORED);
                }

                ExtractionTools.decompressFile(
                        compressedFile.toFile(),
                        decompressDestination.toFile(),
                        CompressionType.Zip,
                        null);

                assertEquals(getFileNames(testDir), getFileNames(decompressDestination));
            } catch (final IOException e) {
                fail(e.getMessage());
            }
        }

        // e.g.: zip -r --exclude *.git* - . | aws s3 cp - s3://code-pipeline/aws-codedeploy-demo.zip
        @Test
        public void canDecompressZipFileCreatedFromCommandLine() {
            try {
                final String filePath = getClass().getClassLoader().getResource("aws-codedeploy-demo.zip").getFile();
                final String osAppropriatePath = System.getProperty("os.name").contains("indow") ? filePath.substring(1) : filePath;

                final Path cliCompressedFile = Paths.get(osAppropriatePath);

                ExtractionTools.decompressFile(
                        cliCompressedFile.toFile(),
                        decompressDestination.toFile(),
                        CompressionType.Zip,
                        null);

                final Set<String> files = new HashSet<>(Arrays.asList(".DS_Store", "appspec.yml",
                            "aws-codepipeline-jenkins-aws-codedeploy_linux.zip", "Gemfile", "LICENSE",
                            "Rakefile", "README.md", "install_dependencies", "start_server", "stop_server",
                            "index.html.haml", "jenkins_sample_test.rb"));

                assertEquals(files, getFileNames(decompressDestination));
            } catch (final IOException e) {
                fail(e.getMessage());
            }
        }

        @SuppressWarnings("unchecked")
        private static Set<String> getFileNames(final Path dir) {
            final Collection<File> files = FileUtils.listFiles(dir.toFile(), null, true);
            final Set<String> fileNames = new HashSet<>();
            for (final File file : files) {
                fileNames.add(file.getName());
            }
            return fileNames;
        }

        private static void zipDirectory(
                final Path directory,
                final ZipArchiveOutputStream out,
                final int compressionMethod) throws IOException {

            Files.walkFileTree(directory, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
                    final String entryName = directory.relativize(file).toString();
                    final ZipArchiveEntry archiveEntry = new ZipArchiveEntry(file.toFile(), entryName);
                    final byte[] contents = Files.readAllBytes(file);

                    out.setMethod(compressionMethod);
                    archiveEntry.setMethod(compressionMethod);

                    if (compressionMethod == ZipOutputStream.STORED) {
                        archiveEntry.setSize(contents.length);

                        final CRC32 crc32 = new CRC32();
                        crc32.update(contents);
                        archiveEntry.setCrc(crc32.getValue());
                    }

                    out.putArchiveEntry(archiveEntry);
                    out.write(contents);
                    out.closeArchiveEntry();

                    return FileVisitResult.CONTINUE;
                }
            });
        }
    }

    public static class ExtractionPathTraversalTest extends TestBase {
        private static final String BASE_DIR = "base";
        private Path testDir;

        @Before
        public void setUp() throws IOException {
            testDir = Files.createTempDirectory("ExtractionPathTraversalTest.tmp.");
        }

        @After
        public void tearDown() throws IOException {
            FileUtils.deleteDirectory(testDir.toFile());
        }


        private Path getFilePath(final String filename) {
            final String filePath = getClass().getClassLoader().getResource(filename).getFile();
            final String osAppropriatePath = System.getProperty("os.name").contains("indow") ? filePath.substring(1) : filePath;
            return Paths.get(osAppropriatePath);
        }

        private Path createExtractionDir() throws IOException {
            final Path baseDir = FileSystems.getDefault().getPath(testDir.toFile().getAbsolutePath(), BASE_DIR);
            Files.createDirectories(baseDir);
            return baseDir;
        }

        private void assertDirContents(final String filename, final Path baseDir) {
            final String[] files = testDir.toFile().list();
            assertEquals(filename + " should produce no extra entries in test dir after extraction", 1, files.length);
            assertEquals("the only entry in test dir should be '" + BASE_DIR + "' directory", BASE_DIR, files[0]);
            assertTrue(filename + " should produce a file named good.txt in '" + BASE_DIR + "' directory",
                    Files.exists(baseDir.resolve("good.txt")));
        }

        private void testPathTraversal(final String filename) throws IOException {
            final Path cliCompressedFile = getFilePath(filename);
            final Path baseDir = createExtractionDir();

            // path traversal is dependent on zip file contents and the file system in use, for that reason the test may
            // or may not throw an IOException. But, in either case, no file should be produced on top level testDir.
            try {
                ExtractionTools.decompressFile(
                        cliCompressedFile.toFile(),
                        baseDir.toFile(),
                        CompressionType.Zip,
                        null);
            } catch (final IOException e) {
                assertTrue(e.getMessage().startsWith("The compressed input file contains files targeting an invalid destination: "));
            }

            assertDirContents(filename, baseDir);
        }


        private void shouldNotThrowExtractingFile(final String filename) throws IOException {
            final Path cliCompressedFile = getFilePath(filename);
            final Path baseDir = createExtractionDir();

            ExtractionTools.decompressFile(
                    cliCompressedFile.toFile(),
                    baseDir.toFile(),
                    CompressionType.Zip,
                    null);

            assertDirContents(filename, baseDir);
        }

        @Test
        public void shouldNotTraverseBaseDirOnExtractionUnix() throws IOException {
            // dir-traversal-unix.zip:
            // - good.txt
            // - ../evil.txt
            testPathTraversal("dir-traversal-unix.zip");
        }

        @Test
        public void shouldNotTraverseBaseDirOnExtractionUnixIfFilenameMatchesBaseDir() throws IOException {
            // dir-traversal-unix2.zip:
            // - good.txt
            // - ../base-evil.txt
            testPathTraversal("dir-traversal-unix2.zip");
        }

        @Test
        public void shouldNotTraverseBaseDirOnExtractionWin() throws IOException {
            // dir-traversal-win.zip:
            // - good.txt
            // - ..\evil.txt
            testPathTraversal("dir-traversal-win.zip");
        }

        @Test
        public void shouldNotTraverseBaseDirOnExtractionWinIfFilenameMatchesBaseDir() throws IOException {
            // dir-traversal-win2.zip:
            // - good.txt
            // - ..\base-evil.txt
            testPathTraversal("dir-traversal-win2.zip");
        }

        @Test
        public void shouldNotThrowIfZipContainsDotSlash() throws IOException {
            // dir-traversal-dotslash.zip
            // - ./
            // - good.txt
            shouldNotThrowExtractingFile("dir-traversal-dotslash.zip");
        }
    }
}