# -*- coding: utf-8; Mode: Python; indent-tabs-mode: nil; tab-width: 4 -*-

# Copyright (C) 2005, 2006, 2007, 2008, 2009 Canonical Ltd.
# Copyright (C) 2010 Mario Limonciello
#
# Functions useful for the final install.py script and for ubiquity
# plugins to use
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

import errno
import fcntl
import hashlib
import os
import re
import select
import shutil
import stat
import subprocess
import sys
import syslog
import traceback

from apt.cache import Cache
from apt.progress.base import InstallProgress
from apt.progress.text import AcquireProgress
import apt
import apt_pkg
import debconf

from ubiquity import misc, osextras
from ubiquity.casper import get_casper

minimal_install_rlist_path = os.path.join(
    '/cdrom',
    get_casper('LIVE_MEDIA_PATH', 'casper').lstrip('/'),
    'filesystem.manifest-minimal-remove')


def debconf_disconnect():
    """Disconnect from debconf. This is only to be used as a subprocess
    preexec_fn helper."""
    os.environ['DEBIAN_FRONTEND'] = 'noninteractive'
    if 'DEBIAN_HAS_FRONTEND' in os.environ:
        del os.environ['DEBIAN_HAS_FRONTEND']
    if 'DEBCONF_USE_CDEBCONF' in os.environ:
        # Probably not a good idea to use this in /target too ...
        del os.environ['DEBCONF_USE_CDEBCONF']


def reconfigure_preexec():
    debconf_disconnect()
    os.environ['XAUTHORITY'] = '/root/.Xauthority'


def reconfigure(target, package):
    """executes a dpkg-reconfigure into installed system to each
    package which provided by args."""
    subprocess.call(['log-output', '-t', 'ubiquity', 'chroot', target,
                     'dpkg-reconfigure', '-fnoninteractive', package],
                    preexec_fn=reconfigure_preexec, close_fds=True)


def chrex(target, *args):
    """executes commands on chroot system (provided by *args)."""
    return misc.execute('chroot', target, *args)


def set_debconf(target, question, value, db=None):
    try:
        if 'UBIQUITY_OEM_USER_CONFIG' in os.environ and db:
            dccomm = None
            dc = db
        else:
            dccomm = subprocess.Popen(['log-output', '-t', 'ubiquity',
                                       '--pass-stdout',
                                       'chroot', target,
                                       'debconf-communicate',
                                       '-fnoninteractive', 'ubiquity'],
                                      stdin=subprocess.PIPE,
                                      stdout=subprocess.PIPE, close_fds=True,
                                      universal_newlines=True)
            dc = debconf.Debconf(read=dccomm.stdout, write=dccomm.stdin)
        dc.set(question, value)
        dc.fset(question, 'seen', 'true')
    finally:
        if dccomm:
            dccomm.stdin.close()
            dccomm.wait()


def get_all_interfaces():
    """Get all non-local network interfaces."""
    ifs = []
    with open('/proc/net/dev') as ifs_file:
        # eat header
        ifs_file.readline()
        ifs_file.readline()

        for line in ifs_file:
            name = re.match(r'(.*?(?::\d+)?):', line.strip()).group(1)
            if name == 'lo':
                continue
            ifs.append(name)

    return ifs


def chroot_setup(target, x11=False):
    """Set up /target for safe package management operations."""
    if target == '/':
        return

    policy_rc_d = os.path.join(target, 'usr/sbin/policy-rc.d')
    with open(policy_rc_d, 'w') as f:
        print("""\
#!/bin/sh
exit 101""", file=f)
    os.chmod(policy_rc_d, 0o755)

    chrex(target, 'dpkg-divert',
          '--divert', '/sbin/start-stop-daemon.REAL',
          '--rename', '/sbin/start-stop-daemon')
    start_stop_daemon = os.path.join(target, 'sbin/start-stop-daemon')
    with open(start_stop_daemon, 'w') as f:
        print("""\
#!/bin/sh
echo 1>&2
echo 'Warning: Fake start-stop-daemon called, doing nothing.' 1>&2
exit 0""", file=f)
    os.chmod(start_stop_daemon, 0o755)

    initctl = os.path.join(target, 'sbin/initctl')
    if os.path.exists(initctl):
        os.rename(initctl, '%s.REAL' % initctl)
        with open(initctl, 'w') as f:
            print("""\
#!/bin/sh
if [ "$1" = version ]; then exec /sbin/initctl.REAL "$@"; fi
echo 1>&2
echo 'Warning: Fake initctl called, doing nothing.' 1>&2
exit 0""", file=f)
        os.chmod(initctl, 0o755)

    if not os.path.exists(os.path.join(target, 'proc/cmdline')):
        chrex(target, 'mount', '-t', 'proc', 'proc', '/proc')
    if not os.path.exists(os.path.join(target, 'sys/devices')):
        chrex(target, 'mount', '-t', 'sysfs', 'sysfs', '/sys')
    misc.execute('mount', '--bind', '/dev', os.path.join(target, 'dev'))
    misc.execute('mount', '--bind', '/run', os.path.join(target, 'run'))

    if x11 and 'DISPLAY' in os.environ:
        if 'SUDO_USER' in os.environ:
            xauthority = os.path.expanduser('~%s/.Xauthority' %
                                            os.environ['SUDO_USER'])
        else:
            xauthority = os.path.expanduser('~/.Xauthority')
        if os.path.exists(xauthority):
            shutil.copy(xauthority,
                        os.path.join(target, 'root/.Xauthority'))

        if not os.path.isdir(os.path.join(target, 'tmp/.X11-unix')):
            os.mkdir(os.path.join(target, 'tmp/.X11-unix'))
        misc.execute('mount', '--bind', '/tmp/.X11-unix',
                     os.path.join(target, 'tmp/.X11-unix'))


