#!/usr/bin/python

# Use the coredump in a crash report to regenerate the stack traces. This is
# helpful to get a trace with debug symbols.
#
# Copyright (c) 2006 - 2011 Canonical Ltd.
# Author: Martin Pitt <martin.pitt@ubuntu.com>
#
# 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.  See http://www.gnu.org/copyleft/gpl.html for
# the full text of the license.

import sys, os, os.path, subprocess, optparse, shutil, tempfile, re, zlib
import tty, termios, gettext, atexit
import apport, apport.fileutils
from apport.crashdb import get_crashdb
from apport import unicode_gettext as _

#
# functions
#

def parse_options():
    '''Parse command line options and return (reportfile, options) tuple.'''

    optparser = optparse.OptionParser(_('%prog [options] <apport problem report | crash ID>'))
    optparser.add_option('-s', '--stdout', action='store_true',
        help=_('Do not put the new traces into the report, but write them to stdout.'))
    optparser.add_option('-g', '--gdb', action='store_true',
        help=_('Start an interactive gdb session with the report\'s core dump (-o ignored; does not rewrite report)'))
    optparser.add_option('-o', '--output', metavar='FILE',
        help=_('Write modified report to given file instead of changing the original report'))
    optparser.add_option('-c', '--remove-core', action='store_true',
        help=_('Remove the core dump from the report after stack trace regeneration'))
    optparser.add_option('-r', '--core-file', metavar='CORE',
        help=_('Override report\'s CoreFile'))
    optparser.add_option('-x', '--executable', metavar='EXE',
        help=_('Override report\'s ExecutablePath'))
    optparser.add_option('-m', '--procmaps', metavar='MAPS',
        help=_('Override report\'s ProcMaps'))
    optparser.add_option('-R', '--rebuild-package-info', action='store_true',
        help=_('Rebuild report\'s Package information'))
    optparser.add_option('-S', '--sandbox', metavar='CONFIG_DIR',
        help=_('Build a temporary sandbox and download/install the necessary packages and debug symbols in there; without this option it assumes that the necessary packages and debug symbols are already installed in the system. The argument points to the packaging system configuration base directory; if you specify "system", it will use the system configuration files, but will then only be able to retrace crashes that happened on the currently running release.'))
    optparser.add_option('-v', '--verbose', action='store_true',
        help=_('Report download/install progress when installing packages into sandbox'))
    optparser.add_option('-C', '--cache', metavar='DIR',
        help=_('Cache directory for packages downloaded in the sandbox'))
    optparser.add_option('-p', '--extra-package', action='append', default=[],
        help=_('Install an extra package into the sandbox (can be specified multiple times)'))
    optparser.add_option('--auth',
        help=_('Path to a file with the crash database authentication information. This is used when specifying a crash ID to upload the retraced stack traces (only if neither -g, -o, nor -s are specified)'))
    optparser.add_option('--confirm', action='store_true',
        help=_('Display retraced stack traces and ask for confirmation before sending them to the crash database.'))
    optparser.add_option('--duplicate-db', metavar='PATH',
        help=_('Path to the duplicate sqlite database (default: no duplicate checking)'))

    (opts, args) = optparser.parse_args()

    if len(args) != 1:
        optparser.error(_('incorrect number of arguments; use --help for a short help'))
        sys.exit(1)

    if not (opts.auth or opts.output or opts.stdout or opts.gdb):
        optparser.error(_('you either need to do a local operation (-s, -g, -o) or supply an authentication file (--auth); see --help for a short help'))
        sys.exit(1)

    return (args[0], opts)

def getch():
    '''Read a single character from stdin.'''

    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)
    try:
        tty.setraw(sys.stdin.fileno())
        ch = sys.stdin.read(1)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
    return ch

