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