def chroot_cleanup(target, x11=False):
    """Undo the work done by chroot_setup."""
    if target == '/':
        return

    if x11 and 'DISPLAY' in os.environ:
        misc.execute('umount', os.path.join(target, 'tmp/.X11-unix'))
        try:
            os.rmdir(os.path.join(target, 'tmp/.X11-unix'))
        except OSError:
            pass
        osextras.unlink_force(os.path.join(target,
                                           'root/.Xauthority'))

    chrex(target, 'umount', '/sys')
    chrex(target, 'umount', '/proc')
    misc.execute('umount', os.path.join(target, 'run'))
    misc.execute('umount', os.path.join(target, 'dev'))

    initctl = os.path.join(target, 'sbin/initctl')
    if os.path.exists('%s.REAL' % initctl):
        os.rename('%s.REAL' % initctl, initctl)

    start_stop_daemon = os.path.join(target, 'sbin/start-stop-daemon')
    osextras.unlink_force(start_stop_daemon)
    chrex(target, 'dpkg-divert',
          '--rename', '--remove', '/sbin/start-stop-daemon')

    policy_rc_d = os.path.join(target, 'usr/sbin/policy-rc.d')
    osextras.unlink_force(policy_rc_d)


def record_installed(pkgs):
    """Record which packages we've explicitly installed so that we don't
    try to remove them later."""

    record_file = "/var/lib/ubiquity/apt-installed"
    if not os.path.exists(os.path.dirname(record_file)):
        os.makedirs(os.path.dirname(record_file))
    with open(record_file, "a") as record:
        for pkg in pkgs:
            print(pkg, file=record)


def query_recorded_installed():
    apt_installed = set()
    if os.path.exists("/var/lib/ubiquity/apt-installed"):
        with open("/var/lib/ubiquity/apt-installed") as record_file:
            for line in record_file:
                apt_installed.add(line.strip())
    apt_removed, apt_removed_recursive = query_recorded_removed()
    all_removed = apt_removed | apt_removed_recursive
    if apt_installed & all_removed:
        syslog.syslog(
            'Refusing to install %s: marked to be removed later on, so this '
            'would be redundant.' % (apt_installed & all_removed))
    apt_installed = apt_installed - all_removed
    return apt_installed


def record_removed(pkgs, recursive=False):
    """Record which packages we've like removed later"""

    record_file = "/var/lib/ubiquity/apt-removed"
    if not os.path.exists(os.path.dirname(record_file)):
        os.makedirs(os.path.dirname(record_file))
    with open(record_file, "a") as record:
        for pkg in pkgs:
            print(pkg, str(recursive).lower(), file=record)


def query_recorded_removed():
    apt_removed = set()
    apt_removed_recursive = set()
    if os.path.exists("/var/lib/ubiquity/apt-removed"):
        with open("/var/lib/ubiquity/apt-removed") as record_file:
            for line in record_file:
                if misc.create_bool(line.split()[1]):
                    apt_removed_recursive.add(line.split()[0])
                else:
                    apt_removed.add(line.split()[0])
    return (apt_removed, apt_removed_recursive)


class DebconfAcquireProgress(AcquireProgress):
    """An object that reports apt's fetching progress using debconf."""

    def __init__(self, db, title, info_starting, info):
        AcquireProgress.__init__(self)
        self.db = db
        self.title = title
        self.info_starting = info_starting
        self.info = info
        self.old_capb = None
        self.eta = 0.0

    def start(self):
        if os.environ['UBIQUITY_FRONTEND'] != 'debconf_ui':
            self.db.progress('START', 0, 100, self.title)
        if self.info_starting is not None:
            self.db.progress('INFO', self.info_starting)
        self.old_capb = self.db.capb()
        capb_list = self.old_capb.split()
        capb_list.append('progresscancel')
        self.db.capb(' '.join(capb_list))

    # TODO cjwatson 2006-02-27: implement updateStatus

    def pulse(self, owner=None):
        AcquireProgress.pulse(self, owner)
        self.percent = (((self.current_bytes + self.current_items) * 100.0) /
                        float(self.total_bytes + self.total_items))
        if self.current_cps > 0:
            self.eta = ((self.total_bytes - self.current_bytes) /
                        float(self.current_cps))

        try:
            if os.environ['UBIQUITY_FRONTEND'] != 'debconf_ui':
                self.db.progress('SET', int(self.percent))
        except debconf.DebconfError:
            return False
        if self.eta != 0.0:
            time_str = "%d:%02d" % divmod(int(self.eta), 60)
            self.db.subst(self.info, 'TIME', time_str)
            try:
                self.db.progress('INFO', self.info)
            except debconf.DebconfError:
                return False
        return True

    def stop(self):
        if self.old_capb is not None:
            self.db.capb(self.old_capb)
            self.old_capb = None
            if os.environ['UBIQUITY_FRONTEND'] != 'debconf_ui':
                self.db.progress('STOP')


