]> git.donarmstrong.com Git - dak.git/blob - daklib/upload.py
daklib/upload.py (Changes): add source_name property
[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 source_name(self):
274         """source package name
275         @type: str
276         """
277         return re_field_source.match(self.changes['Source']).group('package')
278
279     @property
280     def binaries(self):
281         """included binary packages
282         @type: list of L{daklib.upload.Binary}
283         """
284         if self._binaries is None:
285             binaries = []
286             for f in self.files.itervalues():
287                 if re_file_binary.match(f.filename):
288                     binaries.append(Binary(self.directory, f))
289             self._binaries = binaries
290         return self._binaries
291
292     @property
293     def byhand_files(self):
294         """included byhand files
295         @type: list of L{daklib.upload.HashedFile}
296         """
297         byhand = []
298
299         for f in self.files.itervalues():
300             if re_file_dsc.match(f.filename) or re_file_source.match(f.filename) or re_file_binary.match(f.filename):
301                 continue
302             if f.section != 'byhand' and f.section[:4] != 'raw-':
303                 raise InvalidChangesException("{0}: {1} looks like a byhand package, but is in section {2}".format(self.filename, f.filename, f.section))
304             byhand.append(f)
305
306         return byhand
307
308     @property
309     def binary_names(self):
310         """names of included binary packages
311         @type: list of str
312         """
313         return self.changes['Binary'].split()
314
315     @property
316     def closed_bugs(self):
317         """bugs closed by this upload
318         @type: list of str
319         """
320         return self.changes.get('Closes', '').split()
321
322     @property
323     def files(self):
324         """dict mapping filenames to L{daklib.upload.HashedFile} objects
325         @type: dict
326         """
327         if self._files is None:
328             self._files = parse_file_list(self.changes, True)
329         return self._files
330
331     @property
332     def bytes(self):
333         """total size of files included in this upload in bytes
334         @type: number
335         """
336         count = 0
337         for f in self.files.itervalues():
338             count += f.size
339         return count
340
341     def __cmp__(self, other):
342         """compare two changes files
343
344         We sort by source name and version first.  If these are identical,
345         we sort changes that include source before those without source (so
346         that sourceful uploads get processed first), and finally fall back
347         to the filename (this should really never happen).
348
349         @rtype:  number
350         @return: n where n < 0 if self < other, n = 0 if self == other, n > 0 if self > other
351         """
352         ret = cmp(self.changes.get('Source'), other.changes.get('Source'))
353
354         if ret == 0:
355             # compare version
356             ret = apt_pkg.version_compare(self.changes.get('Version', ''), other.changes.get('Version', ''))
357
358         if ret == 0:
359             # sort changes with source before changes without source
360             if 'source' in self.architectures and 'source' not in other.architectures:
361                 ret = -1
362             elif 'source' not in self.architectures and 'source' in other.architectures:
363                 ret = 1
364             else:
365                 ret = 0
366
367         if ret == 0:
368             # fall back to filename
369             ret = cmp(self.filename, other.filename)
370
371         return ret
372
373 class Binary(object):
374     """Representation of a binary package
375     """
376     def __init__(self, directory, hashed_file):
377         self.hashed_file = hashed_file
378         """file object for the .deb
379         @type: HashedFile
380         """
381
382         path = os.path.join(directory, hashed_file.filename)
383         data = apt_inst.DebFile(path).control.extractdata("control")
384
385         self.control = apt_pkg.TagSection(data)
386         """dict to access fields in DEBIAN/control
387         @type: dict-like
388         """
389
390     @property
391     def source(self):
392         """get tuple with source package name and version
393         @type: tuple of str
394         """
395         source = self.control.get("Source", None)
396         if source is None:
397             return (self.control["Package"], self.control["Version"])
398         match = re_field_source.match(source)
399         if not match:
400             raise InvalidBinaryException('{0}: Invalid Source field.'.format(self.hashed_file.filename))
401         version = match.group('version')
402         if version is None:
403             version = self.control['Version']
404         return (match.group('package'), version)
405
406     @property
407     def type(self):
408         """package type ('deb' or 'udeb')
409         @type: str
410         """
411         match = re_file_binary.match(self.hashed_file.filename)
412         if not match:
413             raise InvalidBinaryException('{0}: Does not match re_file_binary'.format(self.hashed_file.filename))
414         return match.group('type')
415
416     @property
417     def component(self):
418         """component name
419         @type: str
420         """
421         fields = self.control['Section'].split('/')
422         if len(fields) > 1:
423             return fields[0]
424         return "main"
425
426 class Source(object):
427     """Representation of a source package
428     """
429     def __init__(self, directory, hashed_files, keyrings, require_signature=True):
430         self.hashed_files = hashed_files
431         """list of source files (including the .dsc itself)
432         @type: list of L{HashedFile}
433         """
434
435         self._dsc_file = None
436         for f in hashed_files:
437             if re_file_dsc.match(f.filename):
438                 if self._dsc_file is not None:
439                     raise InvalidSourceException("Multiple .dsc found ({0} and {1})".format(self._dsc_file.filename, f.filename))
440                 else:
441                     self._dsc_file = f
442         dsc_file_path = os.path.join(directory, self._dsc_file.filename)
443         data = open(dsc_file_path, 'r').read()
444         self._signed_file = SignedFile(data, keyrings, require_signature)
445         self.dsc = apt_pkg.TagSection(self._signed_file.contents)
446         """dict to access fields in the .dsc file
447         @type: dict-like
448         """
449
450         self._files = None
451
452     @property
453     def files(self):
454         """dict mapping filenames to L{HashedFile} objects for additional source files
455
456         This list does not include the .dsc itself.
457
458         @type: dict
459         """
460         if self._files is None:
461             self._files = parse_file_list(self.dsc, False)
462         return self._files
463
464     @property
465     def primary_fingerprint(self):
466         """fingerprint of the key used to sign the .dsc
467         @type: str
468         """
469         return self._signed_file.primary_fingerprint
470
471     @property
472     def valid_signature(self):
473         """C{True} if the .dsc has a valid signature
474         @type: bool
475         """
476         return self._signed_file.valid
477
478     @property
479     def component(self):
480         """guessed component name
481
482         Might be wrong. Don't rely on this.
483
484         @type: str
485         """
486         if 'Section' not in self.dsc:
487             return 'main'
488         fields = self.dsc['Section'].split('/')
489         if len(fields) > 1:
490             return fields[0]
491         return "main"