1# 2# changes.py — .changes file handling class 3# 4# This file was originally part of debexpo 5# https://alioth.debian.org/projects/debexpo/ 6# 7# Copyright © 2008 Jonny Lamb <jonny@debian.org> 8# Copyright © 2010 Jan Dittberner <jandd@debian.org> 9# Copyright © 2012 Arno Töll <arno@debian.org> 10# Copyright © 2012 Paul Tagliamonte <paultag@debian.org> 11# Copyright © 2014 Jérémy Bobbio <lunar@debian.org> 12# 13# Permission is hereby granted, free of charge, to any person 14# obtaining a copy of this software and associated documentation 15# files (the "Software"), to deal in the Software without 16# restriction, including without limitation the rights to use, 17# copy, modify, merge, publish, distribute, sublicense, and/or sell 18# copies of the Software, and to permit persons to whom the 19# Software is furnished to do so, subject to the following 20# conditions: 21# 22# The above copyright notice and this permission notice shall be 23# included in all copies or substantial portions of the Software. 24# 25# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 26# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 27# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 28# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 29# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 30# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 31# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 32# OTHER DEALINGS IN THE SOFTWARE. 33""" 34This code deals with the reading and processing of Debian .changes files. This 35code is copyright (c) Jonny Lamb, and is used by dput, rather then created as 36a result of it. Thank you Jonny. 37""" 38 39__author__ = 'Jonny Lamb' 40__copyright__ = 'Copyright © 2008 Jonny Lamb, Copyright © 2010 Jan Dittberner' 41__license__ = 'MIT' 42 43import os.path 44import hashlib 45import logging 46import subprocess 47 48from debian import deb822 49 50from .tools import tool_required 51 52logger = logging.getLogger(__name__) 53 54 55class ChangesFileException(Exception): 56 pass 57 58 59class Changes: 60 """ 61 Changes object to help process and store information regarding Debian 62 .changes files, used in the upload process. 63 """ 64 65 def __init__(self, filename=None, string=None): 66 """ 67 Object constructor. The object allows the user to specify **either**: 68 69 #. a path to a *changes* file to parse 70 #. a string with the *changes* file contents. 71 72 :: 73 74 a = Changes(filename='/tmp/packagename_version.changes') 75 b = Changes(string='Source: packagename\\nMaintainer: ...') 76 77 ``filename`` 78 Path to *changes* file to parse. 79 80 ``string`` 81 *changes* file in a string to parse. 82 """ 83 if (filename and string) or (not filename and not string): 84 raise TypeError 85 86 if filename: 87 self._absfile = os.path.abspath(filename) 88 self._directory = os.path.dirname(self._absfile) 89 self._data = deb822.Changes(open(filename, encoding='utf-8')) 90 self.basename = os.path.basename(filename) 91 else: 92 self._data = deb822.Changes(string) 93 94 if len(self._data) == 0: 95 raise ChangesFileException('Changes file could not be parsed.') 96 97 def get_filename(self): 98 """ 99 Returns the filename from which the changes file was generated from. 100 Please do note this is just the basename, not the entire full path, or 101 even a relative path. For the absolute path to the changes file, please 102 see :meth:`get_changes_file`. 103 """ 104 return self.basename 105 106 def get_changes_file(self): 107 """ 108 Return the full, absolute path to the changes file. For just the 109 filename, please see :meth:`get_filename`. 110 """ 111 return os.path.join(self._directory, self.get_filename()) 112 113 def get_path(self, filename): 114 """ 115 Return the full, absolute path to a file referenced by the changes 116 file. 117 """ 118 return os.path.join(self._directory, filename) 119 120 def get_files(self): 121 """ 122 Returns a list of files referenced in the changes file, such as 123 the .dsc, .deb(s), .orig.tar.gz, and .diff.gz or .debian.tar.gz. 124 All strings in the array will be absolute paths to the files. 125 """ 126 return [os.path.join(self._directory, z['name']) 127 for z in self._data['Files']] 128 129 def keys(self): 130 return self._data.keys() 131 132 def __getitem__(self, key): 133 """ 134 Returns the value of the rfc822 key specified. 135 136 ``key`` 137 Key of data to request. 138 """ 139 return self._data[key] 140 141 def __contains__(self, key): 142 """ 143 Returns whether the specified RFC822 key exists. 144 145 ``key`` 146 Key of data to check for existence. 147 """ 148 return key in self._data 149 150 def get(self, key, default=None): 151 """ 152 Returns the value of the rfc822 key specified, but defaults 153 to a specific value if not found in the rfc822 file. 154 155 ``key`` 156 Key of data to request. 157 158 ``default`` 159 Default return value if ``key`` does not exist. 160 """ 161 return self._data.get(key, default) 162 163 def get_as_string(self, key): 164 """ 165 Returns the value of the rfc822 key specified as the original string. 166 167 ``key`` 168 Key of data to request. 169 """ 170 return self._data.get_as_string(key) 171 172 def get_component(self): 173 """ 174 Returns the component of the package. 175 """ 176 return self._parse_section(self._data['Files'][0]['section'])[0] 177 178 def get_priority(self): 179 """ 180 Returns the priority of the package. 181 """ 182 return self._parse_section(self._data['Files'][0]['priority'])[1] 183 184 def get_section(self): 185 """ 186 Returns the section of the package. 187 """ 188 return self._parse_section(self._data['Files'][0]['section'])[1] 189 190 def get_dsc(self): 191 """ 192 Returns the name of the .dsc file. 193 """ 194 for item in self.get_files(): 195 if item.endswith('.dsc'): 196 return item 197 198 return None 199 200 def get_pool_path(self): 201 """ 202 Returns the path the changes file would be 203 """ 204 return self._data.get_pool_path() 205 206 def get_package_name(self): 207 """ 208 Returns the source package name 209 """ 210 return self.get("Source") 211 212 def _parse_section(self, section): 213 """ 214 Works out the component and section from the "Section" field. 215 Sections like `python` or `libdevel` are in main. 216 Sections with a prefix, separated with a forward-slash also show the 217 component. 218 It returns a list of strings in the form [component, section]. 219 220 For example, `non-free/python` has component `non-free` and section 221 `python`. 222 223 ``section`` 224 Section name to parse. 225 """ 226 if '/' in section: 227 return section.split('/') 228 229 return ['main', section] 230 231 def set_directory(self, directory): 232 if directory: 233 self._directory = directory 234 else: 235 self._directory = "" 236 237 def validate(self, check_hash="sha1", check_signature=True): 238 """ 239 See :meth:`validate_checksums` for ``check_hash``, and 240 :meth:`validate_signature` if ``check_signature`` is True. 241 """ 242 self.validate_checksums(check_hash) 243 if check_signature: 244 self.validate_signature(check_signature) 245 else: 246 logger.info("Not checking signature") 247 248 @tool_required('gpg') 249 def validate_signature(self, check_signature=True): 250 """ 251 Validate the GPG signature of a .changes file. 252 253 Throws a :class:`dput.exceptions.ChangesFileException` if there's 254 an issue with the GPG signature. Returns the GPG key ID. 255 """ 256 pipe = subprocess.Popen( 257 ["gpg", "--status-fd", "1", "--verify", "--batch", 258 self.get_changes_file()], 259 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 260 gpg_output, gpg_output_stderr = pipe.communicate() 261 262 if pipe.returncode != 0: 263 raise ChangesFileException( 264 "Unknown problem while verifying signature") 265 266 # contains verbose human-readable GPG information 267 gpg_output_stderr = str(gpg_output_stderr, encoding='utf8') 268 269 gpg_output = gpg_output.decode(encoding='UTF-8') 270 271 if gpg_output.count('[GNUPG:] GOODSIG'): 272 pass 273 elif gpg_output.count('[GNUPG:] BADSIG'): 274 raise ChangesFileException("Bad signature") 275 elif gpg_output.count('[GNUPG:] ERRSIG'): 276 raise ChangesFileException("Error verifying signature") 277 elif gpg_output.count('[GNUPG:] NODATA'): 278 raise ChangesFileException("No signature on") 279 else: 280 raise ChangesFileException( 281 "Unknown problem while verifying signature" 282 ) 283 284 key = None 285 for line in gpg_output.split("\n"): 286 if line.startswith('[GNUPG:] VALIDSIG'): 287 key = line.split()[2] 288 return key 289 290 def validate_checksums(self, check_hash="sha1"): 291 """ 292 Validate checksums for a package, using ``check_hack``'s type 293 to validate the package. 294 295 Valid ``check_hash`` types: 296 297 * sha1 298 * sha256 299 * md5 300 * md5sum 301 """ 302 logger.debug("validating %s checksums", check_hash) 303 304 for filename in self.get_files(): 305 if check_hash == "sha1": 306 hash_type = hashlib.sha1() 307 checksums = self.get("Checksums-Sha1") 308 field_name = "sha1" 309 elif check_hash == "sha256": 310 hash_type = hashlib.sha256() 311 checksums = self.get("Checksums-Sha256") 312 field_name = "sha256" 313 elif check_hash == "md5": 314 hash_type = hashlib.md5() 315 checksums = self.get("Files") 316 field_name = "md5sum" 317 318 changed_files = None # appease pylint 319 for changed_files in checksums: 320 if changed_files['name'] == os.path.basename(filename): 321 break 322 else: 323 assert( 324 "get_files() returns different files than Files: knows?!") 325 326 with open(os.path.join(self._directory, filename), "rb") as fc: 327 while True: 328 chunk = fc.read(131072) 329 if not chunk: 330 break 331 hash_type.update(chunk) 332 fc.close() 333 334 if not hash_type.hexdigest() == changed_files[field_name]: 335 raise ChangesFileException( 336 "Checksum mismatch for file %s: %s != %s" % ( 337 filename, 338 hash_type.hexdigest(), 339 changed_files[field_name] 340 )) 341 else: 342 logger.debug("%s Checksum for file %s matches", 343 field_name, filename) 344