class DebconfInstallProgress(InstallProgress):
    """An object that reports apt's installation progress using debconf."""

    def __init__(self, db, title, info, error=None):
        InstallProgress.__init__(self)
        self.db = db
        self.title = title
        self.info = info
        self.error_template = error
        self.started = False
        # InstallProgress uses a non-blocking status fd; our run()
        # implementation doesn't need that, and in fact we spin unless the
        # fd is blocking.
        flags = fcntl.fcntl(self.status_stream.fileno(), fcntl.F_GETFL)
        fcntl.fcntl(self.status_stream.fileno(), fcntl.F_SETFL,
                    flags & ~os.O_NONBLOCK)

    def start_update(self):
        if os.environ['UBIQUITY_FRONTEND'] != 'debconf_ui':
            self.db.progress('START', 0, 100, self.title)
        self.started = True

    def error(self, pkg, errormsg):
        if self.error_template is not None:
            self.db.subst(self.error_template, 'PACKAGE', pkg)
            self.db.subst(self.error_template, 'MESSAGE', errormsg)
            self.db.input('critical', self.error_template)
            self.db.go()

    def status_change(self, dummypkg, percent, status):
        self.percent = percent
        self.status = status
        if os.environ['UBIQUITY_FRONTEND'] != 'debconf_ui':
            self.db.progress('SET', int(percent))
        self.db.subst(self.info, 'DESCRIPTION', status)
        self.db.progress('INFO', self.info)

    def run(self, pm):
        # Create a subprocess to deal with turning apt status messages into
        # debconf protocol messages.
        control_read, control_write = os.pipe()
        child_pid = self.fork()
        if child_pid == 0:
            # child
            self.write_stream.close()
            os.close(control_write)
            try:
                while True:
                    try:
                        rlist, _, _ = select.select(
                            [self.status_stream, control_read], [], [])
                    except select.error as error:
                        if error[0] != errno.EINTR:
                            raise
                    if self.status_stream in rlist:
                        self.update_interface()
                    if control_read in rlist:
                        os._exit(0)
            except (KeyboardInterrupt, SystemExit):
                pass  # we're going to exit anyway
            except Exception:
                for line in traceback.format_exc().split('\n'):
                    syslog.syslog(syslog.LOG_WARNING, line)
            os._exit(0)

        self.status_stream.close()
        os.close(control_read)

        # Redirect stdin from /dev/null and stdout to stderr to avoid them
        # interfering with our debconf protocol stream.
        saved_stdin = os.dup(0)
        try:
            null = os.open('/dev/null', os.O_RDONLY)
            os.dup2(null, 0)
            os.close(null)
        except OSError:
            pass
        saved_stdout = os.dup(1)
        os.dup2(2, 1)

        # Make sure all packages are installed non-interactively. We
        # don't have enough passthrough magic here to deal with any
        # debconf questions they might ask.
        saved_environ_keys = ('DEBIAN_FRONTEND', 'DEBIAN_HAS_FRONTEND',
                              'DEBCONF_USE_CDEBCONF')
        saved_environ = {}
        for key in saved_environ_keys:
            if key in os.environ:
                saved_environ[key] = os.environ[key]
        os.environ['DEBIAN_FRONTEND'] = 'noninteractive'
        if 'DEBIAN_HAS_FRONTEND' in os.environ:
            del os.environ['DEBIAN_HAS_FRONTEND']
        if 'DEBCONF_USE_CDEBCONF' in os.environ:
            # Probably not a good idea to use this in /target too ...
            del os.environ['DEBCONF_USE_CDEBCONF']

        res = pm.RESULT_FAILED
        try:
            res = pm.do_install(self.write_stream.fileno())
        finally:
            # Reap the status-to-debconf subprocess.
            self.write_stream.close()
            os.write(control_write, b'\1')
            os.close(control_write)
            while True:
                try:
                    (pid, status) = os.waitpid(child_pid, 0)
                    if pid != child_pid:
                        break
                    if os.WIFEXITED(status) or os.WIFSIGNALED(status):
                        break
                except OSError:
                    break

            # Put back stdin and stdout.
            os.dup2(saved_stdin, 0)
            os.close(saved_stdin)
            os.dup2(saved_stdout, 1)
            os.close(saved_stdout)

            # Put back the environment.
            for key in saved_environ_keys:
                if key in saved_environ:
                    os.environ[key] = saved_environ[key]
                elif key in os.environ:
                    del os.environ[key]

        return res

    def finish_update(self):
        if self.started:
            if os.environ['UBIQUITY_FRONTEND'] != 'debconf_ui':
                self.db.progress('STOP')
            self.started = False


