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