]> git.donarmstrong.com Git - dak.git/blob - daklib/upload.py
Do not load .dsc to check if an upload is sourceful.
[dak.git] / daklib / upload.py
1 # Copyright (C) 2012, Ansgar Burchardt <ansgar@debian.org>
2 #
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 2 of the License, or
6 # (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License along
14 # with this program; if not, write to the Free Software Foundation, Inc.,
15 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
16
17 """module to handle uploads not yet installed to the archive
18
19 This module provides classes to handle uploads not yet installed to the
20 archive.  Central is the L{Changes} class which represents a changes file.
21 It provides methods to access the included binary and source packages.
22 """
23
24 import apt_inst
25 import apt_pkg
26 import os
27 import re
28
29 from daklib.gpg import SignedFile
30 from daklib.regexes import *
31
32 class InvalidChangesException(Exception):
33     pass
34
35 class InvalidBinaryException(Exception):
36     pass
37
38 class InvalidSourceException(Exception):
39     pass
40
41 class InvalidHashException(Exception):
42     def __init__(self, filename, hash_name, expected, actual):
43         self.filename = filename
44         self.hash_name = hash_name
45         self.expected = expected
46         self.actual = actual
47     def __str__(self):
48         return "Invalid {0} hash for {1}: expected {2}, but got {3}.".format(self.hash_name, self.filename, self.expected, self.actual)
49
50 class InvalidFilenameException(Exception):
51     def __init__(self, filename):
52         self.filename = filename
53     def __str__(self):
54         return "Invalid filename '{0}'.".format(self.filename)
55
56 class HashedFile(object):
57     """file with checksums
58     """
59     def __init__(self, filename, size, md5sum, sha1sum, sha256sum, section=None, priority=None):
60         self.filename = filename
61         """name of the file
62         @type: str
63         """
64
65         self.size = size
66         """size in bytes
67         @type: long
68         """
69
70         self.md5sum = md5sum
71         """MD5 hash in hexdigits
72         @type: str
73         """
74
75         self.sha1sum = sha1sum
76         """SHA1 hash in hexdigits
77         @type: str
78         """
79
80         self.sha256sum = sha256sum
81         """SHA256 hash in hexdigits
82         @type: str
83         """
84
85         self.section = section
86         """section or C{None}
87         @type: str or C{None}
88         """
89
90         self.priority = priority
91         """priority or C{None}
92         @type: str of C{None}
93         """
94
95     @classmethod
96     def from_file(cls, directory, filename, section=None, priority=None):
97         """create with values for an existing file
98
99         Create a C{HashedFile} object that refers to an already existing file.
100
101         @type  directory: str
102         @param directory: directory the file is located in
103
104         @type  filename: str
105         @param filename: filename
106
107         @type  section: str or C{None}
108         @param section: optional section as given in .changes files
109
110         @type  priority: str or C{None}
111         @param priority: optional priority as given in .changes files
112
113         @rtype:  L{HashedFile}
114         @return: C{HashedFile} object for the given file
115         """
116         path = os.path.join(directory, filename)
117         size = os.stat(path).st_size
118         with open(path, 'r') as fh:
119             hashes = apt_pkg.Hashes(fh)
120         return cls(filename, size, hashes.md5, hashes.sha1, hashes.sha256, section, priority)
121
122     def check(self, directory):
123         """Validate hashes
124
125         Check if size and hashes match the expected value.
126
127         @type  directory: str
128         @param directory: directory the file is located in
129
130         @raise InvalidHashException: hash mismatch
131         """
132         path = os.path.join(directory, self.filename)
133         fh = open(path, 'r')
134
135         size = os.stat(path).st_size
136         if size != self.size:
137             raise InvalidHashException(self.filename, 'size', self.size, size)
138
139         md5sum = apt_pkg.md5sum(fh)
140         if md5sum != self.md5sum:
141             raise InvalidHashException(self.filename, 'md5sum', self.md5sum, md5sum)
142
143         fh.seek(0)
144         sha1sum = apt_pkg.sha1sum(fh)
145         if sha1sum != self.sha1sum:
146             raise InvalidHashException(self.filename, 'sha1sum', self.sha1sum, sha1sum)
147
148         fh.seek(0)
149         sha256sum = apt_pkg.sha256sum(fh)
150         if sha256sum != self.sha256sum:
151             raise InvalidHashException(self.filename, 'sha256sum', self.sha256sum, sha256sum)
152
153 def parse_file_list(control, has_priority_and_section):
154     """Parse Files and Checksums-* fields
155
156     @type  control: dict-like
157     @param control: control file to take fields from
158
159     @type  has_priority_and_section: bool
160     @param has_priority_and_section: Files field include section and priority
161                                      (as in .changes)
162
163     @raise InvalidChangesException: missing fields or other grave errors
164
165     @rtype:  dict
166     @return: dict mapping filenames to L{daklib.upload.HashedFile} objects
167     """
168     entries = {}
169
170     for line in control["Files"].split('\n'):
171         if len(line) == 0:
172             continue
173
174         if has_priority_and_section:
175             (md5sum, size, section, priority, filename) = line.split()
176             entry = dict(md5sum=md5sum, size=long(size), section=section, priority=priority, filename=filename)
177         else:
178             (md5sum, size, filename) = line.split()
179             entry = dict(md5sum=md5sum, size=long(size), filename=filename)
180
181         entries[filename] = entry
182
183     for line in control["Checksums-Sha1"].split('\n'):
184         if len(line) == 0:
185             continue
186         (sha1sum, size, filename) = line.split()
187         entry = entries.get(filename, None)
188         if entry.get('size', None) != long(size):
189             raise InvalidChangesException('Size for {0} in Files and Checksum-Sha1 fields differ.'.format(filename))
190         entry['sha1sum'] = sha1sum
191
192     for line in control["Checksums-Sha256"].split('\n'):
193         if len(line) == 0:
194             continue
195         (sha256sum, size, filename) = line.split()
196         entry = entries.get(filename, None)
197         if entry is None:
198             raise InvalidChangesException('No sha256sum for {0}.'.format(filename))
199         if entry.get('size', None) != long(size):
200             raise InvalidChangesException('Size for {0} in Files and Checksum-Sha256 fields differ.'.format(filename))
201         entry['sha256sum'] = sha256sum
202
203     files = {}
204     for entry in entries.itervalues():
205         filename = entry['filename']
206         if 'size' not in entry:
207             raise InvalidChangesException('No size for {0}.'.format(filename))
208         if 'md5sum' not in entry:
209             raise InvalidChangesException('No md5sum for {0}.'.format(filename))
210         if 'sha1sum' not in entry:
211             raise InvalidChangesException('No sha1sum for {0}.'.format(filename))
212         if 'sha256sum' not in entry:
213             raise InvalidChangesException('No sha256sum for {0}.'.format(filename))
214         if not re_file_safe.match(filename):
215             raise InvalidChangesException("{0}: References file with unsafe filename {1}.".format(self.filename, filename))
216         f = files[filename] = HashedFile(**entry)
217
218     return files
219
220 class Changes(object):
221     """Representation of a .changes file
222     """
223     def __init__(self, directory, filename, keyrings, require_signature=True):
224         if not re_file_safe.match(filename):
225             raise InvalidChangesException('{0}: unsafe filename'.format(filename))
226
227         self.directory = directory
228         """directory the .changes is located in
229         @type: str
230         """
231
232         self.filename = filename
233         """name of the .changes file
234         @type: str
235         """
236
237         data = open(self.path).read()
238         self._signed_file = SignedFile(data, keyrings, require_signature)
239         self.changes = apt_pkg.TagSection(self._signed_file.contents)
240         """dict to access fields of the .changes file
241         @type: dict-like
242         """
243
244         self._binaries = None
245         self._source = None
246         self._files = None
247         self._keyrings = keyrings
248         self._require_signature = require_signature
249
250     @property
251     def path(self):
252         """path to the .changes file
253         @type: str
254         """
255         return os.path.join(self.directory, self.filename)
256
257     @property
258     def primary_fingerprint(self):
259         """fingerprint of the key used for signing the .changes file
260         @type: str
261         """
262         return self._signed_file.primary_fingerprint
263
264     @property
265     def valid_signature(self):
266         """C{True} if the .changes has a valid signature
267         @type: bool
268         """
269         return self._signed_file.valid
270
271     @property
272     def architectures(self):
273         """list of architectures included in the upload
274         @type: list of str
275         """
276         return self.changes['Architecture'].split()
277
278     @property
279     def distributions(self):
280         """list of target distributions for the upload
281         @type: list of str
282         """
283         return self.changes['Distribution'].split()
284
285     @property
286     def source(self):
287         """included source or C{None}
288         @type: L{daklib.upload.Source} or C{None}
289         """
290         if self._source is None:
291             source_files = []
292             for f in self.files.itervalues():
293                 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename):
294                     source_files.append(f)
295             if len(source_files) > 0:
296                 self._source = Source(self.directory, source_files, self._keyrings, self._require_signature)
297         return self._source
298
299     @property
300     def sourceful(self):
301         """C{True} if the upload includes source
302         @type: bool
303         """
304         return "source" in self.architectures
305
306     @property
307     def source_name(self):
308         """source package name
309         @type: str
310         """
311         return re_field_source.match(self.changes['Source']).group('package')
312
313     @property
314     def binaries(self):
315         """included binary packages
316         @type: list of L{daklib.upload.Binary}
317         """
318         if self._binaries is None:
319             binaries = []
320             for f in self.files.itervalues():
321                 if re_file_binary.match(f.filename):
322                     binaries.append(Binary(self.directory, f))
323             self._binaries = binaries
324         return self._binaries
325
326     @property
327     def byhand_files(self):
328         """included byhand files
329         @type: list of L{daklib.upload.HashedFile}
330         """
331         byhand = []
332
333         for f in self.files.itervalues():
334             if re_file_dsc.match(f.filename) or re_file_source.match(f.filename) or re_file_binary.match(f.filename):
335                 continue
336             if f.section != 'byhand' and f.section[:4] != 'raw-':
337                 raise InvalidChangesException("{0}: {1} looks like a byhand package, but is in section {2}".format(self.filename, f.filename, f.section))
338             byhand.append(f)
339
340         return byhand
341
342     @property
343     def binary_names(self):
344         """names of included binary packages
345         @type: list of str
346         """
347         return self.changes['Binary'].split()
348
349     @property
350     def closed_bugs(self):
351         """bugs closed by this upload
352         @type: list of str
353         """
354         return self.changes.get('Closes', '').split()
355
356     @property
357     def files(self):
358         """dict mapping filenames to L{daklib.upload.HashedFile} objects
359         @type: dict
360         """
361         if self._files is None:
362             self._files = parse_file_list(self.changes, True)
363         return self._files
364
365     @property
366     def bytes(self):
367         """total size of files included in this upload in bytes
368         @type: number
369         """
370         count = 0
371         for f in self.files.itervalues():
372             count += f.size
373         return count
374
375     def __cmp__(self, other):
376         """compare two changes files
377
378         We sort by source name and version first.  If these are identical,
379         we sort changes that include source before those without source (so
380         that sourceful uploads get processed first), and finally fall back
381         to the filename (this should really never happen).
382
383         @rtype:  number
384         @return: n where n < 0 if self < other, n = 0 if self == other, n > 0 if self > other
385         """
386         ret = cmp(self.changes.get('Source'), other.changes.get('Source'))
387
388         if ret == 0:
389             # compare version
390             ret = apt_pkg.version_compare(self.changes.get('Version', ''), other.changes.get('Version', ''))
391
392         if ret == 0:
393             # sort changes with source before changes without source
394             if 'source' in self.architectures and 'source' not in other.architectures:
395                 ret = -1
396             elif 'source' not in self.architectures and 'source' in other.architectures:
397                 ret = 1
398             else:
399                 ret = 0
400
401         if ret == 0:
402             # fall back to filename
403             ret = cmp(self.filename, other.filename)
404
405         return ret
406
407 class Binary(object):
408     """Representation of a binary package
409     """
410     def __init__(self, directory, hashed_file):
411         self.hashed_file = hashed_file
412         """file object for the .deb
413         @type: HashedFile
414         """
415
416         path = os.path.join(directory, hashed_file.filename)
417         data = apt_inst.DebFile(path).control.extractdata("control")
418
419         self.control = apt_pkg.TagSection(data)
420         """dict to access fields in DEBIAN/control
421         @type: dict-like
422         """
423
424     @classmethod
425     def from_file(cls, directory, filename):
426         hashed_file = HashedFile.from_file(directory, filename)
427         return cls(directory, hashed_file)
428
429     @property
430     def source(self):
431         """get tuple with source package name and version
432         @type: tuple of str
433         """
434         source = self.control.get("Source", None)
435         if source is None:
436             return (self.control["Package"], self.control["Version"])
437         match = re_field_source.match(source)
438         if not match:
439             raise InvalidBinaryException('{0}: Invalid Source field.'.format(self.hashed_file.filename))
440         version = match.group('version')
441         if version is None:
442             version = self.control['Version']
443         return (match.group('package'), version)
444
445     @property
446     def type(self):
447         """package type ('deb' or 'udeb')
448         @type: str
449         """
450         match = re_file_binary.match(self.hashed_file.filename)
451         if not match:
452             raise InvalidBinaryException('{0}: Does not match re_file_binary'.format(self.hashed_file.filename))
453         return match.group('type')
454
455     @property
456     def component(self):
457         """component name
458         @type: str
459         """
460         fields = self.control['Section'].split('/')
461         if len(fields) > 1:
462             return fields[0]
463         return "main"
464
465 class Source(object):
466     """Representation of a source package
467     """
468     def __init__(self, directory, hashed_files, keyrings, require_signature=True):
469         self.hashed_files = hashed_files
470         """list of source files (including the .dsc itself)
471         @type: list of L{HashedFile}
472         """
473
474         self._dsc_file = None
475         for f in hashed_files:
476             if re_file_dsc.match(f.filename):
477                 if self._dsc_file is not None:
478                     raise InvalidSourceException("Multiple .dsc found ({0} and {1})".format(self._dsc_file.filename, f.filename))
479                 else:
480                     self._dsc_file = f
481
482         # make sure the hash for the dsc is valid before we use it
483         self._dsc_file.check(directory)
484
485         dsc_file_path = os.path.join(directory, self._dsc_file.filename)
486         data = open(dsc_file_path, 'r').read()
487         self._signed_file = SignedFile(data, keyrings, require_signature)
488         self.dsc = apt_pkg.TagSection(self._signed_file.contents)
489         """dict to access fields in the .dsc file
490         @type: dict-like
491         """
492
493         self._files = None
494
495     @classmethod
496     def from_file(cls, directory, filename, keyrings, require_signature=True):
497         hashed_file = HashedFile.from_file(directory, filename)
498         return cls(directory, [hashed_file], keyrings, require_signature)
499
500     @property
501     def files(self):
502         """dict mapping filenames to L{HashedFile} objects for additional source files
503
504         This list does not include the .dsc itself.
505
506         @type: dict
507         """
508         if self._files is None:
509             self._files = parse_file_list(self.dsc, False)
510         return self._files
511
512     @property
513     def primary_fingerprint(self):
514         """fingerprint of the key used to sign the .dsc
515         @type: str
516         """
517         return self._signed_file.primary_fingerprint
518
519     @property
520     def valid_signature(self):
521         """C{True} if the .dsc has a valid signature
522         @type: bool
523         """
524         return self._signed_file.valid
525
526     @property
527     def component(self):
528         """guessed component name
529
530         Might be wrong. Don't rely on this.
531
532         @type: str
533         """
534         if 'Section' not in self.dsc:
535             return 'main'
536         fields = self.dsc['Section'].split('/')
537         if len(fields) > 1:
538             return fields[0]
539         return "main"
540
541     @property
542     def filename(self):
543         """filename of .dsc file
544         @type: str
545         """
546         return self._dsc_file.filename