class InstallStepError(Exception):
    """Raised when an install step fails."""

    def __init__(self, message):
        Exception.__init__(self, message)


def excepthook(exctype, excvalue, exctb):
    """Crash handler. Dump the traceback to a file so that it can be
    read by the caller."""

    if (issubclass(exctype, KeyboardInterrupt) or
            issubclass(exctype, SystemExit)):
        return

    tbtext = ''.join(traceback.format_exception(exctype, excvalue, exctb))
    syslog.syslog(syslog.LOG_ERR, "Exception during installation:")
    for line in tbtext.split('\n'):
        syslog.syslog(syslog.LOG_ERR, line)
    with open('/var/lib/ubiquity/install.trace', 'w') as tbfile:
        print(tbtext, file=tbfile)

    sys.exit(1)


def archdetect():
    archdetect = subprocess.Popen(
        ['archdetect'], stdout=subprocess.PIPE, universal_newlines=True)
    answer = archdetect.communicate()[0].strip()
    try:
        return answer.split('/', 1)
    except ValueError:
        return answer, ''


def is_secure_boot():
    try:
        secureboot = ''
        secureboot_efivar = subprocess.Popen(
            ['od', '-An', '-t', 'u1',
             os.path.join('/sys/firmware/efi/efivars',
                          'SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c')],
            stdout=subprocess.PIPE, universal_newlines=True)
        answer = secureboot_efivar.communicate()[0].strip()
        if answer is not None:
            secureboot = answer.split(' ')[-1]
        if len(secureboot) > 0:
            return (int(secureboot) == 1)
        return False
    except Exception:
        return False


# TODO this can probably go away now.
def get_cache_pkg(cache, pkg):
    # work around broken has_key in python-apt 0.6.16
    try:
        return cache[pkg]
    except KeyError:
        return None


def broken_packages(cache):
    expect_count = cache._depcache.broken_count
    count = 0
    brokenpkgs = set()
    for pkg in cache.keys():
        try:
            if cache._depcache.is_inst_broken(cache._cache[pkg]):
                brokenpkgs.add(pkg)
                count += 1
        except KeyError:
            # Apparently sometimes the cache goes a bit bonkers ...
            continue
        if count >= expect_count:
            break
    return brokenpkgs


def mark_install(cache, to_install):
    to_install = sorted(to_install)

    for pkg in to_install:
        cachedpkg = get_cache_pkg(cache, pkg)
        if cachedpkg is None:
            continue
        if not cachedpkg.is_installed:
            cachedpkg.mark_install(auto_fix=False, auto_inst=False, from_user=True)
        elif cachedpkg.is_upgradable:
            auto = cachedpkg.is_auto_installed
            cachedpkg.mark_install(auto_fix=False, auto_inst=False, from_user=True)
            cachedpkg.mark_auto(auto)

    for pkg in to_install:
        cachedpkg = get_cache_pkg(cache, pkg)
        if cachedpkg is None:
            continue
        if not cachedpkg.is_installed:
            cachedpkg.mark_install(auto_fix=False, auto_inst=True, from_user=True)
        elif cachedpkg.is_upgradable:
            auto = cachedpkg.is_auto_installed
            cachedpkg.mark_install(auto_fix=False, auto_inst=True, from_user=True)
            cachedpkg.mark_auto(auto)

    if cache.broken_count > 0:
        brokenpkgs = ", ".join(sorted(broken_packages(cache)))
        syslog.syslog(syslog.LOG_WARNING, f"Try to fix these broken packages: {brokenpkgs}.")
        for pkg in cache:
            if pkg.marked_delete:
                pkg.mark_keep()
        apt.ProblemResolver(cache).resolve_by_keep()

    if cache.broken_count > 0:
        brokenpkgs = ", ".join(sorted(broken_packages(cache)))
        to_install = ", ".join(to_install)
        # We have a conflict we couldn't solve
        cache.clear()
        raise InstallStepError(
            f"Unable to install {to_install} due to the broken packages: {brokenpkgs}.")


