1 # Copyright (C) 2012, Ansgar Burchardt <ansgar@debian.org>
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.
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.
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.
17 """module to handle uploads not yet installed to the archive
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.
29 from daklib.gpg import SignedFile
30 from daklib.regexes import *
32 class InvalidChangesException(Exception):
35 class InvalidBinaryException(Exception):
38 class InvalidSourceException(Exception):
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
48 return ("Invalid {0} hash for {1}:\n"
49 "According to the control file the {0} hash should be {2},\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)
56 class InvalidFilenameException(Exception):
57 def __init__(self, filename):
58 self.filename = filename
60 return "Invalid filename '{0}'.".format(self.filename)
62 class HashedFile(object):
63 """file with checksums
65 def __init__(self, filename, size, md5sum, sha1sum, sha256sum, section=None, priority=None):
66 self.filename = filename
77 """MD5 hash in hexdigits
81 self.sha1sum = sha1sum
82 """SHA1 hash in hexdigits
86 self.sha256sum = sha256sum
87 """SHA256 hash in hexdigits
91 self.section = section
96 self.priority = priority
97 """priority or C{None}
102 def from_file(cls, directory, filename, section=None, priority=None):
103 """create with values for an existing file
105 Create a C{HashedFile} object that refers to an already existing file.
108 @param directory: directory the file is located in
111 @param filename: filename
113 @type section: str or C{None}
114 @param section: optional section as given in .changes files
116 @type priority: str or C{None}
117 @param priority: optional priority as given in .changes files
119 @rtype: L{HashedFile}
120 @return: C{HashedFile} object for the given file
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)
128 def check(self, directory):
131 Check if size and hashes match the expected value.
134 @param directory: directory the file is located in
136 @raise InvalidHashException: hash mismatch
138 path = os.path.join(directory, self.filename)
140 size = os.stat(path).st_size
141 if size != self.size:
142 raise InvalidHashException(self.filename, 'size', self.size, size)
144 with open(path) as fh:
145 hashes = apt_pkg.Hashes(fh)
147 if hashes.md5 != self.md5sum:
148 raise InvalidHashException(self.filename, 'md5sum', self.md5sum, hashes.md5)
150 if hashes.sha1 != self.sha1sum:
151 raise InvalidHashException(self.filename, 'sha1sum', self.sha1sum, hashes.sha1)
153 if hashes.sha256 != self.sha256sum:
154 raise InvalidHashException(self.filename, 'sha256sum', self.sha256sum, hashes.sha256)
156 def parse_file_list(control, has_priority_and_section):
157 """Parse Files and Checksums-* fields
159 @type control: dict-like
160 @param control: control file to take fields from
162 @type has_priority_and_section: bool
163 @param has_priority_and_section: Files field include section and priority
166 @raise InvalidChangesException: missing fields or other grave errors
169 @return: dict mapping filenames to L{daklib.upload.HashedFile} objects
173 for line in control.get("Files", "").split('\n'):
177 if has_priority_and_section:
178 (md5sum, size, section, priority, filename) = line.split()
179 entry = dict(md5sum=md5sum, size=long(size), section=section, priority=priority, filename=filename)
181 (md5sum, size, filename) = line.split()
182 entry = dict(md5sum=md5sum, size=long(size), filename=filename)
184 entries[filename] = entry
186 for line in control.get("Checksums-Sha1", "").split('\n'):
189 (sha1sum, size, filename) = line.split()
190 entry = entries.get(filename, None)
192 raise InvalidChangesException('{0} is listed in Checksums-Sha1, but not in Files.'.format(filename))
193 if entry is not None and entry.get('size', None) != long(size):
194 raise InvalidChangesException('Size for {0} in Files and Checksum-Sha1 fields differ.'.format(filename))
195 entry['sha1sum'] = sha1sum
197 for line in control.get("Checksums-Sha256", "").split('\n'):
200 (sha256sum, size, filename) = line.split()
201 entry = entries.get(filename, None)
203 raise InvalidChangesException('{0} is listed in Checksums-Sha256, but not in Files.'.format(filename))
204 if entry is not None and entry.get('size', None) != long(size):
205 raise InvalidChangesException('Size for {0} in Files and Checksum-Sha256 fields differ.'.format(filename))
206 entry['sha256sum'] = sha256sum
209 for entry in entries.itervalues():
210 filename = entry['filename']
211 if 'size' not in entry:
212 raise InvalidChangesException('No size for {0}.'.format(filename))
213 if 'md5sum' not in entry:
214 raise InvalidChangesException('No md5sum for {0}.'.format(filename))
215 if 'sha1sum' not in entry:
216 raise InvalidChangesException('No sha1sum for {0}.'.format(filename))
217 if 'sha256sum' not in entry:
218 raise InvalidChangesException('No sha256sum for {0}.'.format(filename))
219 if not re_file_safe.match(filename):
220 raise InvalidChangesException("{0}: References file with unsafe filename {1}.".format(self.filename, filename))
221 f = files[filename] = HashedFile(**entry)
225 class Changes(object):
226 """Representation of a .changes file
228 def __init__(self, directory, filename, keyrings, require_signature=True):
229 if not re_file_safe.match(filename):
230 raise InvalidChangesException('{0}: unsafe filename'.format(filename))
232 self.directory = directory
233 """directory the .changes is located in
237 self.filename = filename
238 """name of the .changes file
242 data = open(self.path).read()
243 self._signed_file = SignedFile(data, keyrings, require_signature)
244 self.changes = apt_pkg.TagSection(self._signed_file.contents)
245 """dict to access fields of the .changes file
249 self._binaries = None
252 self._keyrings = keyrings
253 self._require_signature = require_signature
257 """path to the .changes file
260 return os.path.join(self.directory, self.filename)
263 def primary_fingerprint(self):
264 """fingerprint of the key used for signing the .changes file
267 return self._signed_file.primary_fingerprint
270 def valid_signature(self):
271 """C{True} if the .changes has a valid signature
274 return self._signed_file.valid
277 def architectures(self):
278 """list of architectures included in the upload
281 return self.changes.get('Architecture', '').split()
284 def distributions(self):
285 """list of target distributions for the upload
288 return self.changes['Distribution'].split()
292 """included source or C{None}
293 @type: L{daklib.upload.Source} or C{None}
295 if self._source is None:
297 for f in self.files.itervalues():
298 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename):
299 source_files.append(f)
300 if len(source_files) > 0:
301 self._source = Source(self.directory, source_files, self._keyrings, self._require_signature)
306 """C{True} if the upload includes source
309 return "source" in self.architectures
312 def source_name(self):
313 """source package name
316 return re_field_source.match(self.changes['Source']).group('package')
320 """included binary packages
321 @type: list of L{daklib.upload.Binary}
323 if self._binaries is None:
325 for f in self.files.itervalues():
326 if re_file_binary.match(f.filename):
327 binaries.append(Binary(self.directory, f))
328 self._binaries = binaries
329 return self._binaries
332 def byhand_files(self):
333 """included byhand files
334 @type: list of L{daklib.upload.HashedFile}
338 for f in self.files.itervalues():
339 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename) or re_file_binary.match(f.filename):
341 if f.section != 'byhand' and f.section[:4] != 'raw-':
342 raise InvalidChangesException("{0}: {1} looks like a byhand package, but is in section {2}".format(self.filename, f.filename, f.section))
348 def binary_names(self):
349 """names of included binary packages
352 return self.changes['Binary'].split()
355 def closed_bugs(self):
356 """bugs closed by this upload
359 return self.changes.get('Closes', '').split()
363 """dict mapping filenames to L{daklib.upload.HashedFile} objects
366 if self._files is None:
367 self._files = parse_file_list(self.changes, True)
372 """total size of files included in this upload in bytes
376 for f in self.files.itervalues():
380 def __cmp__(self, other):
381 """compare two changes files
383 We sort by source name and version first. If these are identical,
384 we sort changes that include source before those without source (so
385 that sourceful uploads get processed first), and finally fall back
386 to the filename (this should really never happen).
389 @return: n where n < 0 if self < other, n = 0 if self == other, n > 0 if self > other
391 ret = cmp(self.changes.get('Source'), other.changes.get('Source'))
395 ret = apt_pkg.version_compare(self.changes.get('Version', ''), other.changes.get('Version', ''))
398 # sort changes with source before changes without source
399 if 'source' in self.architectures and 'source' not in other.architectures:
401 elif 'source' not in self.architectures and 'source' in other.architectures:
407 # fall back to filename
408 ret = cmp(self.filename, other.filename)
412 class Binary(object):
413 """Representation of a binary package
415 def __init__(self, directory, hashed_file):
416 self.hashed_file = hashed_file
417 """file object for the .deb
421 path = os.path.join(directory, hashed_file.filename)
422 data = apt_inst.DebFile(path).control.extractdata("control")
424 self.control = apt_pkg.TagSection(data)
425 """dict to access fields in DEBIAN/control
430 def from_file(cls, directory, filename):
431 hashed_file = HashedFile.from_file(directory, filename)
432 return cls(directory, hashed_file)
436 """get tuple with source package name and version
439 source = self.control.get("Source", None)
441 return (self.control["Package"], self.control["Version"])
442 match = re_field_source.match(source)
444 raise InvalidBinaryException('{0}: Invalid Source field.'.format(self.hashed_file.filename))
445 version = match.group('version')
447 version = self.control['Version']
448 return (match.group('package'), version)
452 """package type ('deb' or 'udeb')
455 match = re_file_binary.match(self.hashed_file.filename)
457 raise InvalidBinaryException('{0}: Does not match re_file_binary'.format(self.hashed_file.filename))
458 return match.group('type')
465 fields = self.control['Section'].split('/')
470 class Source(object):
471 """Representation of a source package
473 def __init__(self, directory, hashed_files, keyrings, require_signature=True):
474 self.hashed_files = hashed_files
475 """list of source files (including the .dsc itself)
476 @type: list of L{HashedFile}
479 self._dsc_file = None
480 for f in hashed_files:
481 if re_file_dsc.match(f.filename):
482 if self._dsc_file is not None:
483 raise InvalidSourceException("Multiple .dsc found ({0} and {1})".format(self._dsc_file.filename, f.filename))
487 # make sure the hash for the dsc is valid before we use it
488 self._dsc_file.check(directory)
490 dsc_file_path = os.path.join(directory, self._dsc_file.filename)
491 data = open(dsc_file_path, 'r').read()
492 self._signed_file = SignedFile(data, keyrings, require_signature)
493 self.dsc = apt_pkg.TagSection(self._signed_file.contents)
494 """dict to access fields in the .dsc file
501 def from_file(cls, directory, filename, keyrings, require_signature=True):
502 hashed_file = HashedFile.from_file(directory, filename)
503 return cls(directory, [hashed_file], keyrings, require_signature)
507 """dict mapping filenames to L{HashedFile} objects for additional source files
509 This list does not include the .dsc itself.
513 if self._files is None:
514 self._files = parse_file_list(self.dsc, False)
518 def primary_fingerprint(self):
519 """fingerprint of the key used to sign the .dsc
522 return self._signed_file.primary_fingerprint
525 def valid_signature(self):
526 """C{True} if the .dsc has a valid signature
529 return self._signed_file.valid
533 """guessed component name
535 Might be wrong. Don't rely on this.
539 if 'Section' not in self.dsc:
541 fields = self.dsc['Section'].split('/')
548 """filename of .dsc file
551 return self._dsc_file.filename