def confirm_traces(report):
    '''Display the retraced stack traces and ask the user whether or not to
    upload them to the crash database.
    
    Return True if the user agrees.'''

    print '--- stack trace ---'
    print report['Stacktrace']
    if report.has_key('ThreadedStacktrace'):
        print '--- thread stack trace ---'
        print report['ThreadStacktrace']
    print '---'

    ch = None
    while ch not in ['y', 'n']:
        # translators: don't translate y/n, apport currently only checks for "y"
        print _('OK to send these as attachments? [y/n]')
        ch = getch().lower()

    return ch == 'y'

def find_file_dir(name, dir, limit=None):
    '''Return a path list of all files with given name which are in or below
    dir.
    
    If limit is not None, the search will be stopped after finding the given
    number of hits.'''

    result = []
    for root, dirs, files in os.walk(dir):
        if name in files:
            result.append(os.path.join(root, name))
            if limit and len(result) >= limit:
                break
    return result

def get_code(srcdir, filename, line, context=5):
    '''Find the given filename in the srcdir directory and return the code
    lines around the given line number.'''

    files = find_file_dir(filename, srcdir, 1)
    if not files:
        return '  [Error: %s was not found in source tree]\n' % filename

    result = ''
    lineno = 0
    # make enough room for all line numbers
    format = '  %%%ii: %%s' % len(str(line+context))

    for l in open(files[0]):
        lineno += 1
        if lineno >= line-context and lineno <= line+context:
            result += format % (lineno, l)

    return result

def gen_source_stacktrace(report):
    '''Generate StacktraceSource, a version of Stacktrace with the surrounding
    code lines (where available) and with local variables removed.'''

    if not report.has_key('Stacktrace') or not report.has_key('SourcePackage'):
        return

    workdir = tempfile.mkdtemp()
    try:
        try:
            version = report['Package'].split()[1]
        except (IndexError, KeyError):
            version = None
        srcdir = apport.packaging.get_source_tree(report['SourcePackage'], workdir, version)
        if not srcdir:
            return

        src_frame = re.compile('^#\d+\s.* at (.*):(\d+)$')
        other_frame = re.compile('^#\d+')
        result = ''
        for frame in report['Stacktrace'].splitlines():
            m = src_frame.match(frame)
            if m:
                result += frame + '\n' + get_code(srcdir, m.group(1), int(m.group(2)))
            else:
                m = other_frame.search(frame)
                if m:
                    result += frame + '\n'

        report['StacktraceSource'] = result
    finally:
        shutil.rmtree(workdir)
        pass

def needed_packages(report):
    '''Determine necessary packages for given report.

    Return list of (pkgname, version) pairs. version might be None for unknown
    package versions.
    '''
    pkgs = {}

    # first, grab the versions that we captured at crash time
    for l in (report['Package'] + '\n' + report.get('Dependencies', '')).splitlines():
        if not l.strip():
            continue
        try:
            (pkg, version) = l.split()[:2]
        except ValueError:
            apport.warning('invalid Package/Dependencies line: %s', l)
            # invalid line, ignore
            continue
        pkgs[pkg] = version

    return [(p, v) for (p, v) in pkgs.items()]
    
def needed_runtime_packages(report, sandbox, cache_dir, verbose=False):
    '''Determine necessary runtime packages for given report.

    This determines libraries which were dynamically loaded at runtime, i. e.
    appear in /proc/pid/maps, but not in Dependencies: (such as plugins).

    Return list of (pkgname, None) pairs.

    When cache_dir is specified, it is used as a cache for get_file_package().
    '''
    # check list of libraries that the crashed process referenced at
    # runtime and warn about those which are not available
    pkgs = set()
    libs = set()
    if report.has_key('ProcMaps'):
        for l in report['ProcMaps'].splitlines():
            if not l.strip():
                continue
            cols = l.split()
            if len(cols) == 6 and 'x' in cols[1] and '.so' in cols[5]:
                lib = os.path.realpath(cols[5])
                libs.add(lib)

    # grab as much as we can
    for l in libs:
        if os.path.exists(sandbox + l):
            continue

        pkg = apport.packaging.get_file_package(l, True, cache_dir)
        if pkg:
            if verbose:
                print >> sys.stderr, 'dynamically loaded %s needs package %s, queueing' % (l, pkg)
            pkgs.add(pkg)
        else:
                apport.warning('%s is needed, but cannot be mapped to a package', l)

    return [(p, None) for p in pkgs]

