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