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