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