#
# main
#

gettext.textdomain('apport')

(reportfile, options) = parse_options()

crashdb = get_crashdb(options.auth)

# load the report
if os.path.exists(reportfile):
    try:
        report = apport.Report()
        report.load(open(reportfile))
    except (MemoryError, TypeError, ValueError, IOError, zlib.error) as e:
        apport.fatal('Cannot open report file: %s', str(e))
elif reportfile.isdigit():
    # crash ID
    try:
        report = crashdb.download(int(reportfile))
    except AssertionError as e:
        if 'apport format data' in str(e):
            apport.error('Broken report: %s', str(e))
            sys.exit(0)
        else:
            raise
    except (MemoryError, TypeError, ValueError, IOError, SystemError,
            OverflowError, zlib.error) as e:
        # if we process the report automatically, and it is invalid, close it with
        # an informative message and exit cleanly to not break crash-digger
        if options.auth and not options.output and not options.stdout:
            apport.error('Broken report: %s, closing as invalid', str(e))
            crashdb.mark_retrace_failed(reportfile, '''Thank you for your report! 
            
However, processing it in order to get sufficient information for the
developers failed, since the report is ill-formed. Perhaps the report data got
modified?

  %s

If you encounter the crash again, please file a new report.

Thank you for your understanding, and sorry for the inconvenience!
''' % str(e))
            sys.exit(0)
        else:
            raise

    crashid = reportfile
    reportfile = None
else:
    apport.fatal('"%s" is neither an existing report file nor a crash ID', 
            reportfile)

if options.core_file:
    report['CoreDump'] = file(options.core_file).read()
if options.executable:
    report['ExecutablePath'] = options.executable
if options.procmaps:
    report['ProcMaps'] = file(options.procmaps).read()
if options.rebuild_package_info and 'ExecutablePath' in report:
    report.add_package_info()

# sanity checks
required_fields = set(['CoreDump', 'ExecutablePath', 'Package', 'DistroRelease'])
if report['ProblemType'] == 'KernelCrash':
    if not set(['Package','VmCore']).issubset(set(report.keys())):
        apport.error('report file does not contain the required fields')
        sys.exit(0)
    apport.error('KernelCrash processing not implemented yet')
    sys.exit(99) # signal crash digger about transient error
elif not required_fields.issubset(set(report.keys())):
    apport.error('report file does not contain one of the required fields: ' + \
	' '.join(required_fields))
    sys.exit(0)

sandbox = None
outdated_msg = None
if options.sandbox:
    sandbox = tempfile.mkdtemp()

    pkgs = needed_packages(report)
    for p in options.extra_package:
        pkgs.append((p, None))
    if options.sandbox == 'system':
        sandbox_arg = None
    else:
        sandbox_arg = options.sandbox

    # we call install_packages() multiple times, plus get_file_package(); use a
    # shared cache dir for these
    if not options.cache:
        options.cache = tempfile.mkdtemp()
        atexit.register(shutil.rmtree, options.cache)

    outdated_msg = apport.packaging.install_packages(sandbox, sandbox_arg,
        report['DistroRelease'], pkgs, options.verbose, options.cache)

    pkgs = needed_runtime_packages(report, sandbox, options.cache,
            options.verbose)

    # package hooks might reassign Package:, check that we have the originally crashing binary
    for path in ('InterpreterPath', 'ExecutablePath'):
        if path in report and not os.path.exists(sandbox + report[path]):
            pkg = apport.packaging.get_file_package(report[path], True, options.cache)
            if pkg:
                print('Installing extra package %s to get %s' % (pkg, path))
                pkgs.append((pkg, None))
            else:
                apport.warning('Cannot find package which ships %s', path)

    if pkgs:
        outdated_msg += apport.packaging.install_packages(sandbox, sandbox_arg,
            report['DistroRelease'], pkgs, options.verbose, options.cache)

    for path in ('InterpreterPath', 'ExecutablePath'):
        if path in report and not os.path.exists(sandbox + report[path]):
            apport.error('%s %s does exist (report specified package %s)',
                    path, report[path], report['Package'])
            sys.exit(0)

