HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/anaconda3/Lib/site-packages/anaconda_navigator/api/external_apps/bundle/installers.py
# -*- coding: utf-8 -*-

"""Additional utility functions to use for installable applications."""

from __future__ import annotations

__all__ = ['install_launch', 'install_dmg']

import stat

import webbrowser
import collections.abc
import contextlib
import hashlib
import io
import os
import posixpath
import shutil
import subprocess  # nosec: B404
import sys
import tempfile
import typing
import urllib.parse

import requests

from anaconda_navigator.config import preferences
from anaconda_navigator.static.content import DATA_PATH
from anaconda_navigator.utils.logs import logger

if typing.TYPE_CHECKING:
    from .. import base


def safe_domain(domain: str) -> bool:
    """Check if installer at :code:`domain` is safe to use."""
    if preferences.ALLOW_UNSAFE_INSTALLERS:
        return True
    domain = f'.{domain}'
    return any(domain.endswith(item) for item in preferences.SAFE_INSTALLER_DOMAINS)


def safe_filename(filename: str) -> str | None:
    """Check if installer at :code:`filename` is safe to use."""
    if not filename:
        return None
    filename = os.path.abspath(os.path.join(DATA_PATH, filename))

    if preferences.ALLOW_UNSAFE_INSTALLERS:
        return filename

    try:
        if os.path.samefile(os.path.commonpath([DATA_PATH, filename]), DATA_PATH):
            return filename
    except ValueError:
        return None

    return None


@contextlib.contextmanager
def retrieve(url: str) -> collections.abc.Iterator[str]:
    """
    Download installer.

    Must be used as a context manager.

    :param url: URI of the installer (might be a path to local file).
    :return: Path to a downloaded file.
    """
    filename: str | None
    parsed: urllib.parse.SplitResult = urllib.parse.urlsplit(url)
    if (parsed.scheme in ('', 'file')) and (filename := safe_filename(parsed.path)):
        yield filename
    elif safe_domain(parsed.hostname or ''):
        response: requests.Response
        with requests.get(url, stream=True, timeout=30.0, verify=True) as response:
            response.raise_for_status()

            descriptor: int
            stream: io.BufferedWriter
            descriptor, filename = tempfile.mkstemp(suffix=posixpath.splitext(parsed.path)[1])
            with os.fdopen(descriptor, 'wb') as stream:
                chunk: bytes
                for chunk in response.iter_content(chunk_size=4_194_304):
                    stream.write(chunk)

        try:
            yield filename
        finally:
            try:
                os.remove(filename)
            except OSError:
                pass
    else:
        raise ValueError('source is unsafe')


@contextlib.contextmanager
def retrieve_and_validate(url: str, **kwargs: str | None) -> collections.abc.Iterator[str]:
    """Same as :func:`~retrieve`, but also checks for resulting files hash sums."""
    filename: str
    with retrieve(url) as filename:
        query: list[tuple[hashlib._Hash, str]] = [
            (hashlib.new(name), value)
            for name, value in kwargs.items()
            if value
        ]
        if query:
            expectation: str
            hasher: hashlib._Hash
            stream: io.BufferedReader
            with open(filename, 'rb') as stream:
                buffer: bytes
                while buffer := stream.read(4_294_967_296):
                    for hasher, _ in query:
                        hasher.update(buffer)
            for hasher, expectation in query:
                if hasher.hexdigest() != expectation:
                    raise ValueError(
                        f'installer hash missmatch for {os.path.basename(filename)}: '
                        f'got {hasher.hexdigest()!r} instead of {expectation!r}'
                    )
        yield filename


def install_launch(  # pylint: disable=too-many-arguments
        app: 'base.BaseInstallableApp',  # pylint: disable=unused-argument
        *,
        installer_url: str,
        fallback_url: str,
        flags: collections.abc.Sequence[str] = (),
        md5: str | None = None,
        sha256: str | None = None,
) -> None:
    """
    Install application by launching an installer.

    :param installer_url: URL to installer.
    :param fallback_url: URL to open in browser if installer fails.
    :param flags: Extra command line flags to pass to the installer.
    :param md5: Expected MD5 hash of the installer file.
    :param sha256: Expected SHA256 hash of the installer file.
    """
    try:
        with retrieve_and_validate(installer_url, md5=md5, sha256=sha256) as filename:
            if sys.platform in ('linux', 'darwin'):
                info: os.stat_result = os.stat(filename)
                mode: int = info.st_mode | stat.S_IXUSR | stat.S_IXGRP
                if mode != info.st_mode:
                    os.chmod(filename, mode)
            subprocess.check_call([filename, *flags])  # nosec: B603
    except Exception:  # pylint: disable=broad-exception-caught
        webbrowser.open(fallback_url)


def install_dmg(  # pylint: disable=too-many-arguments
        app: 'base.BaseInstallableApp',  # pylint: disable=unused-argument
        *,
        installer_url: str,
        fallback_url: str,
        md5: str | None = None,
        reinstall: bool = False,
        sha256: str | None = None,
        system: bool = True,
) -> None:
    """
    Install application from a DMG installer.

    .. warning::

        Works only on macOS.

    :param installer_url: URL to installer.
    :param fallback_url: URL to open in browser if installer fails.
    :param md5: Expected MD5 hash of the installer file.
    :param reinstall: Reinstall application if it is already installed.
    :param sha256: Expected SHA256 hash of the installer file.
    :param system: Install application system-wide instead of "only for user".
    """
    try:
        import dmglib  # pylint: disable=import-error,import-outside-toplevel

        filename: str
        with retrieve_and_validate(installer_url, md5=md5, sha256=sha256) as filename:
            if dmglib.dmg_already_attached(filename):
                dmglib.dmg_detach_already_attached(filename)

            image: dmglib.DiskImage = dmglib.DiskImage(filename)
            try:
                if image.has_license_agreement():
                    logger.debug('DMG %r requires user to accept license agreement manually', installer_url)
                    raise ValueError('installer requires user to accept license agreement')

                if system:
                    target: str = '/Applications'
                else:
                    target = os.path.join(os.environ['HOME'], 'Applications')

                applications: list[tuple[str, str]] = [
                    (name, os.path.join(root, name))
                    for root in image.attach()
                    for name in os.listdir(root)
                    if name.endswith('.app')
                ]
                if not applications:
                    logger.debug('DMG %r does not contain any applications', installer_url)
                    raise ValueError('Installer does not contain any applications')

                app_name: str
                app_root: str
                for app_name, app_root in applications:
                    app_target: str = os.path.join(target, app_name)
                    if os.path.exists(app_target):
                        if reinstall:
                            shutil.rmtree(app_target)
                        else:
                            continue
                    shutil.copytree(app_root, app_target, symlinks=True)
            finally:
                image.detach()
    except Exception:  # pylint: disable=broad-exception-caught
        webbrowser.open(fallback_url)