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