/*
 * Copyright (C) 2022 The Android Open Source Project
 *
 * Licensed 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 android.content.pm.parsing;

import static android.content.pm.PackageManager.INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES;

import android.annotation.CheckResult;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.content.pm.SigningDetails;
import android.content.pm.parsing.result.ParseInput;
import android.content.pm.parsing.result.ParseResult;
import android.os.Build;
import android.os.FileUtils;
import android.os.SystemProperties;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Slog;
import android.util.apk.ApkSignatureVerifier;

import com.android.internal.util.ArrayUtils;
import com.android.modules.utils.build.UnboundedSdkLevel;

import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.EncodedKeySpec;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;

/** @hide */
public class FrameworkParsingPackageUtils {

    private static final String TAG = "FrameworkParsingPackageUtils";

    /**
     * For those names would be used as a part of the file name. Limits size to 223 and reserves 32
     * for the OS.
     */
    private static final int MAX_FILE_NAME_SIZE = 223;

    public static final int PARSE_IGNORE_OVERLAY_REQUIRED_SYSTEM_PROPERTY = 1 << 7;
    public static final int PARSE_APK_IN_APEX = 1 << 9;

    /**
     * Check if the given name is valid.
     *
     * @param name The name to check.
     * @param requireSeparator {@code true} if the name requires containing a separator at least.
     * @param requireFilename {@code true} to apply file name validation to the given name. It also
     *                        limits length of the name to the {@link #MAX_FILE_NAME_SIZE}.
     * @return Success if it's valid.
     */
    public static String validateName(String name, boolean requireSeparator,
            boolean requireFilename) {
        final int N = name.length();
        boolean hasSep = false;
        boolean front = true;
        for (int i = 0; i < N; i++) {
            final char c = name.charAt(i);
            if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
                front = false;
                continue;
            }
            if (!front) {
                if ((c >= '0' && c <= '9') || c == '_') {
                    continue;
                }
            }
            if (c == '.') {
                hasSep = true;
                front = true;
                continue;
            }
            return "bad character '" + c + "'";
        }
        if (requireFilename) {
            if (!FileUtils.isValidExtFilename(name)) {
                return "Invalid filename";
            } else if (N > MAX_FILE_NAME_SIZE) {
                return "the length of the name is greater than " + MAX_FILE_NAME_SIZE;
            }
        }
        return hasSep || !requireSeparator ? null : "must have at least one '.' separator";
    }

    /**
     * @see #validateName(String, boolean, boolean)
     */
    public static ParseResult validateName(ParseInput input, String name, boolean requireSeparator,
            boolean requireFilename) {
        final String errorMessage = validateName(name, requireSeparator, requireFilename);
        if (errorMessage != null) {
            return input.error(errorMessage);
        }
        return input.success(null);
    }

    /**
     * @return {@link PublicKey} of a given encoded public key.
     */
    public static PublicKey parsePublicKey(final String encodedPublicKey) {
        if (encodedPublicKey == null) {
            Slog.w(TAG, "Could not parse null public key");
            return null;
        }

        try {
            return parsePublicKey(Base64.decode(encodedPublicKey, Base64.DEFAULT));
        } catch (IllegalArgumentException e) {
            Slog.w(TAG, "Could not parse verifier public key; invalid Base64");
            return null;
        }
    }

    /**
     * @return {@link PublicKey} of the given byte array of a public key.
     */
    public static PublicKey parsePublicKey(final byte[] publicKey) {
        if (publicKey == null) {
            Slog.w(TAG, "Could not parse null public key");
            return null;
        }

        final EncodedKeySpec keySpec;
        try {
            keySpec = new X509EncodedKeySpec(publicKey);
        } catch (IllegalArgumentException e) {
            Slog.w(TAG, "Could not parse verifier public key; invalid Base64");
            return null;
        }

        /* First try the key as an RSA key. */
        try {
            final KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            return keyFactory.generatePublic(keySpec);
        } catch (NoSuchAlgorithmException e) {
            Slog.wtf(TAG, "Could not parse public key: RSA KeyFactory not included in build");
        } catch (InvalidKeySpecException e) {
            // Not a RSA public key.
        }

        /* Now try it as a ECDSA key. */
        try {
            final KeyFactory keyFactory = KeyFactory.getInstance("EC");
            return keyFactory.generatePublic(keySpec);
        } catch (NoSuchAlgorithmException e) {
            Slog.wtf(TAG, "Could not parse public key: EC KeyFactory not included in build");
        } catch (InvalidKeySpecException e) {
            // Not a ECDSA public key.
        }

        /* Now try it as a DSA key. */
        try {
            final KeyFactory keyFactory = KeyFactory.getInstance("DSA");
            return keyFactory.generatePublic(keySpec);
        } catch (NoSuchAlgorithmException e) {
            Slog.wtf(TAG, "Could not parse public key: DSA KeyFactory not included in build");
        } catch (InvalidKeySpecException e) {
            // Not a DSA public key.
        }

        if (android.security.Flags.apkPqcHybridSigning()) {
            /* Now try it as an ML-DSA key */
            try {
                final KeyFactory keyFactory = KeyFactory.getInstance("ML-DSA");
                return keyFactory.generatePublic(keySpec);
            } catch (NoSuchAlgorithmException e) {
                Slog.wtf(TAG,
                        "Could not parse public key: ML-DSA KeyFactory not included in build");
            } catch (InvalidKeySpecException e) {
                // Not an ML-DSA public key.
            }
        }

        /* Not a supported key type */
        return null;
    }

    /**
     * Returns {@code true} if both the property name and value are empty or if the given system
     * property is set to the specified value. Properties can be one or more, and if properties are
     * more than one, they must be separated by comma, and count of names and values must be equal,
     * and also every given system property must be set to the corresponding value.
     * In all other cases, returns {@code false}
     */
    public static boolean checkRequiredSystemProperties(@Nullable String rawPropNames,
            @Nullable String rawPropValues) {
        if (TextUtils.isEmpty(rawPropNames) || TextUtils.isEmpty(rawPropValues)) {
            if (!TextUtils.isEmpty(rawPropNames) || !TextUtils.isEmpty(rawPropValues)) {
                // malformed condition - incomplete
                Slog.w(TAG, "Disabling overlay - incomplete property :'" + rawPropNames
                        + "=" + rawPropValues + "' - require both requiredSystemPropertyName"
                        + " AND requiredSystemPropertyValue to be specified.");
                return false;
            }
            // no valid condition set - so no exclusion criteria, overlay will be included.
            return true;
        }

        final String[] propNames = rawPropNames.split(",");
        final String[] propValues = rawPropValues.split(",");

        if (propNames.length != propValues.length) {
            Slog.w(TAG, "Disabling overlay - property :'" + rawPropNames
                    + "=" + rawPropValues + "' - require both requiredSystemPropertyName"
                    + " AND requiredSystemPropertyValue lists to have the same size.");
            return false;
        }
        for (int i = 0; i < propNames.length; i++) {
            // Check property value: make sure it is both set and equal to expected value
            final String currValue = SystemProperties.get(propNames[i]);
            if (!TextUtils.equals(currValue, propValues[i])) {
                return false;
            }
        }
        return true;
    }

    @CheckResult
    public static ParseResult<SigningDetails> getSigningDetails(ParseInput input,
            String baseCodePath, boolean skipVerify, boolean isStaticSharedLibrary,
            @NonNull SigningDetails existingSigningDetails, int targetSdk) {
        int minSignatureScheme = ApkSignatureVerifier.getMinimumSignatureSchemeVersionForTargetSdk(
                targetSdk);
        if (isStaticSharedLibrary) {
            // must use v2 signing scheme
            minSignatureScheme = SigningDetails.SignatureSchemeVersion.SIGNING_BLOCK_V2;
        }
        final ParseResult<SigningDetails> verified;
        if (skipVerify) {
            // systemDir APKs are already trusted, save time by not verifying; since the
            // signature is not verified and some system apps can have their V2+ signatures
            // stripped allow pulling the certs from the jar signature.
            verified = ApkSignatureVerifier.unsafeGetCertsWithoutVerification(input, baseCodePath,
                    SigningDetails.SignatureSchemeVersion.JAR);
        } else {
            verified = ApkSignatureVerifier.verify(input, baseCodePath, minSignatureScheme);
        }

        if (verified.isError()) {
            return input.error(verified);
        }

        // Verify that entries are signed consistently with the first pkg
        // we encountered. Note that for splits, certificates may have
        // already been populated during an earlier parse of a base APK.
        if (existingSigningDetails == SigningDetails.UNKNOWN) {
            return verified;
        } else {
            if (!Signature.areExactMatch(existingSigningDetails,
                    verified.getResult())) {
                return input.error(INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES,
                        baseCodePath + " has mismatched certificates");
            }

            return input.success(existingSigningDetails);
        }
    }

    /**
     * Computes the minSdkVersion to use at runtime. If the package is not compatible with this
     * platform, populates {@code outError[0]} with an error message.
     * <p>
     * If {@code minCode} is not specified, e.g. the value is {@code null}, then behavior varies
     * based on the {@code platformSdkVersion}:
     * <ul>
     * <li>If the platform SDK version is greater than or equal to the
     * {@code minVers}, returns the {@code mniVers} unmodified.
     * <li>Otherwise, returns -1 to indicate that the package is not
     * compatible with this platform.
     * </ul>
     * <p>
     * Otherwise, the behavior varies based on whether the current platform
     * is a pre-release version, e.g. the {@code platformSdkCodenames} array
     * has length > 0:
     * <ul>
     * <li>If this is a pre-release platform and the value specified by
     * {@code targetCode} is contained within the array of allowed pre-release
     * codenames, this method will return {@link Build.VERSION_CODES#CUR_DEVELOPMENT}.
     * <li>If this is a released platform, this method will return -1 to
     * indicate that the package is not compatible with this platform.
     * </ul>
     *
     * @param minVers              minSdkVersion number, if specified in the application manifest,
     *                             or 1 otherwise
     * @param minCode              minSdkVersion code, if specified in the application manifest, or
     *                             {@code null} otherwise
     * @param platformSdkVersion   platform SDK version number, typically Build.VERSION.SDK_INT
     * @param platformSdkCodenames array of allowed prerelease SDK codenames for this platform
     * @return the minSdkVersion to use at runtime if successful
     */
    public static ParseResult<Integer> computeMinSdkVersion(@IntRange(from = 1) int minVers,
            @Nullable String minCode, @IntRange(from = 1) int platformSdkVersion,
            @NonNull String[] platformSdkCodenames, @NonNull ParseInput input) {
        // If it's a release SDK, make sure we meet the minimum SDK requirement.
        if (minCode == null) {
            if (minVers <= platformSdkVersion) {
                return input.success(minVers);
            }

            // We don't meet the minimum SDK requirement.
            return input.error(PackageManager.INSTALL_FAILED_OLDER_SDK,
                    "Requires newer sdk version #" + minVers
                            + " (current version is #" + platformSdkVersion + ")");
        }

        // If it's a pre-release SDK and the codename matches this platform, we
        // definitely meet the minimum SDK requirement.
        if (matchTargetCode(platformSdkCodenames, minCode)) {
            return input.success(Build.VERSION_CODES.CUR_DEVELOPMENT);
        }

        // STOPSHIP: hack for the pre-release SDK
        if (platformSdkCodenames.length == 0 && "CinnamonBun".equals(minCode)) {
            Slog.w(TAG, "Parsed package requires min development platform " + minCode
                    + ", returning current version " + Build.VERSION.SDK_INT);
            return input.success(Build.VERSION.SDK_INT);
        }

        // Otherwise, we're looking at an incompatible pre-release SDK.
        if (platformSdkCodenames.length > 0) {
            return input.error(PackageManager.INSTALL_FAILED_OLDER_SDK,
                    "Requires development platform " + minCode
                            + " (current platform is any of "
                            + Arrays.toString(platformSdkCodenames) + ")");
        } else {
            return input.error(PackageManager.INSTALL_FAILED_OLDER_SDK,
                    "Requires development platform " + minCode
                            + " but this is a release platform.");
        }
    }

    /**
     * Computes the targetSdkVersion to use at runtime. If the package is not compatible with this
     * platform, populates {@code outError[0]} with an error message.
     * <p>
     * If {@code targetCode} is not specified, e.g. the value is {@code null}, then the {@code
     * targetVers} will be returned unmodified.
     * <p>
     * When {@code allowUnknownCodenames} is false, the behavior varies based on whether the
     * current platform is a pre-release version, e.g. the {@code platformSdkCodenames} array has
     * length > 0:
     * <ul>
     * <li>If this is a pre-release platform and the value specified by
     * {@code targetCode} is contained within the array of allowed pre-release
     * codenames, this method will return {@link Build.VERSION_CODES#CUR_DEVELOPMENT}.
     * <li>If this is a released platform, this method will return -1 to
     * indicate that the package is not compatible with this platform.
     * </ul>
     * <p>
     * When {@code allowUnknownCodenames} is true, any codename that is not known (presumed to be
     * a codename announced after the build of the current device) is allowed and this method will
     * return {@link Build.VERSION_CODES#CUR_DEVELOPMENT}.
     *
     * @param targetVers            targetSdkVersion number, if specified in the application
     *                              manifest, or 0 otherwise
     * @param targetCode            targetSdkVersion code, if specified in the application manifest,
     *                              or {@code null} otherwise
     * @param platformSdkCodenames  array of allowed pre-release SDK codenames for this platform
     * @param allowUnknownCodenames allow unknown codenames, if true this method will accept unknown
     *                              (presumed to be future) codenames
     * @return the targetSdkVersion to use at runtime if successful
     */
    public static ParseResult<Integer> computeTargetSdkVersion(@IntRange(from = 0) int targetVers,
            @Nullable String targetCode, @NonNull String[] platformSdkCodenames,
            @NonNull ParseInput input, boolean allowUnknownCodenames) {
        // If it's a release SDK, return the version number unmodified.
        if (targetCode == null) {
            return input.success(targetVers);
        }

        // If it's a pre-release SDK and the codename matches this platform, it
        // definitely targets this SDK.
        if (matchTargetCode(platformSdkCodenames, targetCode)) {
            return input.success(Build.VERSION_CODES.CUR_DEVELOPMENT);
        }

        // STOPSHIP: hack for the pre-release SDK
        if (platformSdkCodenames.length == 0 && "CinnamonBun".equals(targetCode)) {
            Slog.w(TAG, "Parsed package requires development platform " + targetCode
                    + ", returning current version " + Build.VERSION.SDK_INT);
            return input.success(Build.VERSION.SDK_INT);
        }

        try {
            if (allowUnknownCodenames && UnboundedSdkLevel.isAtMost(targetCode)) {
                return input.success(Build.VERSION_CODES.CUR_DEVELOPMENT);
            }
        } catch (IllegalArgumentException e) {
            return input.error(PackageManager.INSTALL_FAILED_OLDER_SDK, "Bad package SDK");
        }

        // Otherwise, we're looking at an incompatible pre-release SDK.
        if (platformSdkCodenames.length > 0) {
            return input.error(PackageManager.INSTALL_FAILED_OLDER_SDK,
                    "Requires development platform " + targetCode
                            + " (current platform is any of "
                            + Arrays.toString(platformSdkCodenames) + ")");
        } else {
            return input.error(PackageManager.INSTALL_FAILED_OLDER_SDK,
                    "Requires development platform " + targetCode
                            + " but this is a release platform.");
        }
    }

    /**
     * Computes the maxSdkVersion. If the package is not compatible with this platform, populates
     * {@code outError[0]} with an error message.
     * <p>
     * {@code maxVers} is compared against {@code platformSdkVersion}. If {@code maxVers} is less
     * than the {@code platformSdkVersion} then populates {@code outError[0]} with an error message.
     * Otherwise, it returns {@code maxVers} unmodified.
     *
     * @param maxVers maxSdkVersion number, if specified in the application manifest, or {@code
     *                Integer.MAX_VALUE} otherwise
     * @param platformSdkVersion   platform SDK version number, typically Build.VERSION.SDK_INT
     * @return the maxSdkVersion that was recognised or an error if the condition is not satisfied
     */
    public static ParseResult<Integer> computeMaxSdkVersion(@IntRange(from = 0) int maxVers,
            @IntRange(from = 1) int platformSdkVersion, @NonNull ParseInput input) {
        if (platformSdkVersion > maxVers) {
            return input.error(PackageManager.INSTALL_FAILED_NEWER_SDK,
                    "Requires max SDK version " + maxVers + " but is "
                            + platformSdkVersion);
        } else {
            return input.success(maxVers);
        }
    }

    /**
     * Matches a given {@code targetCode} against a set of release codeNames. Target codes can
     * either be of the form {@code [codename]}" (e.g {@code "Q"}) or of the form {@code
     * [codename].[fingerprint]} (e.g {@code "Q.cafebc561"}).
     */
    private static boolean matchTargetCode(@NonNull String[] codeNames,
            @NonNull String targetCode) {
        final String targetCodeName;
        final int targetCodeIdx = targetCode.indexOf('.');
        if (targetCodeIdx == -1) {
            targetCodeName = targetCode;
        } else {
            targetCodeName = targetCode.substring(0, targetCodeIdx);
        }
        return ArrayUtils.contains(codeNames, targetCodeName);
    }
}
