]> git.donarmstrong.com Git - dak.git/blob - daklib/upload.py
daklib.upload.Source: expose Package-List.
[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 architectures(self):
279         """list of architectures included in the upload
280         @type: list of str
281         """
282         return self.changes.get('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 name(self):
453         return self.control['Package']
454
455     @property
456     def type(self):
457         """package type ('deb' or 'udeb')
458         @type: str
459         """
460         match = re_file_binary.match(self.hashed_file.filename)
461         if not match:
462             raise InvalidBinaryException('{0}: Does not match re_file_binary'.format(self.hashed_file.filename))
463         return match.group('type')
464
465     @property
466     def component(self):
467         """component name
468         @type: str
469         """
470         fields = self.control['Section'].split('/')
471         if len(fields) > 1:
472             return fields[0]
473         return "main"
474
475 class Source(object):
476     """Representation of a source package
477     """
478     def __init__(self, directory, hashed_files, keyrings, require_signature=True):
479         self.hashed_files = hashed_files
480         """list of source files (including the .dsc itself)
481         @type: list of L{HashedFile}
482         """
483
484         self._dsc_file = None
485         for f in hashed_files:
486             if re_file_dsc.match(f.filename):
487                 if self._dsc_file is not None:
488                     raise InvalidSourceException("Multiple .dsc found ({0} and {1})".format(self._dsc_file.filename, f.filename))
489                 else:
490                     self._dsc_file = f
491
492         # make sure the hash for the dsc is valid before we use it
493         self._dsc_file.check(directory)
494
495         dsc_file_path = os.path.join(directory, self._dsc_file.filename)
496         data = open(dsc_file_path, 'r').read()
497         self._signed_file = SignedFile(data, keyrings, require_signature)
498         self.dsc = apt_pkg.TagSection(self._signed_file.contents)
499         """dict to access fields in the .dsc file
500         @type: dict-like
501         """
502
503         self.package_list = daklib.packagelist.PackageList(self.dsc)
504         """Information about packages built by the source.
505         @type: daklib.packagelist.PackageList
506         """
507
508         self._files = None
509
510     @classmethod
511     def from_file(cls, directory, filename, keyrings, require_signature=True):
512         hashed_file = HashedFile.from_file(directory, filename)
513         return cls(directory, [hashed_file], keyrings, require_signature)
514
515     @property
516     def files(self):
517         """dict mapping filenames to L{HashedFile} objects for additional source files
518
519         This list does not include the .dsc itself.
520
521         @type: dict
522         """
523         if self._files is None:
524             self._files = parse_file_list(self.dsc, False)
525         return self._files
526
527     @property
528     def primary_fingerprint(self):
529         """fingerprint of the key used to sign the .dsc
530         @type: str
531         """
532         return self._signed_file.primary_fingerprint
533
534     @property
535     def valid_signature(self):
536         """C{True} if the .dsc has a valid signature
537         @type: bool
538         """
539         return self._signed_file.valid
540
541     @property
542     def component(self):
543         """guessed component name
544
545         Might be wrong. Don't rely on this.
546
547         @type: str
548         """
549         if 'Section' not in self.dsc:
550             return 'main'
551         fields = self.dsc['Section'].split('/')
552         if len(fields) > 1:
553             return fields[0]
554         return "main"
555
556     @property
557     def filename(self):
558         """filename of .dsc file
559         @type: str
560         """
561         return self._dsc_file.filename