]> git.decadent.org.uk Git - dak.git/blob - daklib/upload.py
daklib/archive.py, daklib/checks.py: implement upload blocks
[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 `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 from .gpg import SignedFile
29 from .regexes import *
30
31 class InvalidChangesException(Exception):
32     pass
33
34 class InvalidBinaryException(Exception):
35     pass
36
37 class InvalidSourceException(Exception):
38     pass
39
40 class InvalidHashException(Exception):
41     def __init__(self, filename, hash_name, expected, actual):
42         self.filename = filename
43         self.hash_name = hash_name
44         self.expected = expected
45         self.actual = actual
46     def __str__(self):
47         return "Invalid {0} hash for {1}: expected {2}, but got {3}.".format(self.hash_name, self.filename, self.expected, self.actual)
48
49 class InvalidFilenameException(Exception):
50     def __init__(self, filename):
51         self.filename = filename
52     def __str__(self):
53         return "Invalid filename '{0}'.".format(self.filename)
54
55 class HashedFile(object):
56     """file with checksums
57
58     Attributes:
59        filename (str): name of the file
60        size (long): size in bytes
61        md5sum (str): MD5 hash in hexdigits
62        sha1sum (str): SHA1 hash in hexdigits
63        sha256sum (str): SHA256 hash in hexdigits
64        section (str): section or None
65        priority (str): priority or None
66     """
67     def __init__(self, filename, size, md5sum, sha1sum, sha256sum, section=None, priority=None):
68         self.filename = filename
69         self.size = size
70         self.md5sum = md5sum
71         self.sha1sum = sha1sum
72         self.sha256sum = sha256sum
73         self.section = section
74         self.priority = priority
75
76     def check(self, directory):
77         """Validate hashes
78
79         Check if size and hashes match the expected value.
80
81         Args:
82            directory (str): directory the file is located in
83
84         Raises:
85            InvalidHashException: hash mismatch
86         """
87         path = os.path.join(directory, self.filename)
88         fh = open(path, 'r')
89
90         size = os.stat(path).st_size
91         if size != self.size:
92             raise InvalidHashException(self.filename, 'size', self.size, size)
93
94         md5sum = apt_pkg.md5sum(fh)
95         if md5sum != self.md5sum:
96             raise InvalidHashException(self.filename, 'md5sum', self.md5sum, md5sum)
97
98         fh.seek(0)
99         sha1sum = apt_pkg.sha1sum(fh)
100         if sha1sum != self.sha1sum:
101             raise InvalidHashException(self.filename, 'sha1sum', self.sha1sum, sha1sum)
102
103         fh.seek(0)
104         sha256sum = apt_pkg.sha256sum(fh)
105         if sha256sum != self.sha256sum:
106             raise InvalidHashException(self.filename, 'sha256sum', self.sha256sum, sha256sum)
107
108 def parse_file_list(control, has_priority_and_section):
109     """Parse Files and Checksums-* fields
110
111     Args:
112        control (dict-like): control file to take fields from
113        has_priority_and_section (bool): Files include section and priority (as in .changes)
114
115     Raises:
116        InvalidChangesException: missing fields or other grave errors
117
118     Returns:
119        dictonary mapping filenames to `daklib.upload.HashedFile` objects
120     """
121     entries = {}
122
123     for line in control["Files"].split('\n'):
124         if len(line) == 0:
125             continue
126
127         if has_priority_and_section:
128             (md5sum, size, section, priority, filename) = line.split()
129             entry = dict(md5sum=md5sum, size=long(size), section=section, priority=priority, filename=filename)
130         else:
131             (md5sum, size, filename) = line.split()
132             entry = dict(md5sum=md5sum, size=long(size), filename=filename)
133
134         entries[filename] = entry
135
136     for line in control["Checksums-Sha1"].split('\n'):
137         if len(line) == 0:
138             continue
139         (sha1sum, size, filename) = line.split()
140         entry = entries.get(filename, None)
141         if entry.get('size', None) != long(size):
142             raise InvalidChangesException('Size for {0} in Files and Checksum-Sha1 fields differ.'.format(filename))
143         entry['sha1sum'] = sha1sum
144
145     for line in control["Checksums-Sha256"].split('\n'):
146         if len(line) == 0:
147             continue
148         (sha256sum, size, filename) = line.split()
149         entry = entries.get(filename, None)
150         if entry is None:
151             raise InvalidChangesException('No sha256sum for {0}.'.format(filename))
152         if entry.get('size', None) != long(size):
153             raise InvalidChangesException('Size for {0} in Files and Checksum-Sha256 fields differ.'.format(filename))
154         entry['sha256sum'] = sha256sum
155
156     files = {}
157     for entry in entries.itervalues():
158         filename = entry['filename']
159         if 'size' not in entry:
160             raise InvalidChangesException('No size for {0}.'.format(filename))
161         if 'md5sum' not in entry:
162             raise InvalidChangesException('No md5sum for {0}.'.format(filename))
163         if 'sha1sum' not in entry:
164             raise InvalidChangesException('No sha1sum for {0}.'.format(filename))
165         if 'sha256sum' not in entry:
166             raise InvalidChangesException('No sha256sum for {0}.'.format(filename))
167         if not re_file_safe.match(filename):
168             raise InvalidChangesException("{0}: References file with unsafe filename {1}.".format(self.filename, filename))
169         f = files[filename] = HashedFile(**entry)
170
171     return files
172
173 class Changes(object):
174     """Representation of a .changes file
175
176     Attributes:
177        architectures (list of str): list of architectures included in the upload
178        binaries (list of daklib.upload.Binary): included binary packages
179        binary_names (list of str): names of included binary packages
180        byhand_files (list of daklib.upload.HashedFile): included byhand files
181        bytes (int): total size of files included in this upload in bytes
182        changes (dict-like): dict to access fields of the .changes file
183        closed_bugs (list of str): list of bugs closed by this upload
184        directory (str): directory the .changes is located in
185        distributions (list of str): list of target distributions for the upload
186        filename (str): name of the .changes file
187        files (dict): dict mapping filenames to daklib.upload.HashedFile objects
188        path (str): path to the .changes files
189        primary_fingerprint (str): fingerprint of the PGP key used for the signature
190        source (daklib.upload.Source or None): included source
191        valid_signature (bool): True if the changes has a valid signature
192     """
193     def __init__(self, directory, filename, keyrings, require_signature=True):
194         if not re_file_safe.match(filename):
195             raise InvalidChangesException('{0}: unsafe filename'.format(filename))
196         self.directory = directory
197         self.filename = filename
198         data = open(self.path).read()
199         self._signed_file = SignedFile(data, keyrings, require_signature)
200         self.changes = apt_pkg.TagSection(self._signed_file.contents)
201         self._binaries = None
202         self._source = None
203         self._files = None
204         self._keyrings = keyrings
205         self._require_signature = require_signature
206
207     @property
208     def path(self):
209         return os.path.join(self.directory, self.filename)
210
211     @property
212     def primary_fingerprint(self):
213         return self._signed_file.primary_fingerprint
214
215     @property
216     def valid_signature(self):
217         return self._signed_file.valid
218
219     @property
220     def architectures(self):
221         return self.changes['Architecture'].split()
222
223     @property
224     def distributions(self):
225         return self.changes['Distribution'].split()
226
227     @property
228     def source(self):
229         if self._source is None:
230             source_files = []
231             for f in self.files.itervalues():
232                 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename):
233                     source_files.append(f)
234             if len(source_files) > 0:
235                 self._source = Source(self.directory, source_files, self._keyrings, self._require_signature)
236         return self._source
237
238     @property
239     def binaries(self):
240         if self._binaries is None:
241             binaries = []
242             for f in self.files.itervalues():
243                 if re_file_binary.match(f.filename):
244                     binaries.append(Binary(self.directory, f))
245             self._binaries = binaries
246         return self._binaries
247
248     @property
249     def byhand_files(self):
250         byhand = []
251
252         for f in self.files.itervalues():
253             if re_file_dsc.match(f.filename) or re_file_source.match(f.filename) or re_file_binary.match(f.filename):
254                 continue
255             if f.section != 'byhand' and f.section[:4] != 'raw-':
256                 raise InvalidChangesException("{0}: {1} looks like a byhand package, but is in section {2}".format(self.filename, f.filename, f.section))
257             byhand.append(f)
258
259         return byhand
260
261     @property
262     def binary_names(self):
263         return self.changes['Binary'].split()
264
265     @property
266     def closed_bugs(self):
267         return self.changes.get('Closes', '').split()
268
269     @property
270     def files(self):
271         if self._files is None:
272             self._files = parse_file_list(self.changes, True)
273         return self._files
274
275     @property
276     def bytes(self):
277         count = 0
278         for f in self.files.itervalues():
279             count += f.size
280         return count
281
282     def __cmp__(self, other):
283         """Compare two changes packages
284
285         We sort by source name and version first.  If these are identical,
286         we sort changes that include source before those without source (so
287         that sourceful uploads get processed first), and finally fall back
288         to the filename (this should really never happen).
289
290         Returns:
291            -1 if self < other, 0 if self == other, 1 if self > other
292         """
293         ret = cmp(self.changes.get('Source'), other.changes.get('Source'))
294
295         if ret == 0:
296             # compare version
297             ret = apt_pkg.version_compare(self.changes.get('Version', ''), other.changes.get('Version', ''))
298
299         if ret == 0:
300             # sort changes with source before changes without source
301             if 'source' in self.architectures and 'source' not in other.architectures:
302                 ret = -1
303             elif 'source' not in self.architectures and 'source' in other.architectures:
304                 ret = 1
305             else:
306                 ret = 0
307
308         if ret == 0:
309             # fall back to filename
310             ret = cmp(self.filename, other.filename)
311
312         return ret
313
314 class Binary(object):
315     """Representation of a binary package
316
317     Attributes:
318        component (str): component name
319        control (dict-like): dict to access fields in DEBIAN/control
320        hashed_file (HashedFile): HashedFile object for the .deb
321     """
322     def __init__(self, directory, hashed_file):
323         self.hashed_file = hashed_file
324
325         path = os.path.join(directory, hashed_file.filename)
326         data = apt_inst.DebFile(path).control.extractdata("control")
327         self.control = apt_pkg.TagSection(data)
328
329     @property
330     def source(self):
331         """Get source package name and version
332
333         Returns:
334            tuple containing source package name and version
335         """
336         source = self.control.get("Source", None)
337         if source is None:
338             return (self.control["Package"], self.control["Version"])
339         match = re_field_source.match(source)
340         if not match:
341             raise InvalidBinaryException('{0}: Invalid Source field.'.format(self.hashed_file.filename))
342         version = match.group('version')
343         if version is None:
344             version = self.control['Version']
345         return (match.group('package'), version)
346
347     @property
348     def type(self):
349         """Get package type
350
351         Returns:
352            String with the package type ('deb' or 'udeb')
353         """
354         match = re_file_binary.match(self.hashed_file.filename)
355         if not match:
356             raise InvalidBinaryException('{0}: Does not match re_file_binary'.format(self.hashed_file.filename))
357         return match.group('type')
358
359     @property
360     def component(self):
361         fields = self.control['Section'].split('/')
362         if len(fields) > 1:
363             return fields[0]
364         return "main"
365
366 class Source(object):
367     """Representation of a source package
368
369     Attributes:
370        component (str): guessed component name. Might be wrong!
371        dsc (dict-like): dict to access fields in the .dsc file
372        hashed_files (list of daklib.upload.HashedFile): list of source files (including .dsc)
373        files (dict): dictonary mapping filenames to HashedFile objects for
374            additional source files (not including .dsc)
375        primary_fingerprint (str): fingerprint of the PGP key used for the signature
376        valid_signature (bool):  True if the dsc has a valid signature
377     """
378     def __init__(self, directory, hashed_files, keyrings, require_signature=True):
379         self.hashed_files = hashed_files
380         self._dsc_file = None
381         for f in hashed_files:
382             if re_file_dsc.match(f.filename):
383                 if self._dsc_file is not None:
384                     raise InvalidSourceException("Multiple .dsc found ({0} and {1})".format(self._dsc_file.filename, f.filename))
385                 else:
386                     self._dsc_file = f
387         dsc_file_path = os.path.join(directory, self._dsc_file.filename)
388         data = open(dsc_file_path, 'r').read()
389         self._signed_file = SignedFile(data, keyrings, require_signature)
390         self.dsc = apt_pkg.TagSection(self._signed_file.contents)
391         self._files = None
392
393     @property
394     def files(self):
395         if self._files is None:
396             self._files = parse_file_list(self.dsc, False)
397         return self._files
398
399     @property
400     def primary_fingerprint(self):
401         return self._signed_file.primary_fingerprint
402
403     @property
404     def valid_signature(self):
405         return self._signed_file.valid
406
407     @property
408     def component(self):
409         if 'Section' not in self.dsc:
410             return 'main'
411         fields = self.dsc['Section'].split('/')
412         if len(fields) > 1:
413             return fields[0]
414         return "main"