/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License 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 org.apache.ignite.spi.deployment.uri;

import java.io.IOException;
import java.io.InputStream;
import java.security.CodeSigner;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarInputStream;
import java.util.jar.Manifest;
import org.apache.ignite.IgniteLogger;
import org.apache.ignite.internal.util.typedef.internal.U;

/**
 * Helper class that verifies either JAR file or JAR file input stream
 * if it is consistent or not. Consistency means that file was not changed
 * since build and all files mentioned in manifest are signed.
 */
final class GridUriDeploymentJarVerifier {
    /**
     * Enforces singleton.
     */
    private GridUriDeploymentJarVerifier() {
        // No-op.
    }

    /** Default buffer size = 4K. */
    private static final int BUF_SIZE = 4096;

    /**
     * Verify JAR-file that it was not changed since creation.
     * If parameter {@code allSigned} equals {@code true} and file is not
     * listed in manifest than method return {@code false}. If file listed
     * in manifest but doesn't exist in JAR-file than method return
     * {@code false}.
     *
     * @param jarName JAR file name.
     * @param allSigned If {@code true} then all files must be signed.
     * @param log Logger.
     * @return {@code true} if JAR file was not changed.
     * @throws IOException Thrown if JAR file or its entries could not be read.
     */
    static boolean verify(String jarName, boolean allSigned, IgniteLogger log) throws IOException {
        assert jarName != null;

        return verify0(jarName, null, allSigned, log);
    }

    /**
     * Verify JAR-file that all files declared in manifest are signed.
     * If manifest is {@code null} than method returns {@code true} if
     * public key is {@code null}.
     * If parameter {@code allSigned} equals {@code true} and file not
     * listed in manifest than method return {@code false}. If file
     * listed in manifest but doesn't exist in JAR-file than method
     * return {@code false}.
     *
     * @param jarName JAR file name.
     * @param pubKey Public key.
     * @param allSigned If {@code true} then all files must be signed.
     * @param log Logger.
     * @return {@code true} if JAR file is signed with given public key.
     * @throws IOException Thrown if JAR file or its entries could not be read.
     */
    static boolean verify(String jarName, PublicKey pubKey, boolean allSigned, IgniteLogger log)
        throws IOException {
        assert jarName != null;
        assert pubKey != null;

        return verify0(jarName, pubKey, allSigned, log);
    }

    /**
     * Tests whether given JAR file input stream was not changed since creation.
     *
     * @param in JAR file input stream.
     * @param allSigned Hint which means that all files of all entries must be
     *      signed.
     * @param log Logger.
     * @return {@code true} if JAR file input stream was not changed.
     * @throws IOException Thrown if JAR file stream or its entries could not
     *    be read.
     */
    static boolean verify(InputStream in, boolean allSigned, IgniteLogger log) throws IOException {
        assert in != null;

        return verify0(in, null, allSigned, log);
    }

    /**
     * Tests whether given JAR file input stream is signed with public key.
     * If manifest is {@code null} than method returns {@code true} if
     * public key is {@code null}.
     * If parameter {@code allSigned} equals {@code true} and file not
     * listed in manifest than method return {@code false}. If file
     * listed in manifest but doesn't exist in JAR-file than method
     * return {@code false}.
     *
     * @param in JAR file input stream.
     * @param pubKey Public key to be tested with.
     * @param allSigned Hint which means that all files in entry must be signed.
     * @param log Logger.
     * @return {@code true} if JAR file is signed with given public key.
     * @throws IOException Thrown if JAR file or its entries could not be read.
     */
    static boolean verify(InputStream in, PublicKey pubKey, boolean allSigned, IgniteLogger log)
        throws IOException {
        assert in != null;
        assert pubKey != null;

        return verify0(in, pubKey, allSigned, log);
    }

