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