#!/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, 2009 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
import apport, apport.fileutils

from apport.crashdb import get_crashdb

#
# 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', 
        help='Do not put the new traces into the report, but write them to stdout.',
        action='store_true', dest='stdout', default=False)
    optparser.add_option('-g', '--gdb', 
        help="Start an interactive gdb session with the report's core dump (-o ignored; does not rewrite report)",
        action='store_true', dest='gdb', default=False)
    optparser.add_option('-o', '--output', 
        help='Write modified report to given file instead of changing the original report',
        action='store', type='string', dest='output', metavar='FILE', default=None)
    optparser.add_option('-c', '--remove-core', 
        help='Remove the core dump from the report after stack trace regeneration',
        action='store_true', dest='removecore', default=False)
    optparser.add_option('-r', '--core-file', 
        help="Override report's CoreFile",
        action='store', type='string', dest='core_file', metavar='CORE', default=None)
    optparser.add_option('-x', '--executable', 
        help="Override report's ExecutablePath",
        action='store', type='string', dest='executable', metavar='EXE', default=None)
    optparser.add_option('-m', '--procmaps', 
        help="Override report's ProcMaps",
        action='store', type='string', dest='procmaps', metavar='MAPS', default=None)
    optparser.add_option('-R', '--rebuild-package-info', 
        help="Rebuild report's Package information",
        action='count', dest='rebuild', default=0)
    optparser.add_option('-v', '--verbose', 
        help='Report download/install progress when installing additional packages',
        action='count', dest='verbose', default=0)
    optparser.add_option('-u', '--unpack-only', 
        help='Only unpack the additionally required packages, do not configure them; purge packages again after retracing',
        action='store_true', dest='unpack_only', default=False)
    optparser.add_option('--no-pkg', 
        help='Do not use packaging system when using -u and do not purge packages afterwards. This should only be used for temporarily unpacked chroot tarballs where it would just be a waste of time.',
        action='store_true', dest='no_pkg', default=False)
    optparser.add_option('-p', '--extra-package', 
        help='Install an extra package (can be specified multiple times)',
        action='append', type='string', dest='extra_packages', default=[])
    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)',
        action='store', type='string', dest='auth_file', default=None)
    optparser.add_option('--confirm',
        help='Display retraced stack traces and ask for confirmation before sending them to the crash database.',
        action='store_true', dest='confirm', default=False)
    optparser.add_option('--duplicate-db',
        help='Path to the duplicate sqlite database (default: no duplicate checking)',
        action='store', type='string', dest='dup_db', metavar='PATH', default=None)

    (opts, args) = optparser.parse_args()

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

    if not (opts.auth_file 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 online 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']
    print '--- thread stack trace ---'
    print report['ThreadStacktrace']
    print '---'

    ch = None
    while ch not in ['y', 'n']:
        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

#
# main
#

(reportfile, options) = parse_options()

crashdb = get_crashdb(options.auth_file)

# load the report
if os.path.exists(reportfile):
    try:
        report = apport.Report()
        report.load(open(reportfile))
    except (MemoryError, TypeError, ValueError, IOError, zlib.error), e:
        print >> sys.stderr, 'Cannot open report file:', e
        sys.exit(1)
elif reportfile.isdigit():
    # crash ID
    try:
        report = crashdb.download(int(reportfile))
    except (MemoryError, TypeError, ValueError, IOError, zlib.error), 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_file and not options.output and not options.stdout:
            print >> sys.stderr, 'Broken report:', str(e), 'closing as invalid'
            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:
    print >> sys.stderr, 'ERROR: "%s" is neither an existing report file nor a \
crash ID' % reportfile
    sys.exit(1)

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:
    report.add_package_info()

# sanity checks
required_fields = set(['CoreDump', 'ExecutablePath', 'Package'])
if not required_fields.issubset(set(report.keys())):
    print >> sys.stderr, 'report file does not contain required fields: ' + \
	' '.join(required_fields)
    sys.exit(0)

(installed, outdated_msg) = apport.packaging.install_retracing_packages(report,
    options.verbose, options.unpack_only, options.no_pkg,
    options.extra_packages)

# interactive gdb session
if options.gdb:
    core = tempfile.NamedTemporaryFile()
    core.write(report['CoreDump'])
    core.flush()
    subprocess.call(['gdb',
        '--ex', 'file ' + report.get('InterpreterPath', report['ExecutablePath']),
        '--ex', 'core-file ' + core.name])
    core.close()
else:
    # regenerate gdb info
    try:
        report.add_gdb_info()
        gen_source_stacktrace(report)
    except AssertionError:
        if outdated_msg:
            # we did not find ExecutablePath, and have outdated packages, close
            # as invalid (will be caught below)
            pass
        else:
            raise

modified = False

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

if options.stdout:
    print '--- stack trace ---'
    print report['Stacktrace']
    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_file:
            print >> sys.stderr, 'You need to specify --auth for uploading retraced results back to the crash database.'
            sys.exit(1)
        if not options.confirm or confirm_traces(report):
            # check for duplicates
            update_bug = True
            if options.dup_db:
                crashdb.init_duplicate_db(options.dup_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(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)

if options.unpack_only and installed and not options.no_pkg:
    apport.packaging.remove_packages(installed, options.verbose)