    /**
     * Tests whether all files in given JAR file input stream are signed
     * with public key. If manifest is {@code null} than method returns
     * {@code true} if public key is null.
     *
     * @param in JAR file input stream.
     * @param pubKey Public key to be tested with.
     * @param allSigned Hint which means that all files in entry must be signed.
     * @param log Logger.
     * @return {@code true} if JAR file is signed with given public key.
     * @throws IOException Thrown if JAR file or its entries could not be read.
     */
    private static boolean verify0(InputStream in, PublicKey pubKey, boolean allSigned, IgniteLogger log)
        throws IOException {
        assert in != null;

        JarInputStream jin = null;

        try {
            jin = new JarInputStream(in, true);

            Manifest manifest = jin.getManifest();

            // Manifest must be included into a signed package.
            if (manifest == null)
                return pubKey == null;

            Set<String> manifestFiles = getSignedFiles(manifest);

            JarEntry jarEntry;

            while ((jarEntry = jin.getNextJarEntry()) != null) {
                if (jarEntry.isDirectory())
                    continue;

                // Verify by reading the file if altered.
                // Will return quietly if no problem.
                verifyDigestsImplicitly(jin);

                if (!verifyEntry(jarEntry, manifest, pubKey, allSigned, true))
                    return false;

                manifestFiles.remove(jarEntry.getName());
            }

            return manifestFiles.size() <= 0;
        }
        catch (SecurityException e) {
            if (log.isDebugEnabled())
                log.debug("Got security error (ignoring): " + e.getMessage());
        }
        finally {
            U.close(jin, log);
        }

        return false;
    }

    /**
     * Tests whether all files in given JAR file are signed
     * with public key. If manifest is {@code null} than method returns
     * {@code true} if public key is null.
     * <p>
     * <strong>DO NOT REFACTOR THIS METHOD. THERE IS A SUN DEFECT ABOUT PROCESSING JAR AS
     * FILE AND AS STREAM. THE PROCESSING IS DIFFERENT.</strong>
     *
     * @param jarName JAR file name.
     * @param pubKey Public key to be tested with.
     * @param allSigned Hint which means that all files in entry must be signed.
     * @param log Logger.
     * @return {@code true} if JAR file is signed with given public key.
     * @throws IOException Thrown if JAR file or its entries could not be read.
     */
    private static boolean verify0(String jarName, PublicKey pubKey, boolean allSigned, IgniteLogger log)
        throws IOException {
        JarFile jarFile = null;

        try {
            jarFile = new JarFile(jarName, true);

            Manifest manifest = jarFile.getManifest();

            // Manifest must be included into a signed package.
            if (manifest == null)
                return pubKey == null;

            Set<String> manifestFiles = getSignedFiles(manifest);

            Enumeration<JarEntry> entries = jarFile.entries();

            while (entries.hasMoreElements()) {
                JarEntry jarEntry = entries.nextElement();

                if (jarEntry.isDirectory())
                    continue;

                // Verify by reading the file if altered.
                // Will return quietly if no problem.
                verifyDigestsImplicitly(jarFile.getInputStream(jarEntry));

                if (!verifyEntry(jarEntry, manifest, pubKey, allSigned, false))
                    return false;

                manifestFiles.remove(jarEntry.getName());
            }

            return manifestFiles.size() <= 0;
        }
        catch (SecurityException e) {
            if (log.isDebugEnabled())
                log.debug("Got security error (ignoring): " + e.getMessage());
        }
        finally {
            U.close(jarFile, log);
        }

        return false;
    }