# interactive gdb session
try:
    if options.gdb:
        core = tempfile.NamedTemporaryFile()
        core.write(report['CoreDump'])
        core.flush()

        argv = ['gdb']
        if options.sandbox:
            argv += ['--ex', 'set debug-file-directory %s/usr/lib/debug' % sandbox,
                     '--ex', 'set solib-absolute-prefix ' + sandbox]

        argv += ['--ex', 'file ' + report.get('InterpreterPath', report['ExecutablePath']),
            '--ex', 'core-file ' + core.name]

        subprocess.call(argv)
        core.close()
    else:
        # regenerate gdb info
        report.add_gdb_info(sandbox)
        #gen_source_stacktrace(report) # TODO: disabled until this gets adapted to the chroot-less sandbox
        report.add_kernel_crash_info()
finally:
    if sandbox:
        shutil.rmtree(sandbox)

modified = False

if options.remove_core:
    del report['CoreDump']
    modified = True

if options.stdout:
    print '--- stack trace ---'
    print report['Stacktrace']
    if report.has_key('ThreadedStacktrace'):
        print '--- thread stack trace ---'
        print report['ThreadStacktrace']
    if report.has_key('StacktraceSource'):
        print '--- source code stack trace ---'
        print report['StacktraceSource']
else:
    if not options.gdb:
        modified = True

if modified:
    if not reportfile and not options.output:
        if not options.auth:
            apport.fatal('You need to specify --auth for uploading retraced results back to the crash database.')
        if not options.confirm or confirm_traces(report):
            # check for duplicates
            update_bug = True
            if options.duplicate_db:
                crashdb.init_duplicate_db(options.duplicate_db)
                res = crashdb.check_duplicate(int(crashid), report)
                if res:
                    if res[1] == None:
                        print 'Report is a duplicate of #%i (not fixed yet)' % res[0]
                    elif res[1] == '':
                        print 'Report is a duplicate of #%i (fixed in latest version)' % res[0]
                    else:
                        print 'Report is a duplicate of #%i (fixed in version %s)' % res
                    update_bug = False
                else:
                    print 'Duplicate check negative'

            if update_bug:
                if 'Stacktrace' in report:
                    crashdb.update_traces(crashid, report)
                    print 'New attachments uploaded to crash database #' + crashid
                else:
                    assert outdated_msg
                    print 'No stack trace, invalid report'

                if not report.has_useful_stacktrace():
                    if outdated_msg:
                        invalid_msg = '''Thank you for your report! 

However, processing it in order to get sufficient information for the
developers failed (it does not generate an useful symbolic stack trace). This
might be caused by some outdated packages which were installed on your system
at the time of the report:

%s

Please upgrade your system to the latest package versions. If you still
encounter the crash, please file a new report.

Thank you for your understanding, and sorry for the inconvenience!
''' % outdated_msg
                        print 'No crash signature and outdated packages, invalidating report'
                        crashdb.mark_retrace_failed(crashid, invalid_msg)
                    else:
                        print 'Report has no crash signature, so retrace is flawed'
                        crashdb.mark_retrace_failed(crashid)

    else:
        if options.output == None:
            out = open(reportfile, 'w')
        elif options.output == '-':
            out = sys.stdout
        else:
            out = open(options.output, 'w')

        report.write(out)