def expand_dependencies_simple(cache, keep, to_remove, recommends=True):
    """Calculate non-removable packages.

    Return the list of packages in to_remove that clearly cannot be removed
    if we want to keep the set of packages in keep. Except in the case of
    Recommends, this is not required for correctness (we could just let apt
    figure it out), but it allows us to ask apt fewer separate questions,
    and so is faster.
    """
    keys = ['Pre-Depends', 'Depends']
    if recommends:
        keys.append('Recommends')

    to_scan = set(keep)
    to_scan_next = set()
    expanded = set(keep)
    while to_scan:
        for pkg in to_scan:
            cachedpkg = get_cache_pkg(cache, pkg)
            if cachedpkg is None:
                continue
            ver = cachedpkg._pkg.current_ver
            if ver is None:
                continue
            for key in keys:
                if key in ver.depends_list:
                    for dep_or in ver.depends_list[key]:
                        # Keep the first element of a disjunction that's
                        # installed; this mirrors what 'apt-get install' would
                        # do if you were installing the package from scratch.
                        # This doesn't handle versioned dependencies, but
                        # that's largely OK since apt will spot those later;
                        # the only case I can think of where this might have
                        # trouble is "Recommends: foo (>= 2) | bar".
                        for dep in dep_or:
                            depname = dep.target_pkg.name
                            cacheddep = get_cache_pkg(cache, depname)
                            if cacheddep is None:
                                continue
                            if cacheddep._pkg.current_ver is not None:
                                break
                        else:
                            continue
                        if depname in expanded or depname not in to_remove:
                            continue
                        expanded.add(depname)
                        to_scan_next.add(depname)
        to_scan = to_scan_next
        to_scan_next = set()

    return expanded


def locale_to_language_pack(locale):
    lang = locale.split('.')[0]
    if lang == 'zh_CN':
        return 'zh-hans'
    elif lang == 'zh_TW':
        return 'zh-hant'
    else:
        lang = locale.split('_')[0]
        return lang


def get_remove_list(cache, to_remove, recursive=False):
    to_remove = set(to_remove)
    all_removed = set()
    while True:
        removed = set()
        for pkg in to_remove:
            cachedpkg = get_cache_pkg(cache, pkg)
            if cachedpkg is not None and cachedpkg.is_installed:
                apt_error = False
                try:
                    cachedpkg.mark_delete(auto_fix=False, purge=True)
                except SystemError:
                    apt_error = True
                if apt_error:
                    cachedpkg.mark_keep()
                elif cache._depcache.broken_count > 0:
                    # If we're recursively removing packages, or if all
                    # of the broken packages are in the set of packages
                    # to remove anyway, then go ahead and try to remove
                    # them too.
                    brokenpkgs = broken_packages(cache)
                    broken_removed = set()
                    while brokenpkgs and (recursive or
                                          brokenpkgs <= to_remove):
                        broken_removed_inner = set()
                        for pkg2 in brokenpkgs:
                            cachedpkg2 = get_cache_pkg(cache, pkg2)
                            if cachedpkg2 is not None:
                                broken_removed_inner.add(pkg2)
                                try:
                                    cachedpkg2.mark_delete(auto_fix=False,
                                                           purge=True)
                                except SystemError:
                                    apt_error = True
                                    break
                        broken_removed |= broken_removed_inner
                        if apt_error or not broken_removed_inner:
                            break
                        brokenpkgs = broken_packages(cache)
                    if apt_error or cache._depcache.broken_count > 0:
                        # That didn't work. Revert all the removals we
                        # just tried.
                        for pkg2 in broken_removed:
                            get_cache_pkg(cache, pkg2).mark_keep()
                        cachedpkg.mark_keep()
                    else:
                        removed.add(pkg)
                        removed |= broken_removed
                else:
                    removed.add(pkg)
                if cache._depcache.broken_count > 0:
                    # We have a conflict we couldn't solve
                    cache.clear()
                    raise InstallStepError(
                        "Unable to remove packages due to conflicts.")

        if not removed:
            break
        to_remove -= removed
        all_removed |= removed
    return all_removed


def remove_target(source_root, target_root, relpath, st_source):
    """Remove a target file if necessary and if we can.

    On the whole, we can assume that partman-target has arranged to clear
    out the areas of the filesystem we're installing to.  However, in edge
    cases it's possible that there is still some detritus left over, and we
    want to steer a reasonable course between cavalierly destroying data and
    crashing.  So, we remove non-directories and empty directories that are
    in our way, but if a non-empty directory is in our way then we move it
    aside (adding .bak suffixes until we find something unused) instead.
    """
    targetpath = os.path.join(target_root, relpath)
    try:
        st_target = os.lstat(targetpath)
    except OSError:
        # The target does not exist.  Boring.
        return

    if stat.S_ISDIR(st_source.st_mode) and stat.S_ISDIR(st_target.st_mode):
        # One directory is as good as another, so we don't need to remove an
        # existing directory just in order to create another one.
        return

    if not stat.S_ISDIR(st_target.st_mode):
        # Installing over a non-directory is easy; just remove it.
        osextras.unlink_force(targetpath)
        return

    try:
        # Is it an empty directory?  That's easy too.
        os.rmdir(targetpath)
        return
    except OSError as e:
        if e.errno not in (errno.ENOTEMPTY, errno.EEXIST):
            raise

    # If we've got this far, then we must be trying to install a
    # non-directory over an existing non-empty directory.  The slightly
    # easier case is if it's a symlink, and if the prospective symlink
    # target hasn't been copied yet or is empty; in that case, we should try
    # to move the existing directory to the symlink target.
    if stat.S_ISLNK(st_source.st_mode):
        sourcepath = os.path.join(source_root, relpath)
        linkto = os.path.join(
            os.path.dirname(relpath), os.readlink(sourcepath))
        if linkto.startswith('/'):
            linkto = linkto[1:]
        linktarget = os.path.join(target_root, linkto)
        try:
            os.rmdir(linktarget)
        except OSError:
            pass
        if not os.path.exists(linktarget):
            try:
                os.makedirs(os.path.dirname(linktarget))
            except OSError as e:
                if e.errno != errno.EEXIST:
                    raise
            shutil.move(targetpath, linktarget)
            return

    # We're installing a non-directory over an existing non-empty directory,
    # and we have no better strategy.  Move the existing directory to a
    # backup location.
    backuppath = targetpath + '.bak'
    while True:
        if not os.path.exists(backuppath):
            os.rename(targetpath, backuppath)
            break
        else:
            backuppath = backuppath + '.bak'


