# Copyright 2019 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Signing Model Objects

This module contains classes that encapsulate data about the signing process.
"""

import enum
import os.path
import re
import string

from signing import commands


def _get_unexpired_identities():
    """Returns a set of the SHA-1 hashes of unexpired code signing identities

    Raises:
        ValueError: If no unexpired code signing identities are found.
    """
    # Avoid -v because it filters out self-signed certificates.
    command = ['security', 'find-identity', '-p', 'codesigning']
    output = commands.run_command_output(command)

    matches = re.finditer(
        rb'\d+\) (?P<id>[0-9A-Fa-f]{40}) "[^"]+"( \((?P<error>[^\)]+)\))?',
        output,
        flags=re.MULTILINE,
    )

    identities = set()
    for match in matches:
        # Exclude expired certificates. Other errors are ignored.
        if match.group('error') == b'CSSMERR_TP_CERT_EXPIRED':
            continue

        identities.add(match.group('id'))

    if not identities:
        raise ValueError('No code signing identities found')

    return identities


def _get_identity_hash(identity):
    """Returns a string of the SHA-1 hash of a specified keychain identity.

    Args:
        identity: A string specifying the identity.

    Returns:
        A string with the hash, with a-f in lower case.

    Raises:
        ValueError: If the identity cannot be found.
    """
    if len(identity) == 40 and all(ch in string.hexdigits for ch in identity):
        return identity.lower()

    unexpired_identities = _get_unexpired_identities()

    command = ['security', 'find-certificate', '-a', '-c', identity, '-Z']
    output = commands.run_command_output(command)

    hashes = re.findall(
        b'^SHA-1 hash: ([0-9A-Fa-f]{40})$', output, flags=re.MULTILINE
    )
    if not hashes:
        raise ValueError('Cannot find identity', identity)

    valid_hashes = [h for h in hashes if h in unexpired_identities]
    if not valid_hashes:
        raise ValueError('Identity found, but expired', identity)

    return valid_hashes[0].decode('utf-8').lower()


class CodeSignedProduct(object):
    """Represents a build product that will be signed with `codesign(1)`."""

    def __init__(
        self,
        path,
        identifier,
        options=None,
        requirements=None,
        identifier_requirement=True,
        sign_with_identifier=False,
        entitlements=None,
        verify_options=None,
    ):
        """A build product to be codesigned.

        Args:
            path: The path to the product to be signed. This is relative to a
                work directory containing the build products.
            identifier: The unique identifier set when code signing. This is
                only explicitly passed with the `--identifier` flag if
                |sign_with_identifier| is True.
            options: |CodeSignOptions| flags to pass to `codesign --options`.
            requirements: String for additional `--requirements` to pass to the
                `codesign` command. These are joined with a space to the
                |config.CodeSignConfig.codesign_requirements_basic| string. See
                |CodeSignedProduct.requirements_string()| for details.
            identifier_requirement: If True, a designated identifier requirement
                based on |identifier| will be inserted into the requirements
                string. If False, then no designated requirement will be
                generated based on the identifier.
            sign_with_identifier: If True, then the identifier will be specified
                when running the `codesign` command. If False, `codesign` will
                infer the identifier itself.
            entitlements: File name of the entitlements file to sign the product
                with. The file should reside in the |Paths.packaging_dir|.
            verify_options: |VerifyOptions| flags to pass to `codesign
                --verify`.
        """
        self.path = path
        self.identifier = identifier
        if options and not isinstance(options, CodeSignOptions):
            raise ValueError('Must be a CodeSignOptions')
        self.options = options
        self.requirements = requirements
        self.identifier_requirement = identifier_requirement
        self.sign_with_identifier = sign_with_identifier
        self.entitlements = entitlements
        if verify_options and not isinstance(verify_options, VerifyOptions):
            raise ValueError('Must be a VerifyOptions')
        self.verify_options = verify_options

    def requirements_string(self, config):
        """Produces a full requirements string for the product.

        Args:
            config: A |config.CodeSignConfig| object.

        Returns:
            A string for designated requirements of the product, which can be
            passed to `codesign --requirements`.
        """
        # If the signing identity indicates ad-hoc (i.e. no real signing
        # identity), do not enforce any requirements. Ad hoc signing will append
        # a hash to the identifier, which would violate the
        # identifier_requirement and most other requirements that would be
        # specified.
        #
        # Similarly, if no explicit requirements are available, let codesign
        # --sign use its defaults, which should be appropriate in any case where
        # requirement customization is unnecessary.
        if config.identity == '-' or (
            not self.requirements and not config.codesign_requirements_basic
        ):
            return ''

        reqs = []
        if self.identifier_requirement:
            reqs.append(
                'designated => identifier "{identifier}"'.format(
                    identifier=self.identifier
                )
            )
        if self.requirements:
            reqs.append(self.requirements)
        if config.codesign_requirements_basic:
            reqs.append(config.codesign_requirements_basic)
        return ' '.join(reqs)

    def __repr__(self):
        return (
            'CodeSignedProduct(identifier={0.identifier}, '
            'options={0.options}, path={0.path})'.format(self)
        )


class VerifyOptions(enum.Flag):
    """Enum for the options that can be specified when validating the results of
    code signing.

    These options are passed to `codesign --verify` after the
    |CodeSignedProduct| has been signed.
    """

    DEEP = enum.auto()
    STRICT = enum.auto()
    NO_STRICT = enum.auto()
    IGNORE_RESOURCES = enum.auto()

    def to_list(self):
        result = []
        values = {
            self.DEEP: '--deep',
            self.STRICT: '--strict',
            self.NO_STRICT: '--no-strict',
            self.IGNORE_RESOURCES: '--ignore-resources',
        }

        for key, value in values.items():
            if key & self:
                result.append(value)

        return sorted(result)


class CodeSignOptions(enum.Flag):
    """Enum for the options that can be specified when signing the code.

    These options are passed to `codesign --sign --options`.
    """

    RESTRICT = enum.auto()
    LIBRARY_VALIDATION = enum.auto()
    HARDENED_RUNTIME = enum.auto()
    KILL = enum.auto()
    # Specify the components of HARDENED_RUNTIME that are also available on
    # older macOS versions.
    FULL_HARDENED_RUNTIME_OPTIONS = (
        RESTRICT | LIBRARY_VALIDATION | HARDENED_RUNTIME | KILL
    )

    def to_comma_delimited_string(self):
        result = []
        values = {
            self.RESTRICT: 'restrict',
            self.LIBRARY_VALIDATION: 'library',
            self.HARDENED_RUNTIME: 'runtime',
            self.KILL: 'kill',
        }

        for key, value in values.items():
            if key & self:
                result.append(value)

        return ','.join(sorted(result))


class NotarizeAndStapleLevel(enum.Enum):
    """An enum specifying the level of notarization and stapling to do.

    `NONE` means no notarization tasks should be performed.

    `WAIT_NOSTAPLE` means to submit the signed application and packaging to
    Apple for notarization, and wait for a reply, but not to staple the
    resulting notarization ticket.

    `STAPLE` means to submit the signed application and packaging to Apple for
    notarization, wait for a reply, and staple the resulting notarization
    ticket.
    """

    NONE = 0
    WAIT_NOSTAPLE = 1
    STAPLE = 2

    def should_notarize(self):
        return self.value > self.NONE.value

    def should_staple(self):
        return self.value > self.WAIT_NOSTAPLE.value

    def __str__(self):
        return self.name.lower().replace('_', '-')

    @classmethod
    def from_string(cls, str):
        try:
            return cls[str.upper().replace('-', '_')]
        except KeyError:
            raise ValueError(f'Invalid NotarizeAndStapleLevel: {str}')


class Distribution(object):
    """A Distribution represents a final, signed, and potentially channel-
    customized Chrome product.

    Channel customization refers to modifying parts of the app bundle structure
    to have different file names, internal identifiers, and assets.
    """

    def __init__(
        self,
        channel=None,
        branding_code=None,
        app_name_fragment=None,
        packaging_name_fragment=None,
        product_dirname=None,
        creator_code=None,
        channel_customize=False,
        package_as_dmg=True,
        package_as_pkg=False,
        package_as_zip=False,
        inflation_kilobytes=0,
        direct_launch_scheme=None,
    ):
        """Creates a new Distribution object. All arguments are optional.

        Args:
            channel: The release channel for the product.
            branding_code: A branding code helps track how users acquired the
                product from various marketing channels.
            app_name_fragment: If present, this string fragment is appended to
                the |config.CodeSignConfig.app_product|. This renames the binary
                and outer app bundle.
            packaging_name_fragment: If present, this is appended to the
                |config.CodeSignConfig.packaging_basename| to help differentiate
                different |branding_code|s.
            product_dirname: If present, this string value is set in the app's
                Info.plist with the key "CrProductDirName". This key influences
                the browser's default user-data-dir location.
            creator_code: If present, this will set a new macOS creator code
                in the Info.plist "CFBundleSignature" key and in the PkgInfo
                file. If this is not specified, the original values from the
                build products will be kept.
            channel_customize: If True, then the product will be modified in
                several ways:
                - The |channel| will be appended to the
                  |config.CodeSignConfig.base_bundle_id|.
                - The product will be renamed with |app_name_fragment|.
                - Different assets will be used for icons in the app.
            package_as_dmg: If True, then a .dmg file will be created containing
                the product.
            package_as_pkg: If True, then a .pkg file will be created containing
                the product.
            package_as_zip: If True, then a .zip file will be created containing
                the product.
            inflation_kilobytes: If non-zero, a blob of this size will be
                inserted into the DMG. Incompatible with package_as_pkg = True.
            direct_launch_scheme: The URL scheme that launches this specific
                product (e.g. "google-chrome"). If None, the scheme will be
                removed from the Info.plist if present.
        """
        if channel_customize:
            # Side-by-side channels must have a distinct names and creator
            # codes, as well as keep their user data in separate locations.
            assert channel
            assert app_name_fragment
            assert product_dirname
            assert creator_code

        self.channel = channel
        self.branding_code = branding_code
        self.app_name_fragment = app_name_fragment
        self.packaging_name_fragment = packaging_name_fragment
        self.product_dirname = product_dirname
        self.creator_code = creator_code
        self.channel_customize = channel_customize
        self.package_as_zip = package_as_zip
        self.package_as_dmg = package_as_dmg
        self.package_as_pkg = package_as_pkg
        self.inflation_kilobytes = inflation_kilobytes
        self.direct_launch_scheme = direct_launch_scheme

        # inflation_kilobytes are only inserted into DMGs
        assert not self.inflation_kilobytes or self.package_as_dmg

    def brandless_copy(self):
        """Derives and returns a copy of this Distribution object, identical
        except for not having a branding code.

        This is useful in the case where a non-branded app bundle needs to be
        created with otherwise the same configuration.
        """
        return Distribution(
            self.channel,
            None,
            self.app_name_fragment,
            self.packaging_name_fragment,
            self.product_dirname,
            self.creator_code,
            self.channel_customize,
            self.package_as_dmg,
            self.package_as_pkg,
            self.package_as_zip,
            self.inflation_kilobytes,
            self.direct_launch_scheme,
        )

    def to_config(self, base_config):
        """Produces a derived |config.CodeSignConfig| for the Distribution.

        Args:
            base_config: The base CodeSignConfig to derive.

        Returns:
            A new CodeSignConfig instance that uses information in the
            Distribution to alter various properties of the |base_config|.
        """
        this = self

        class DistributionCodeSignConfig(base_config.__class__):
            @property
            def base_config(self):
                return base_config

            @property
            def distribution(self):
                return this

            @property
            def app_product(self):
                if this.channel_customize:
                    return '{} {}'.format(
                        base_config.app_product, this.app_name_fragment
                    )
                return base_config.app_product

            @property
            def base_bundle_id(self):
                base_bundle_id = base_config.base_bundle_id
                if this.channel_customize:
                    return base_bundle_id + '.' + this.channel
                return base_bundle_id

            @property
            def provisioning_profile_basename(self):
                profile_basename = base_config.provisioning_profile_basename
                if not profile_basename:
                    return profile_basename

                if this.channel_customize:
                    profile_basename = '{}_{}'.format(
                        profile_basename, this.app_name_fragment
                    )
                if base_config.identity:
                    profile_basename = '{}.{}'.format(
                        profile_basename,
                        _get_identity_hash(base_config.identity),
                    )

                return profile_basename

            @property
            def packaging_basename(self):
                if this.packaging_name_fragment:
                    return '{}-{}-{}'.format(
                        self.app_product.replace(' ', ''),
                        self.version,
                        this.packaging_name_fragment,
                    )
                return super(
                    DistributionCodeSignConfig, self
                ).packaging_basename

        return DistributionCodeSignConfig(
            **pick(
                base_config,
                (
                    'invoker',
                    'identity',
                    'installer_identity',
                    'codesign_requirements_basic',
                ),
            )
        )


class Paths(object):
    """Paths holds the three file path contexts for signing operations.

    The input directory always remains un-modified.
    The output directory is where final, signed products are stored.
    The work directory is set by internal operations.
    """

    def __init__(self, input, output, work):
        self._input = os.path.abspath(input)
        self._output = os.path.abspath(output)
        self._work = work
        if self._work:
            self._work = os.path.abspath(self._work)

    @property
    def input(self):
        return self._input

    @property
    def output(self):
        return self._output

    @property
    def work(self):
        return self._work

    def packaging_dir(self, config):
        """Returns the path to the product packaging directory, which contains
        scripts and assets used in signing.

        Args:
            config: The |config.CodeSignConfig| object.

        Returns:
            Path to the packaging directory.
        """
        return os.path.join(self.input, '{} Packaging'.format(config.product))

    def replace_work(self, new_work):
        """Creates a new Paths with the same input and output directories, but
        with |work| set to |new_work|."""
        return Paths(self.input, self.output, new_work)

    def __eq__(self, other):
        if not isinstance(other, self.__class__):
            return False
        return (
            self._input == other._input
            and self._output == other._output
            and self._work == other._work
        )

    def __repr__(self):
        return (
            'Paths(input={0.input}, output={0.output}, work={0.work})'.format(
                self
            )
        )


def pick(o, keys):
    """Returns a dictionary with the values of |o| from the keys specified
    in |keys|.

    Args:
        o: object or dictionary, An object to take values from.
        keys: list of string, Keys to pick from |o|.

    Returns:
        A new dictionary with keys from |keys| and values from |o|. Keys not
        in |o| will be omitted.
    """
    d = {}
    iterable = hasattr(o, '__getitem__')
    for k in keys:
        if hasattr(o, k):
            d[k] = getattr(o, k)
        elif iterable and k in o:
            d[k] = o[k]
    return d
