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 signature_timestamp(self):
279 return self._signed_file.signature_timestamp
282 def contents_sha1(self):
283 return self._signed_file.contents_sha1
286 def architectures(self):
287 """list of architectures included in the upload
290 return self.changes.get('Architecture', '').split()
293 def distributions(self):
294 """list of target distributions for the upload
297 return self.changes['Distribution'].split()
301 """included source or C{None}
302 @type: L{daklib.upload.Source} or C{None}
304 if self._source is None:
306 for f in self.files.itervalues():
307 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename):
308 source_files.append(f)
309 if len(source_files) > 0:
310 self._source = Source(self.directory, source_files, self._keyrings, self._require_signature)
315 """C{True} if the upload includes source
318 return "source" in self.architectures
321 def source_name(self):
322 """source package name
325 return re_field_source.match(self.changes['Source']).group('package')
329 """included binary packages
330 @type: list of L{daklib.upload.Binary}
332 if self._binaries is None:
334 for f in self.files.itervalues():
335 if re_file_binary.match(f.filename):
336 binaries.append(Binary(self.directory, f))
337 self._binaries = binaries
338 return self._binaries
341 def byhand_files(self):
342 """included byhand files
343 @type: list of L{daklib.upload.HashedFile}
347 for f in self.files.itervalues():
348 if re_file_dsc.match(f.filename) or re_file_source.match(f.filename) or re_file_binary.match(f.filename):
350 if f.section != 'byhand' and f.section[:4] != 'raw-':
351 raise InvalidChangesException("{0}: {1} looks like a byhand package, but is in section {2}".format(self.filename, f.filename, f.section))
357 def binary_names(self):
358 """names of included binary packages
361 return self.changes['Binary'].split()
364 def closed_bugs(self):
365 """bugs closed by this upload
368 return self.changes.get('Closes', '').split()
372 """dict mapping filenames to L{daklib.upload.HashedFile} objects
375 if self._files is None:
376 self._files = parse_file_list(self.changes, True)
381 """total size of files included in this upload in bytes
385 for f in self.files.itervalues():
389 def __cmp__(self, other):
390 """compare two changes files
392 We sort by source name and version first. If these are identical,
393 we sort changes that include source before those without source (so
394 that sourceful uploads get processed first), and finally fall back
395 to the filename (this should really never happen).
398 @return: n where n < 0 if self < other, n = 0 if self == other, n > 0 if self > other
400 ret = cmp(self.changes.get('Source'), other.changes.get('Source'))
404 ret = apt_pkg.version_compare(self.changes.get('Version', ''), other.changes.get('Version', ''))
407 # sort changes with source before changes without source
408 if 'source' in self.architectures and 'source' not in other.architectures:
410 elif 'source' not in self.architectures and 'source' in other.architectures:
416 # fall back to filename
417 ret = cmp(self.filename, other.filename)
421 class Binary(object):
422 """Representation of a binary package
424 def __init__(self, directory, hashed_file):
425 self.hashed_file = hashed_file
426 """file object for the .deb
430 path = os.path.join(directory, hashed_file.filename)
431 data = apt_inst.DebFile(path).control.extractdata("control")
433 self.control = apt_pkg.TagSection(data)
434 """dict to access fields in DEBIAN/control
439 def from_file(cls, directory, filename):
440 hashed_file = HashedFile.from_file(directory, filename)
441 return cls(directory, hashed_file)
445 """get tuple with source package name and version
448 source = self.control.get("Source", None)
450 return (self.control["Package"], self.control["Version"])
451 match = re_field_source.match(source)
453 raise InvalidBinaryException('{0}: Invalid Source field.'.format(self.hashed_file.filename))
454 version = match.group('version')
456 version = self.control['Version']
457 return (match.group('package'), version)
461 return self.control['Package']
465 """package type ('deb' or 'udeb')
468 match = re_file_binary.match(self.hashed_file.filename)
470 raise InvalidBinaryException('{0}: Does not match re_file_binary'.format(self.hashed_file.filename))
471 return match.group('type')
478 fields = self.control['Section'].split('/')
483 class Source(object):
484 """Representation of a source package
486 def __init__(self, directory, hashed_files, keyrings, require_signature=True):
487 self.hashed_files = hashed_files
488 """list of source files (including the .dsc itself)
489 @type: list of L{HashedFile}
492 self._dsc_file = None
493 for f in hashed_files:
494 if re_file_dsc.match(f.filename):
495 if self._dsc_file is not None:
496 raise InvalidSourceException("Multiple .dsc found ({0} and {1})".format(self._dsc_file.filename, f.filename))
500 # make sure the hash for the dsc is valid before we use it
501 self._dsc_file.check(directory)
503 dsc_file_path = os.path.join(directory, self._dsc_file.filename)
504 data = open(dsc_file_path, 'r').read()
505 self._signed_file = SignedFile(data, keyrings, require_signature)
506 self.dsc = apt_pkg.TagSection(self._signed_file.contents)
507 """dict to access fields in the .dsc file
511 self.package_list = daklib.packagelist.PackageList(self.dsc)
512 """Information about packages built by the source.
513 @type: daklib.packagelist.PackageList
519 def from_file(cls, directory, filename, keyrings, require_signature=True):
520 hashed_file = HashedFile.from_file(directory, filename)
521 return cls(directory, [hashed_file], keyrings, require_signature)
525 """dict mapping filenames to L{HashedFile} objects for additional source files
527 This list does not include the .dsc itself.
531 if self._files is None:
532 self._files = parse_file_list(self.dsc, False)
536 def primary_fingerprint(self):
537 """fingerprint of the key used to sign the .dsc
540 return self._signed_file.primary_fingerprint
543 def valid_signature(self):
544 """C{True} if the .dsc has a valid signature
547 return self._signed_file.valid
551 """guessed component name
553 Might be wrong. Don't rely on this.
557 if 'Section' not in self.dsc:
559 fields = self.dsc['Section'].split('/')
566 """filename of .dsc file
569 return self._dsc_file.filename