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