def copy_file(db, sourcepath, targetpath, md5_check):
    while 1:
        if md5_check:
            sourcehash = hashlib.md5()

        with open(sourcepath, 'rb') as sourcefh:
            with open(targetpath, 'wb') as targetfh:
                while True:
                    buf = sourcefh.read(16 * 1024)
                    if not buf:
                        break
                    targetfh.write(buf)
                    if md5_check:
                        sourcehash.update(buf)

        if not md5_check:
            break

        with open(targetpath, 'rb') as targetfh:
            if md5_check:
                targethash = hashlib.md5()
            while True:
                buf = targetfh.read(16 * 1024)
                if not buf:
                    break
                targethash.update(buf)

        if targethash.digest() != sourcehash.digest():
            error_template = 'ubiquity/install/copying_error/md5'
            db.subst(error_template, 'FILE', targetpath)
            db.input('critical', error_template)
            db.go()
            response = db.get(error_template)
            if response == 'skip':
                break
            elif response == 'abort':
                syslog.syslog(syslog.LOG_ERR, 'MD5 failure on %s' % targetpath)
                sys.exit(3)
            elif response == 'retry':
                pass
        else:
            break


class InstallBase:
    def __init__(self):
        self.target = '/target'
        self.casper_path = os.path.join(
            '/cdrom', get_casper('LIVE_MEDIA_PATH', 'casper').lstrip('/'))

    def target_file(self, *args):
        return os.path.join(self.target, *args)

    def warn_broken_packages(self, pkgs, err):
        pkgs = ', '.join(pkgs)
        syslog.syslog('broken packages after installation: %s' % pkgs)
        self.db.subst('ubiquity/install/broken_install', 'ERROR', err)
        self.db.subst('ubiquity/install/broken_install', 'PACKAGES', pkgs)
        self.db.input('critical', 'ubiquity/install/broken_install')
        self.db.go()

    def progress_region(self, start, end):
        if os.environ['UBIQUITY_FRONTEND'] != 'debconf_ui':
            self.db.progress('REGION', start, end)

    def next_region(self, size=1):
        self.db.progress('SET', self.count)
        self.progress_region(self.count, self.count + size)
        self.prev_count = self.count
        self.count += size

    def nested_progress_start(self):
        if os.environ['UBIQUITY_FRONTEND'] == 'debconf_ui':
            self.db.progress('STOP')

    def nested_progress_end(self):
        if os.environ['UBIQUITY_FRONTEND'] == 'debconf_ui':
            self.db.progress('START', self.start, self.end,
                             'ubiquity/install/title')
            self.db.progress('SET', self.prev_count)

    def commit_with_verify(self, cache, fetch_progress, install_progress):
        # Hack around occasional undetected download errors in apt by doing
        # our own verification pass at the end.  See
        # https://bugs.launchpad.net/bugs/922949.  Unfortunately this means
        # clone-and-hacking most of cache.commit ...
        pm = apt_pkg.PackageManager(cache._depcache)
        fetcher = apt_pkg.Acquire(fetch_progress)
        while True:
            # fetch archives first
            res = cache._fetch_archives(fetcher, pm)

            # manually verify all the downloads
            syslog.syslog('Verifying downloads ...')
            for item in fetcher.items:
                with open(item.destfile, 'rb') as destfile:
                    st = os.fstat(destfile.fileno())
                    if st.st_size != item.filesize:
                        osextras.unlink_force(item.destfile)
                        raise IOError(
                            "%s size mismatch: %ld != %ld" %
                            (item.destfile, st.st_size, item.filesize))

                    # Mapping back to the package object is an utter pain.
                    # If we fail to find one, it's entirely possible it's a
                    # programming error and not a download error, so skip
                    # verification in such cases rather than failing.
                    destfile_base = os.path.basename(item.destfile)
                    try:
                        name, version, arch = destfile_base.split('_')
                        version = version.replace('%3a', ':')
                        arch = arch.split('.')[0]
                        if arch == 'all':
                            fullname = name
                        else:
                            fullname = '%s:%s' % (name, arch)
                            # This syntax only works on systems configured
                            # for multiarch, so check and fall back to the
                            # single-architecture syntax.
                            if fullname not in cache:
                                fullname = name
                        candidate = cache[fullname].versions[version]
                    except (KeyError, ValueError) as e:
                        syslog.syslog(
                            'Failed to find package object for %s: %s' %
                            (item.destfile, e))
                        continue

                    if candidate.sha256 is not None:
                        sha256 = hashlib.sha256()
                        for chunk in iter(lambda: destfile.read(16384), b''):
                            sha256.update(chunk)
                        if sha256.hexdigest() != candidate.sha256:
                            osextras.unlink_force(item.destfile)
                            raise IOError(
                                "%s SHA256 checksum mismatch: %s != %s" %
                                (item.destfile, sha256.hexdigest(),
                                 candidate.sha256))
            syslog.syslog('Downloads verified successfully')

            # then install
            res = cache.install_archives(pm, install_progress)
            if res == pm.RESULT_COMPLETED:
                break
            elif res == pm.RESULT_FAILED:
                raise SystemError("installArchives() failed")
            elif res == pm.RESULT_INCOMPLETE:
                pass
            else:
                raise SystemError("internal-error: unknown result code "
                                  "from InstallArchives: %s" % res)
            # reload the fetcher for media swapping
            fetcher.shutdown()
        return (res == pm.RESULT_COMPLETED)

    def do_install(self, to_install, langpacks=False):
        self.nested_progress_start()

        if langpacks:
            self.db.progress('START', 0, 10, 'ubiquity/langpacks/title')
        else:
            self.db.progress('START', 0, 10, 'ubiquity/install/title')
        self.db.progress('INFO', 'ubiquity/install/find_installables')

        self.progress_region(0, 1)
        fetchprogress = DebconfAcquireProgress(
            self.db, 'ubiquity/install/title',
            'ubiquity/install/apt_indices_starting',
            'ubiquity/install/apt_indices')

        with Cache() as cache:

            if cache._depcache.broken_count > 0:
                syslog.syslog(
                    'not installing additional packages, since there are'
                    ' broken packages: %s' % ', '.join(broken_packages(cache)))
                self.db.progress('STOP')
                self.nested_progress_end()
                return

            with cache.actiongroup():
                mark_install(cache, to_install)

            self.db.progress('SET', 1)
            self.progress_region(1, 10)
            if langpacks:
                fetchprogress = DebconfAcquireProgress(
                    self.db, 'ubiquity/langpacks/title', None,
                    'ubiquity/langpacks/packages')
                installprogress = DebconfInstallProgress(
                    self.db, 'ubiquity/langpacks/title',
                    'ubiquity/install/apt_info')
            else:
                fetchprogress = DebconfAcquireProgress(
                    self.db, 'ubiquity/install/title', None,
                    'ubiquity/install/fetch_remove')
                installprogress = DebconfInstallProgress(
                    self.db, 'ubiquity/install/title',
                    'ubiquity/install/apt_info',
                    'ubiquity/install/apt_error_install')
            chroot_setup(self.target)
            commit_error = None
            try:
                try:
                    if not self.commit_with_verify(
                            cache, fetchprogress, installprogress):
                        fetchprogress.stop()
                        installprogress.finish_update()
                        self.db.progress('STOP')
                        self.nested_progress_end()
                        return
                except IOError:
                    for line in traceback.format_exc().split('\n'):
                        syslog.syslog(syslog.LOG_ERR, line)
                    fetchprogress.stop()
                    installprogress.finish_update()
                    self.db.progress('STOP')
                    self.nested_progress_end()
                    return
                except SystemError as e:
                    for line in traceback.format_exc().split('\n'):
                        syslog.syslog(syslog.LOG_ERR, line)
                    commit_error = str(e)
            finally:
                chroot_cleanup(self.target)
            self.db.progress('SET', 10)

            cache.open(None)
            if commit_error or cache._depcache.broken_count > 0:
                if commit_error is None:
                    commit_error = ''
                brokenpkgs = broken_packages(cache)
                self.warn_broken_packages(brokenpkgs, commit_error)

            self.db.progress('STOP')

            self.nested_progress_end()

    def select_language_packs(self, save=False):
        try:
            keep_packages = self.db.get('ubiquity/keep-installed')
            keep_packages = keep_packages.replace(',', '').split()
            syslog.syslog('keeping packages due to preseeding: %s' %
                          ' '.join(keep_packages))
            record_installed(keep_packages)
        except debconf.DebconfError:
            pass

        langpacks = []
        all_langpacks = False
        try:
            langpack_db = self.db.get('pkgsel/language-packs')
            if langpack_db == 'ALL':
                apt_subp = subprocess.Popen(
                    ['apt-cache', '-n', 'search', '^language-pack-[^-][^-]*$'],
                    stdout=subprocess.PIPE, universal_newlines=True)
                apt_out = apt_subp.communicate()[0].rstrip().split('\n')
                langpacks = [x.split('-')[2].strip() for x in apt_out]
                all_langpacks = True
            else:
                langpacks = langpack_db.replace(',', '').split()
        except debconf.DebconfError:
            pass
        if not langpacks:
            langpack_set = set()
            try:
                langpack_db = self.db.get('localechooser/supported-locales')
                for locale in langpack_db.replace(',', '').split():
                    langpack_set.add(locale)
            except debconf.DebconfError:
                pass
            langpack_db = self.db.get('debian-installer/locale')
            langpack_set.add(langpack_db)
            langpacks = sorted(langpack_set)

        no_install = '/var/lib/ubiquity/no-install-langpacks'
        if os.path.exists(no_install):
            osextras.unlink_force(no_install)
        if len(langpacks) == 1 and langpacks[0] in ('C', 'en'):
            # Touch
            with open(no_install, 'a'):
                os.utime(no_install, None)

        syslog.syslog('keeping language packs for: %s' % ' '.join(langpacks))

        try:
            lppatterns = self.db.get('pkgsel/language-pack-patterns').split()
        except debconf.DebconfError:
            return

        cache = Cache()

        to_install = []
        checker = osextras.find_on_path('check-language-support')
        for lp_locale in langpacks:
            lp = locale_to_language_pack(lp_locale)
            # Basic language packs, required to get localisation working at
            # all. We install these almost unconditionally; if you want to
            # get rid of even these, you can preseed pkgsel/language-packs
            # to the empty string.
            to_install.append('language-pack-%s' % lp)
            # Other language packs, typically selected by preseeding.
            for pattern in lppatterns:
                to_install.append(pattern.replace('$LL', lp))
            # More extensive language support packages.
            # If pkgsel/language-packs is ALL, then speed things up by
            # calling check-language-support just once.
            if not all_langpacks and checker:
                check_lang = subprocess.Popen(
                    ['check-language-support', '-l', lp_locale.split('.')[0],
                     '--show-installed'],
                    stdout=subprocess.PIPE, universal_newlines=True)
                to_install.extend(check_lang.communicate()[0].strip().split())
            else:
                to_install.append('language-support-%s' % lp)
            if checker:
                # Keep language-support-$LL installed if it happens to be in
                # the live filesystem, since there's no point spending time
                # removing it; but don't install it if it isn't in the live
                # filesystem.
                toplevel = 'language-support-%s' % lp
                toplevel_pkg = get_cache_pkg(cache, toplevel)
                if toplevel_pkg and toplevel_pkg.is_installed:
                    to_install.append(toplevel)
        if all_langpacks and checker:
            check_lang = subprocess.Popen(
                ['check-language-support', '-a', '--show-installed'],
                stdout=subprocess.PIPE, universal_newlines=True)
            to_install.extend(check_lang.communicate()[0].strip().split())

        # Filter the list of language packs to include only language packs
        # that exist in the live filesystem's apt cache, so that we can tell
        # the difference between "no such language pack" and "language pack
        # not retrievable given apt configuration in /target" later on.
        to_install = [
            pkg for pkg in to_install if get_cache_pkg(cache, pkg) is not None]

        install_new = True
        try:
            install_new_key = \
                self.db.get('pkgsel/install-language-support') == 'true'
            if install_new_key != '' and not misc.create_bool(install_new_key):
                install_new = False
        except debconf.DebconfError:
            pass

        if not install_new:
            # Keep packages that are on the live filesystem, but don't install
            # new ones.
            # TODO cjwatson 2010-03-18: To match pkgsel's semantics, we
            # ought to be willing to install packages from the package pool
            # on the CD as well.
            to_install = [
                pkg for pkg in to_install
                if get_cache_pkg(cache, pkg).is_installed]

        # filter out langpacks matching unwanted application names
        # in manual install
        if self.db.get('ubiquity/minimal_install') == 'true':
            if os.path.exists(minimal_install_rlist_path):
                rm = set()
                with open(minimal_install_rlist_path) as m_file:
                    rm = {line.strip().split(':')[0] for line in m_file}
                to_install = list(set(to_install) - rm)

        del cache
        record_installed(to_install)

        langpacks_file = '/var/lib/ubiquity/langpacks'
        if os.path.exists(langpacks_file):
            osextras.unlink_force(langpacks_file)
        if install_new:
            if save:
                if not os.path.exists(os.path.dirname(langpacks_file)):
                    os.makedirs(os.path.dirname(langpacks_file))
                with open(langpacks_file, 'w') as langpacks:
                    for pkg in to_install:
                        print(pkg, file=langpacks)
                return []
            else:
                return to_install

# vim:ai:et:sts=4:tw=80:sw=4:
