]> git.decadent.org.uk Git - dak.git/blob - dak/examine_package.py
Merge remote-tracking branch 'nthykier/auto-decruft'
[dak.git] / dak / examine_package.py
1 #!/usr/bin/env python
2
3 """
4 Script to automate some parts of checking NEW packages
5
6 Most functions are written in a functional programming style. They
7 return a string avoiding the side effect of directly printing the string
8 to stdout. Those functions can be used in multithreaded parts of dak.
9
10 @contact: Debian FTP Master <ftpmaster@debian.org>
11 @copyright: 2000, 2001, 2002, 2003, 2006  James Troup <james@nocrew.org>
12 @copyright: 2009  Joerg Jaspert <joerg@debian.org>
13 @license: GNU General Public License version 2 or later
14 """
15
16 # This program is free software; you can redistribute it and/or modify
17 # it under the terms of the GNU General Public License as published by
18 # the Free Software Foundation; either version 2 of the License, or
19 # (at your option) any later version.
20
21 # This program is distributed in the hope that it will be useful,
22 # but WITHOUT ANY WARRANTY; without even the implied warranty of
23 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
24 # GNU General Public License for more details.
25
26 # You should have received a copy of the GNU General Public License
27 # along with this program; if not, write to the Free Software
28 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
29
30 ################################################################################
31
32 # <Omnic> elmo wrote docs?!!?!?!?!?!?!
33 # <aj> as if he wasn't scary enough before!!
34 # * aj imagines a little red furry toy sitting hunched over a computer
35 #   tapping furiously and giggling to himself
36 # <aj> eventually he stops, and his heads slowly spins around and you
37 #      see this really evil grin and then he sees you, and picks up a
38 #      knife from beside the keyboard and throws it at you, and as you
39 #      breathe your last breath, he starts giggling again
40 # <aj> but i should be telling this to my psychiatrist, not you guys,
41 #      right? :)
42
43 ################################################################################
44
45 # suppress some deprecation warnings in squeeze related to md5 module
46 import warnings
47 warnings.filterwarnings('ignore', \
48     "the md5 module is deprecated; use hashlib instead", \
49     DeprecationWarning)
50
51 import errno
52 import os
53 import re
54 import sys
55 import md5
56 import apt_pkg
57 import apt_inst
58 import shutil
59 import subprocess
60 import threading
61
62 from daklib import utils
63 from daklib.config import Config
64 from daklib.dbconn import DBConn, get_component_by_package_suite
65 from daklib.gpg import SignedFile
66 from daklib.regexes import html_escaping, re_html_escaping, re_version, re_spacestrip, \
67                            re_contrib, re_nonfree, re_localhost, re_newlinespace, \
68                            re_package, re_doc_directory
69 from daklib.dak_exceptions import ChangesUnicodeError
70 import daklib.daksubprocess
71
72 ################################################################################
73
74 Cnf = None
75 Cnf = utils.get_conf()
76
77 printed = threading.local()
78 printed.copyrights = {}
79 package_relations = {}           #: Store relations of packages for later output
80
81 # default is to not output html.
82 use_html = False
83
84 ################################################################################
85
86 def usage (exit_code=0):
87     print """Usage: dak examine-package [PACKAGE]...
88 Check NEW package(s).
89
90   -h, --help                 show this help and exit
91   -H, --html-output          output html page with inspection result
92   -f, --file-name            filename for the html page
93
94 PACKAGE can be a .changes, .dsc, .deb or .udeb filename."""
95
96     sys.exit(exit_code)
97
98 ################################################################################
99 # probably xml.sax.saxutils would work as well
100
101 def escape_if_needed(s):
102     if use_html:
103         return re_html_escaping.sub(lambda x: html_escaping.get(x.group(0)), s)
104     else:
105         return s
106
107 def headline(s, level=2, bodyelement=None):
108     if use_html:
109         if bodyelement:
110             return """<thead>
111                 <tr><th colspan="2" class="title" onclick="toggle('%(bodyelement)s', 'table-row-group', 'table-row-group')">%(title)s <span class="toggle-msg">(click to toggle)</span></th></tr>
112               </thead>\n"""%{"bodyelement":bodyelement,"title":utils.html_escape(os.path.basename(s))}
113         else:
114             return "<h%d>%s</h%d>\n" % (level, utils.html_escape(s), level)
115     else:
116         return "---- %s ----\n" % (s)
117
118 # Colour definitions, 'end' isn't really for use
119
120 ansi_colours = {
121   'main': "\033[36m",
122   'contrib': "\033[33m",
123   'nonfree': "\033[31m",
124   'provides': "\033[35m",
125   'arch': "\033[32m",
126   'end': "\033[0m",
127   'bold': "\033[1m",
128   'maintainer': "\033[32m",
129   'distro': "\033[1m\033[41m"}
130
131 html_colours = {
132   'main': ('<span style="color: aqua">',"</span>"),
133   'contrib': ('<span style="color: yellow">',"</span>"),
134   'nonfree': ('<span style="color: red">',"</span>"),
135   'provides': ('<span style="color: magenta">',"</span>"),
136   'arch': ('<span style="color: green">',"</span>"),
137   'bold': ('<span style="font-weight: bold">',"</span>"),
138   'maintainer': ('<span style="color: green">',"</span>"),
139   'distro': ('<span style="font-weight: bold; background-color: red">',"</span>")}
140
141 def colour_output(s, colour):
142     if use_html:
143         return ("%s%s%s" % (html_colours[colour][0], utils.html_escape(s), html_colours[colour][1]))
144     else:
145         return ("%s%s%s" % (ansi_colours[colour], s, ansi_colours['end']))
146
147 def escaped_text(s, strip=False):
148     if use_html:
149         if strip:
150             s = s.strip()
151         return "<pre>%s</pre>" % (s)
152     else:
153         return s
154
155 def formatted_text(s, strip=False):
156     if use_html:
157         if strip:
158             s = s.strip()
159         return "<pre>%s</pre>" % (utils.html_escape(s))
160     else:
161         return s
162
163 def output_row(s):
164     if use_html:
165         return """<tr><td>"""+s+"""</td></tr>"""
166     else:
167         return s
168
169 def format_field(k,v):
170     if use_html:
171         return """<tr><td class="key">%s:</td><td class="val">%s</td></tr>"""%(k,v)
172     else:
173         return "%s: %s"%(k,v)
174
175 def foldable_output(title, elementnameprefix, content, norow=False):
176     d = {'elementnameprefix':elementnameprefix}
177     result = ''
178     if use_html:
179         result += """<div id="%(elementnameprefix)s-wrap"><a name="%(elementnameprefix)s" />
180                    <table class="infobox rfc822">\n"""%d
181     result += headline(title, bodyelement="%(elementnameprefix)s-body"%d)
182     if use_html:
183         result += """    <tbody id="%(elementnameprefix)s-body" class="infobody">\n"""%d
184     if norow:
185         result += content + "\n"
186     else:
187         result += output_row(content) + "\n"
188     if use_html:
189         result += """</tbody></table></div>"""
190     return result
191
192 ################################################################################
193
194 def get_depends_parts(depend) :
195     v_match = re_version.match(depend)
196     if v_match:
197         d_parts = { 'name' : v_match.group(1), 'version' : v_match.group(2) }
198     else :
199         d_parts = { 'name' : depend , 'version' : '' }
200     return d_parts
201
202 def get_or_list(depend) :
203     or_list = depend.split("|")
204     return or_list
205
206 def get_comma_list(depend) :
207     dep_list = depend.split(",")
208     return dep_list
209
210 def split_depends (d_str) :
211     # creates a list of lists of dictionaries of depends (package,version relation)
212
213     d_str = re_spacestrip.sub('',d_str)
214     depends_tree = []
215     # first split depends string up amongs comma delimiter
216     dep_list = get_comma_list(d_str)
217     d = 0
218     while d < len(dep_list):
219         # put depends into their own list
220         depends_tree.append([dep_list[d]])
221         d += 1
222     d = 0
223     while d < len(depends_tree):
224         k = 0
225         # split up Or'd depends into a multi-item list
226         depends_tree[d] = get_or_list(depends_tree[d][0])
227         while k < len(depends_tree[d]):
228             # split depends into {package, version relation}
229             depends_tree[d][k] = get_depends_parts(depends_tree[d][k])
230             k += 1
231         d += 1
232     return depends_tree
233
234 def read_control (filename):
235     recommends = []
236     predepends = []
237     depends = []
238     section = ''
239     maintainer = ''
240     arch = ''
241
242     deb_file = utils.open_file(filename)
243     try:
244         extracts = utils.deb_extract_control(deb_file)
245         control = apt_pkg.TagSection(extracts)
246     except:
247         print formatted_text("can't parse control info")
248         deb_file.close()
249         raise
250
251     deb_file.close()
252
253     control_keys = control.keys()
254
255     if "Pre-Depends" in control:
256         predepends_str = control["Pre-Depends"]
257         predepends = split_depends(predepends_str)
258
259     if "Depends" in control:
260         depends_str = control["Depends"]
261         # create list of dependancy lists
262         depends = split_depends(depends_str)
263
264     if "Recommends" in control:
265         recommends_str = control["Recommends"]
266         recommends = split_depends(recommends_str)
267
268     if "Section" in control:
269         section_str = control["Section"]
270
271         c_match = re_contrib.search(section_str)
272         nf_match = re_nonfree.search(section_str)
273         if c_match :
274             # contrib colour
275             section = colour_output(section_str, 'contrib')
276         elif nf_match :
277             # non-free colour
278             section = colour_output(section_str, 'nonfree')
279         else :
280             # main
281             section = colour_output(section_str, 'main')
282     if "Architecture" in control:
283         arch_str = control["Architecture"]
284         arch = colour_output(arch_str, 'arch')
285
286     if "Maintainer" in control:
287         maintainer = control["Maintainer"]
288         localhost = re_localhost.search(maintainer)
289         if localhost:
290             #highlight bad email
291             maintainer = colour_output(maintainer, 'maintainer')
292         else:
293             maintainer = escape_if_needed(maintainer)
294
295     return (control, control_keys, section, predepends, depends, recommends, arch, maintainer)
296
297 def read_changes_or_dsc (suite, filename, session = None):
298     dsc = {}
299
300     dsc_file = utils.open_file(filename)
301     try:
302         dsc = utils.parse_changes(filename, dsc_file=1)
303     except:
304         return formatted_text("can't parse .dsc control info")
305     dsc_file.close()
306
307     filecontents = strip_pgp_signature(filename)
308     keysinorder = []
309     for l in filecontents.split('\n'):
310         m = re.match(r'([-a-zA-Z0-9]*):', l)
311         if m:
312             keysinorder.append(m.group(1))
313
314     for k in dsc.keys():
315         if k in ("build-depends","build-depends-indep"):
316             dsc[k] = create_depends_string(suite, split_depends(dsc[k]), session)
317         elif k == "architecture":
318             if (dsc["architecture"] != "any"):
319                 dsc['architecture'] = colour_output(dsc["architecture"], 'arch')
320         elif k == "distribution":
321             if dsc["distribution"] not in ('unstable', 'experimental'):
322                 dsc['distribution'] = colour_output(dsc["distribution"], 'distro')
323         elif k in ("files","changes","description"):
324             if use_html:
325                 dsc[k] = formatted_text(dsc[k], strip=True)
326             else:
327                 dsc[k] = ('\n'+'\n'.join(map(lambda x: ' '+x, dsc[k].split('\n')))).rstrip()
328         else:
329             dsc[k] = escape_if_needed(dsc[k])
330
331     keysinorder = filter(lambda x: not x.lower().startswith('checksums-'), keysinorder)
332
333     filecontents = '\n'.join(map(lambda x: format_field(x,dsc[x.lower()]), keysinorder))+'\n'
334     return filecontents
335
336 def get_provides(suite):
337     provides = set()
338     session = DBConn().session()
339     query = '''SELECT DISTINCT value
340                FROM binaries_metadata m
341                JOIN bin_associations b
342                ON b.bin = m.bin_id
343                WHERE key_id = (
344                  SELECT key_id
345                  FROM metadata_keys
346                  WHERE key = 'Provides' )
347                AND b.suite = (
348                  SELECT id
349                  FROM suite
350                  WHERE suite_name = '%(suite)s'
351                  OR codename = '%(suite)s')''' % \
352             {'suite': suite}
353     for p in session.execute(query):
354         for e in p:
355             for i in e.split(','):
356                 provides.add(i.strip())
357     session.close()
358     return provides
359
360 def create_depends_string (suite, depends_tree, session = None):
361     result = ""
362     if suite == 'experimental':
363         suite_list = ['experimental','unstable']
364     else:
365         suite_list = [suite]
366
367     provides = set()
368     comma_count = 1
369     for l in depends_tree:
370         if (comma_count >= 2):
371             result += ", "
372         or_count = 1
373         for d in l:
374             if (or_count >= 2 ):
375                 result += " | "
376             # doesn't do version lookup yet.
377
378             component = get_component_by_package_suite(d['name'], suite_list, \
379                 session = session)
380             if component is not None:
381                 adepends = d['name']
382                 if d['version'] != '' :
383                     adepends += " (%s)" % (d['version'])
384
385                 if component == "contrib":
386                     result += colour_output(adepends, "contrib")
387                 elif component == "non-free":
388                     result += colour_output(adepends, "nonfree")
389                 else :
390                     result += colour_output(adepends, "main")
391             else:
392                 adepends = d['name']
393                 if d['version'] != '' :
394                     adepends += " (%s)" % (d['version'])
395                 if not provides:
396                     provides = get_provides(suite)
397                 if d['name'] in provides:
398                     result += colour_output(adepends, "provides")
399                 else:
400                     result += colour_output(adepends, "bold")
401             or_count += 1
402         comma_count += 1
403     return result
404
405 def output_package_relations ():
406     """
407     Output the package relations, if there is more than one package checked in this run.
408     """
409
410     if len(package_relations) < 2:
411         # Only list something if we have more than one binary to compare
412         package_relations.clear()
413         return
414
415     to_print = ""
416     for package in package_relations:
417         for relation in package_relations[package]:
418             to_print += "%-15s: (%s) %s\n" % (package, relation, package_relations[package][relation])
419
420     package_relations.clear()
421     return foldable_output("Package relations", "relations", to_print)
422
423 def output_deb_info(suite, filename, packagename, session = None):
424     (control, control_keys, section, predepends, depends, recommends, arch, maintainer) = read_control(filename)
425
426     if control == '':
427         return formatted_text("no control info")
428     to_print = ""
429     if not package_relations.has_key(packagename):
430         package_relations[packagename] = {}
431     for key in control_keys :
432         if key == 'Pre-Depends':
433             field_value = create_depends_string(suite, predepends, session)
434             package_relations[packagename][key] = field_value
435         elif key == 'Depends':
436             field_value = create_depends_string(suite, depends, session)
437             package_relations[packagename][key] = field_value
438         elif key == 'Recommends':
439             field_value = create_depends_string(suite, recommends, session)
440             package_relations[packagename][key] = field_value
441         elif key == 'Section':
442             field_value = section
443         elif key == 'Architecture':
444             field_value = arch
445         elif key == 'Maintainer':
446             field_value = maintainer
447         elif key == 'Description':
448             if use_html:
449                 field_value = formatted_text(control.find(key), strip=True)
450             else:
451                 desc = control.find(key)
452                 desc = re_newlinespace.sub('\n ', desc)
453                 field_value = escape_if_needed(desc)
454         else:
455             field_value = escape_if_needed(control.find(key))
456         to_print += " "+format_field(key,field_value)+'\n'
457     return to_print
458
459 def do_command (command, escaped=False):
460     process = daklib.daksubprocess.Popen(command, stdout=subprocess.PIPE)
461     o = process.stdout
462     try:
463         if escaped:
464             return escaped_text(o.read())
465         else:
466             return formatted_text(o.read())
467     finally:
468         process.wait()
469
470 def do_lintian (filename):
471     cnf = Config()
472     cmd = []
473
474     user = cnf.get('Dinstall::UnprivUser') or None
475     if user is not None:
476         cmd.extend(['sudo', '-H', '-u', user])
477
478     color = 'always'
479     if use_html:
480         color = 'html'
481
482     cmd.extend(['lintian', '--show-overrides', '--color', color, "--", filename])
483
484     return do_command(cmd, escaped=True)
485
486 def get_copyright (deb_filename):
487     global printed
488
489     package = re_package.sub(r'\1', os.path.basename(deb_filename))
490     o = os.popen("dpkg-deb -c %s | egrep 'usr(/share)?/doc/[^/]*/copyright' | awk '{print $6}' | head -n 1" % (deb_filename))
491     cright = o.read()[:-1]
492
493     if cright == "":
494         return formatted_text("WARNING: No copyright found, please check package manually.")
495
496     doc_directory = re_doc_directory.sub(r'\1', cright)
497     if package != doc_directory:
498         return formatted_text("WARNING: wrong doc directory (expected %s, got %s)." % (package, doc_directory))
499
500     o = os.popen("dpkg-deb --fsys-tarfile %s | tar xvOf - %s 2>/dev/null" % (deb_filename, cright))
501     cright = o.read()
502     copyrightmd5 = md5.md5(cright).hexdigest()
503
504     res = ""
505     if printed.copyrights.has_key(copyrightmd5) and printed.copyrights[copyrightmd5] != "%s (%s)" % (package, os.path.basename(deb_filename)):
506         res += formatted_text( "NOTE: Copyright is the same as %s.\n\n" % \
507                                (printed.copyrights[copyrightmd5]))
508     else:
509         printed.copyrights[copyrightmd5] = "%s (%s)" % (package, os.path.basename(deb_filename))
510     return res+formatted_text(cright)
511
512 def get_readme_source (dsc_filename):
513     tempdir = utils.temp_dirname()
514     os.rmdir(tempdir)
515
516     cmd = ('dpkg-source', '--no-check', '--no-copy', '-x', dsc_filename, tempdir)
517     try:
518         daklib.daksubprocess.check_output(cmd, stderr=1)
519     except subprocess.CalledProcessError as e:
520         res = "How is education supposed to make me feel smarter? Besides, every time I learn something new, it pushes some\n old stuff out of my brain. Remember when I took that home winemaking course, and I forgot how to drive?\n"
521         res += "Error, couldn't extract source, WTF?\n"
522         res += "'dpkg-source -x' failed. return code: %s.\n\n" % (e.returncode)
523         res += e.output
524         return res
525
526     path = os.path.join(tempdir, 'debian/README.source')
527     res = ""
528     if os.path.exists(path):
529         res += do_command(["cat", "--", path])
530     else:
531         res += "No README.source in this package\n\n"
532
533     try:
534         shutil.rmtree(tempdir)
535     except OSError as e:
536         if errno.errorcode[e.errno] != 'EACCES':
537             res += "%s: couldn't remove tmp dir %s for source tree." % (dsc_filename, tempdir)
538
539     return res
540
541 def check_dsc (suite, dsc_filename, session = None):
542     dsc = read_changes_or_dsc(suite, dsc_filename, session)
543     dsc_basename = os.path.basename(dsc_filename)
544     return foldable_output(dsc_filename, "dsc", dsc, norow=True) + \
545            "\n" + \
546            foldable_output("lintian check for %s" % dsc_basename,
547                "source-lintian", do_lintian(dsc_filename)) + \
548            "\n" + \
549            foldable_output("README.source for %s" % dsc_basename,
550                "source-readmesource", get_readme_source(dsc_filename))
551
552 def check_deb (suite, deb_filename, session = None):
553     filename = os.path.basename(deb_filename)
554     packagename = filename.split('_')[0]
555
556     if filename.endswith(".udeb"):
557         is_a_udeb = 1
558     else:
559         is_a_udeb = 0
560
561     result = foldable_output("control file for %s" % (filename), "binary-%s-control"%packagename,
562         output_deb_info(suite, deb_filename, packagename, session), norow=True) + "\n"
563
564     if is_a_udeb:
565         result += foldable_output("skipping lintian check for udeb",
566             "binary-%s-lintian"%packagename, "") + "\n"
567     else:
568         result += foldable_output("lintian check for %s" % (filename),
569             "binary-%s-lintian"%packagename, do_lintian(deb_filename)) + "\n"
570
571     result += foldable_output("contents of %s" % (filename), "binary-%s-contents"%packagename,
572                               do_command(["dpkg", "-c", deb_filename])) + "\n"
573
574     if is_a_udeb:
575         result += foldable_output("skipping copyright for udeb",
576             "binary-%s-copyright"%packagename, "") + "\n"
577     else:
578         result += foldable_output("copyright of %s" % (filename),
579             "binary-%s-copyright"%packagename, get_copyright(deb_filename)) + "\n"
580
581     return result
582
583 # Read a file, strip the signature and return the modified contents as
584 # a string.
585 def strip_pgp_signature (filename):
586     with utils.open_file(filename) as f:
587         data = f.read()
588         signedfile = SignedFile(data, keyrings=(), require_signature=False)
589         return signedfile.contents
590
591 def display_changes(suite, changes_filename):
592     global printed
593     changes = read_changes_or_dsc(suite, changes_filename)
594     printed.copyrights = {}
595     return foldable_output(changes_filename, "changes", changes, norow=True)
596
597 def check_changes (changes_filename):
598     try:
599         changes = utils.parse_changes (changes_filename)
600     except ChangesUnicodeError:
601         utils.warn("Encoding problem with changes file %s" % (changes_filename))
602     print display_changes(changes['distribution'], changes_filename)
603
604     files = utils.build_file_list(changes)
605     for f in files.keys():
606         if f.endswith(".deb") or f.endswith(".udeb"):
607             print check_deb(changes['distribution'], f)
608         if f.endswith(".dsc"):
609             print check_dsc(changes['distribution'], f)
610         # else: => byhand
611
612 def main ():
613     global Cnf, db_files, waste, excluded
614
615 #    Cnf = utils.get_conf()
616
617     Arguments = [('h',"help","Examine-Package::Options::Help"),
618                  ('H',"html-output","Examine-Package::Options::Html-Output"),
619                 ]
620     for i in [ "Help", "Html-Output", "partial-html" ]:
621         if not Cnf.has_key("Examine-Package::Options::%s" % (i)):
622             Cnf["Examine-Package::Options::%s" % (i)] = ""
623
624     args = apt_pkg.parse_commandline(Cnf,Arguments,sys.argv)
625     Options = Cnf.subtree("Examine-Package::Options")
626
627     if Options["Help"]:
628         usage()
629
630     if Options["Html-Output"]:
631         global use_html
632         use_html = True
633
634     stdout_fd = sys.stdout
635
636     for f in args:
637         try:
638             if not Options["Html-Output"]:
639                 # Pipe output for each argument through less
640                 less_cmd = ("less", "-R", "-")
641                 less_process = daklib.daksubprocess.Popen(less_cmd, stdin=subprocess.PIPE, bufsize=0)
642                 less_fd = less_process.stdin
643                 # -R added to display raw control chars for colour
644                 sys.stdout = less_fd
645             try:
646                 if f.endswith(".changes"):
647                     check_changes(f)
648                 elif f.endswith(".deb") or f.endswith(".udeb"):
649                     # default to unstable when we don't have a .changes file
650                     # perhaps this should be a command line option?
651                     print check_deb('unstable', f)
652                 elif f.endswith(".dsc"):
653                     print check_dsc('unstable', f)
654                 else:
655                     utils.fubar("Unrecognised file type: '%s'." % (f))
656             finally:
657                 print output_package_relations()
658                 if not Options["Html-Output"]:
659                     # Reset stdout here so future less invocations aren't FUBAR
660                     less_fd.close()
661                     less_process.wait()
662                     sys.stdout = stdout_fd
663         except IOError as e:
664             if errno.errorcode[e.errno] == 'EPIPE':
665                 utils.warn("[examine-package] Caught EPIPE; skipping.")
666                 pass
667             else:
668                 raise
669         except KeyboardInterrupt:
670             utils.warn("[examine-package] Caught C-c; skipping.")
671             pass
672
673 #######################################################################################
674
675 if __name__ == '__main__':
676     main()