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 *
31 import daklib.packagelist
33 class InvalidChangesException(Exception):
36 class InvalidBinaryException(Exception):
39 class InvalidSourceException(Exception):
42 class InvalidHashException(Exception):
43 def __init__(self, filename, hash_name, expected, actual):
44 self.filename = filename
45 self.hash_name = hash_name
46 self.expected = expected
49 return ("Invalid {0} hash for {1}:\n"
50 "According to the control file the {0} hash should be {2},\n"
53 "If you did not include {1} in you upload, a different version\n"
54 "might already be known to the archive software.") \
55 .format(self.hash_name, self.filename, self.expected, self.actual)
57 class InvalidFilenameException(Exception):
58 def __init__(self, filename):
59 self.filename = filename
61 return "Invalid filename '{0}'.".format(self.filename)
63 class HashedFile(object):
64 """file with checksums
66 def __init__(self, filename, size, md5sum, sha1sum, sha256sum, section=None, priority=None):
67 self.filename = filename
78 """MD5 hash in hexdigits
82 self.sha1sum = sha1sum
83 """SHA1 hash in hexdigits
87 self.sha256sum = sha256sum
88 """SHA256 hash in hexdigits
92 self.section = section
97 self.priority = priority
98 """priority or C{None}
103 def from_file(cls, directory, filename, section=None, priority=None):
104 """create with values for an existing file
106 Create a C{HashedFile} object that refers to an already existing file.
109 @param directory: directory the file is located in
112 @param filename: filename
114 @type section: str or C{None}
115 @param section: optional section as given in .changes files
117 @type priority: str or C{None}
118 @param priority: optional priority as given in .changes files
120 @rtype: L{HashedFile}
121 @return: C{HashedFile} object for the given file
123 path = os.path.join(directory, filename)
124 size = os.stat(path).st_size
125 with open(path, 'r') as fh:
126 hashes = apt_pkg.Hashes(fh)
127 return cls(filename, size, hashes.md5, hashes.sha1, hashes.sha256, section, priority)
129 def check(self, directory):
132 Check if size and hashes match the expected value.
135 @param directory: directory the file is located in
137 @raise InvalidHashException: hash mismatch
139 path = os.path.join(directory, self.filename)
141 size = os.stat(path).st_size
142 if size != self.size:
143 raise InvalidHashException(self.filename, 'size', self.size, size)
145 with open(path) as fh:
146 hashes = apt_pkg.Hashes(fh)
148 if hashes.md5 != self.md5sum:
149 raise InvalidHashException(self.filename, 'md5sum', self.md5sum, hashes.md5)
151 if hashes.sha1 != self.sha1sum:
152 raise InvalidHashException(self.filename, 'sha1sum', self.sha1sum, hashes.sha1)
154 if hashes.sha256 != self.sha256sum:
155 raise InvalidHashException(self.filename, 'sha256sum', self.sha256sum, hashes.sha256)
157 def parse_file_list(control, has_priority_and_section):
158 """Parse Files and Checksums-* fields
160 @type control: dict-like
161 @param control: control file to take fields from
163 @type has_priority_and_section: bool
164 @param has_priority_and_section: Files field include section and priority
167 @raise InvalidChangesException: missing fields or other grave errors
170 @return: dict mapping filenames to L{daklib.upload.HashedFile} objects
174 for line in control.get("Files", "").split('\n'):
178 if has_priority_and_section:
179 (md5sum, size, section, priority, filename) = line.split()
180 entry = dict(md5sum=md5sum, size=long(size), section=section, priority=priority, filename=filename)
182 (md5sum, size, filename) = line.split()
183 entry = dict(md5sum=md5sum, size=long(size), filename=filename)
185 entries[filename] = entry
187 for line in control.get("Checksums-Sha1", "").split('\n'):
190 (sha1sum, size, filename) = line.split()
191 entry = entries.get(filename, None)
193 raise InvalidChangesException('{0} is listed in Checksums-Sha1, but not in Files.'.format(filename))
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
198 for line in control.get("Checksums-Sha256", "").split('\n'):
201 (sha256sum, size, filename) = line.split()
202 entry = entries.get(filename, None)
204 raise InvalidChangesException('{0} is listed in Checksums-Sha256, but not in Files.'.format(filename))
205 if entry is not None and entry.get('size', None) != long(size):
206 raise InvalidChangesException('Size for {0} in Files and Checksum-Sha256 fields differ.'.format(filename))
207 entry['sha256sum'] = sha256sum
210 for entry in entries.itervalues():
211 filename = entry['filename']
212 if 'size' not in entry:
213 raise InvalidChangesException('No size for {0}.'.format(filename))
214 if 'md5sum' not in entry:
215 raise InvalidChangesException('No md5sum for {0}.'.format(filename))
216 if 'sha1sum' not in entry:
217 raise InvalidChangesException('No sha1sum for {0}.'.format(filename))
218 if 'sha256sum' not in entry:
219 raise InvalidChangesException('No sha256sum for {0}.'.format(filename))
220 if not re_file_safe.match(filename):
221 raise InvalidChangesException("{0}: References file with unsafe filename {1}.".format(self.filename, filename))
222 f = files[filename] = HashedFile(**entry)
226 class Changes(object):
227 """Representation of a .changes file
229 def __init__(self, directory, filename, keyrings, require_signature=True):
230 if not re_file_safe.match(filename):
231 raise InvalidChangesException('{0}: unsafe filename'.format(filename))
233 self.directory = directory
234 """directory the .changes is located in
238 self.filename = filename
239 """name of the .changes file
243 data = open(self.path).read()
244 self._signed_file = SignedFile(data, keyrings, require_signature)
245 self.changes = apt_pkg.TagSection(self._signed_file.contents)
246 """dict to access fields of the .changes file
250 self._binaries = None
253 self._keyrings = keyrings
254 self._require_signature = require_signature
258 """path to the .changes file
261 return os.path.join(self.directory, self.filename)
264 def primary_fingerprint(self):
265 """fingerprint of the key used for signing the .changes file
268 return self._signed_file.primary_fingerprint
271 def valid_signature(self):
272 """C{True} if the .changes has a valid signature
275 return self._signed_file.valid
278 def architectures(self):
279 """list of architectures included in the upload
282 return self.changes.get('Architecture', '').split()
285 def distributions(self):
286 """list of target distributions for the upload
289 return self.changes['Distribution'].split()
293 """included source or C{None}
294 @type: L{daklib.upload.Source} or C{None}
296 if self._source is None:
298 for f in self.files.itervalues():
299 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename):
300 source_files.append(f)
301 if len(source_files) > 0:
302 self._source = Source(self.directory, source_files, self._keyrings, self._require_signature)
307 """C{True} if the upload includes source
310 return "source" in self.architectures
313 def source_name(self):
314 """source package name
317 return re_field_source.match(self.changes['Source']).group('package')
321 """included binary packages
322 @type: list of L{daklib.upload.Binary}
324 if self._binaries is None:
326 for f in self.files.itervalues():
327 if re_file_binary.match(f.filename):
328 binaries.append(Binary(self.directory, f))
329 self._binaries = binaries
330 return self._binaries
333 def byhand_files(self):
334 """included byhand files
335 @type: list of L{daklib.upload.HashedFile}
339 for f in self.files.itervalues():
340 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename) or re_file_binary.match(f.filename):
342 if f.section != 'byhand' and f.section[:4] != 'raw-':
343 raise InvalidChangesException("{0}: {1} looks like a byhand package, but is in section {2}".format(self.filename, f.filename, f.section))
349 def binary_names(self):
350 """names of included binary packages
353 return self.changes['Binary'].split()
356 def closed_bugs(self):
357 """bugs closed by this upload
360 return self.changes.get('Closes', '').split()
364 """dict mapping filenames to L{daklib.upload.HashedFile} objects
367 if self._files is None:
368 self._files = parse_file_list(self.changes, True)
373 """total size of files included in this upload in bytes
377 for f in self.files.itervalues():
381 def __cmp__(self, other):
382 """compare two changes files
384 We sort by source name and version first. If these are identical,
385 we sort changes that include source before those without source (so
386 that sourceful uploads get processed first), and finally fall back
387 to the filename (this should really never happen).
390 @return: n where n < 0 if self < other, n = 0 if self == other, n > 0 if self > other
392 ret = cmp(self.changes.get('Source'), other.changes.get('Source'))
396 ret = apt_pkg.version_compare(self.changes.get('Version', ''), other.changes.get('Version', ''))
399 # sort changes with source before changes without source
400 if 'source' in self.architectures and 'source' not in other.architectures:
402 elif 'source' not in self.architectures and 'source' in other.architectures:
408 # fall back to filename
409 ret = cmp(self.filename, other.filename)
413 class Binary(object):
414 """Representation of a binary package
416 def __init__(self, directory, hashed_file):
417 self.hashed_file = hashed_file
418 """file object for the .deb
422 path = os.path.join(directory, hashed_file.filename)
423 data = apt_inst.DebFile(path).control.extractdata("control")
425 self.control = apt_pkg.TagSection(data)
426 """dict to access fields in DEBIAN/control
431 def from_file(cls, directory, filename):
432 hashed_file = HashedFile.from_file(directory, filename)
433 return cls(directory, hashed_file)
437 """get tuple with source package name and version
440 source = self.control.get("Source", None)
442 return (self.control["Package"], self.control["Version"])
443 match = re_field_source.match(source)
445 raise InvalidBinaryException('{0}: Invalid Source field.'.format(self.hashed_file.filename))
446 version = match.group('version')
448 version = self.control['Version']
449 return (match.group('package'), version)
453 return self.control['Package']
457 """package type ('deb' or 'udeb')
460 match = re_file_binary.match(self.hashed_file.filename)
462 raise InvalidBinaryException('{0}: Does not match re_file_binary'.format(self.hashed_file.filename))
463 return match.group('type')
470 fields = self.control['Section'].split('/')
475 class Source(object):
476 """Representation of a source package
478 def __init__(self, directory, hashed_files, keyrings, require_signature=True):
479 self.hashed_files = hashed_files
480 """list of source files (including the .dsc itself)
481 @type: list of L{HashedFile}
484 self._dsc_file = None
485 for f in hashed_files:
486 if re_file_dsc.match(f.filename):
487 if self._dsc_file is not None:
488 raise InvalidSourceException("Multiple .dsc found ({0} and {1})".format(self._dsc_file.filename, f.filename))
492 # make sure the hash for the dsc is valid before we use it
493 self._dsc_file.check(directory)
495 dsc_file_path = os.path.join(directory, self._dsc_file.filename)
496 data = open(dsc_file_path, 'r').read()
497 self._signed_file = SignedFile(data, keyrings, require_signature)
498 self.dsc = apt_pkg.TagSection(self._signed_file.contents)
499 """dict to access fields in the .dsc file
503 self.package_list = daklib.packagelist.PackageList(self.dsc)
504 """Information about packages built by the source.
505 @type: daklib.packagelist.PackageList
511 def from_file(cls, directory, filename, keyrings, require_signature=True):
512 hashed_file = HashedFile.from_file(directory, filename)
513 return cls(directory, [hashed_file], keyrings, require_signature)
517 """dict mapping filenames to L{HashedFile} objects for additional source files
519 This list does not include the .dsc itself.
523 if self._files is None:
524 self._files = parse_file_list(self.dsc, False)
528 def primary_fingerprint(self):
529 """fingerprint of the key used to sign the .dsc
532 return self._signed_file.primary_fingerprint
535 def valid_signature(self):
536 """C{True} if the .dsc has a valid signature
539 return self._signed_file.valid
543 """guessed component name
545 Might be wrong. Don't rely on this.
549 if 'Section' not in self.dsc:
551 fields = self.dsc['Section'].split('/')
558 """filename of .dsc file
561 return self._dsc_file.filename