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