    /**
     * Tests whether given JAR entry from manifest contains at least one
     * certificate with given public key.
     * <p>
     * Files which starts with "META-INF/" are always verified successfully.
     *
     * @param jarEntry Tested JAR entry.
     * @param manifest Manifest this entry belongs to.
     * @param pubKey Public key we are testing. If it is {@code null} returns
     *      {@code true}.
     * @param allSigned Hint which means that all files in entry must be signed.
     * @param makeCerts If {@code true} JAR entry certificates are scanned.
     *      Otherwise all JAR entry signers certificates  are scanned.
     * @return {@code true} if JAR entry is verified {@code false} otherwise.
     */
    private static boolean verifyEntry(JarEntry jarEntry, Manifest manifest, PublicKey pubKey, boolean allSigned,
        boolean makeCerts) {
        assert jarEntry != null;
        assert manifest != null;

        boolean inManifest = false;

        String entryName = jarEntry.getName();

        // Check that entry name contains in manifest file.
        if (manifest.getAttributes(entryName) != null || manifest.getAttributes("./" + entryName) != null ||
            manifest.getAttributes('/' + entryName) != null)
            inManifest = true;

        // Don't ignore files not listed in manifest and META-INF directory.
        if (allSigned && !inManifest && !entryName.toUpperCase().startsWith("META-INF/"))
            return false;

        // Looking at entries in manifest file.
        if (inManifest) {
            Certificate[] certs = !makeCerts ? jarEntry.getCertificates() : getCertificates(jarEntry);

            boolean isSigned = certs != null && certs.length > 0;

            if (!isSigned || pubKey != null && !findKeyInCertificates(pubKey, certs))
                return false;
        }

        return true;
    }

    /**
     * This checks that everything is valid and unchanged from the digest
     * listed in the manifest next to the name.
     *
     * @param in JAR file or JAR entry input stream.
     * @throws IOException Thrown if read fails.
     */
    private static void verifyDigestsImplicitly(InputStream in) throws IOException {
        byte[] buffer = new byte[BUF_SIZE];

        while (in.read(buffer, 0, buffer.length) != -1) {
            // Just read the entry. Will throw a SecurityException if signature
            // or digest check fails. Since we instantiated JarFile with parameter
            // true, that tells it to verify that the files match the digests
            // and haven't been changed.
        }
    }

    /**
     * Tests whether given certificate contains public key or not.
     *
     * @param key Public key which we are looking for.
     * @param certs Certificate which should be tested.
     * @return {@code true} if certificate contains given key and
     *      {@code false} if not.
     */
    private static boolean findKeyInCertificates(PublicKey key, Certificate[] certs) {
        if (key == null || certs == null)
            return false;

        for (Certificate cert : certs) {
            if (cert.getPublicKey().equals(key))
                return true;
        }

        return false;
    }

    /**
     * Gets all signed files from the manifest.
     * <p>
     * It scans all manifest entries and their attributes. If there is an attribute
     * name which ends with "-DIGEST" we are assuming that manifest entry name is a
     * signed file name.
     *
     * @param manifest JAR file manifest.
     * @return Either empty set if none found or set of signed file names.
     */
    private static Set<String> getSignedFiles(Manifest manifest) {
        Set<String> fileNames = new HashSet<>();

        Map<String, Attributes> entries = manifest.getEntries();

        if (entries != null && entries.size() > 0) {
            for (Map.Entry<String, Attributes> entry : entries.entrySet()) {
                Attributes attrs = entry.getValue();

                for (Map.Entry<Object, Object> attrEntry : attrs.entrySet()) {
                    if (attrEntry.getKey().toString().toUpperCase().endsWith("-DIGEST")) {
                        fileNames.add(entry.getKey());

                        break;
                    }
                }
            }
        }

        return fileNames;
    }

    /**
     * Gets all JAR file entry certificates.
     * Method scans entry for signers and than collects all their certificates.
     *
     * @param entry JAR file entry.
     * @return Array of certificates which corresponds to the entry.
     */
    private static Certificate[] getCertificates(JarEntry entry) {
        Certificate[] certs = null;

        CodeSigner[] signers = entry.getCodeSigners();

        // Extract the certificates in each code signer's cert chain.
        if (signers != null) {
            List<Certificate> certChains = new ArrayList<>();

            for (CodeSigner signer : signers)
                certChains.addAll(signer.getSignerCertPath().getCertificates());

            // Convert into a Certificate[]
            return certChains.toArray(new Certificate[certChains.size()]);
        }

        return certs;
    }
}
