Welcome to mirror list, hosted at ThFree Co, Russian Federation.

base_code_signer.py « codesign « buildbot « build_files - git.blender.org/blender.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: f045e9c824235159aee7a513124c46789bac84c2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# ##### BEGIN GPL LICENSE BLOCK #####
#
#  This program is free software; you can redistribute it and/or
#  modify it under the terms of the GNU General Public License
#  as published by the Free Software Foundation; either version 2
#  of the License, or (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software Foundation,
#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####

# <pep8 compliant>

# Signing process overview.
#
# From buildbot worker side:
#  - Files which needs to be signed are collected from either a directory to
#    sign all signable files in there, or by filename of a single file to sign.
#  - Those files gets packed into an archive and stored in a location location
#    which is watched by the signing server.
#  - A marker READY file is created which indicates the archive is ready for
#    access.
#  - Wait for the server to provide an archive with signed files.
#    This is done by watching for the READY file which corresponds to an archive
#    coming from the signing server.
#  - Unpack the signed signed files from the archives and replace original ones.
#
# From code sign server:
#  - Watch special location for a READY file which indicates the there is an
#    archive with files which are to be signed.
#  - Unpack the archive to a temporary location.
#  - Run codesign tool and make sure all the files are signed.
#  - Pack the signed files and store them in a location which is watched by
#    the buildbot worker.
#  - Create a READY file which indicates that the archive with signed files is
#    ready.

import abc
import logging
import shutil
import subprocess
import time
import tarfile
import uuid

from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Iterable, List

import codesign.util as util

from codesign.absolute_and_relative_filename import AbsoluteAndRelativeFileName
from codesign.archive_with_indicator import ArchiveWithIndicator
from codesign.exception import CodeSignException


logger = logging.getLogger(__name__)
logger_builder = logger.getChild('builder')
logger_server = logger.getChild('server')


def pack_files(files: Iterable[AbsoluteAndRelativeFileName],
               archive_filepath: Path) -> None:
    """
    Create tar archive from given files for the signing pipeline.
    Is used by buildbot worker to create an archive of files which are to be
    signed, and by signing server to send signed files back to the worker.
    """
    with tarfile.TarFile.open(archive_filepath, 'w') as tar_file_handle:
        for file_info in files:
            tar_file_handle.add(file_info.absolute_filepath,
                                arcname=file_info.relative_filepath)


def extract_files(archive_filepath: Path,
                  extraction_dir: Path) -> None:
    """
    Extract all files form the given archive into the given direcotry.
    """

    # TODO(sergey): Verify files in the archive have relative path.

    with tarfile.TarFile.open(archive_filepath, mode='r') as tar_file_handle:
        tar_file_handle.extractall(path=extraction_dir)


class BaseCodeSigner(metaclass=abc.ABCMeta):
    """
    Base class for a platform-specific signer of binaries.

    Contains all the logic shared across platform-specific implementations, such
    as synchronization and notification logic.

    Platform specific bits (such as actual command for signing the binary) are
    to be implemented as a subclass.

    Provides utilities code signing as a whole, including functionality needed
    by a signing server and a buildbot worker.

    The signer and builder may run on separate machines, the only requirement is
    that they have access to a directory which is shared between them. For the
    security concerns this is to be done as a separate machine (or as a Shared
    Folder configuration in VirtualBox configuration). This directory might be
    mounted under different base paths, but its underlying storage is to be
    the same.

    The code signer is short-lived on a buildbot worker side, and is living
    forever on a code signing server side.
    """

    # TODO(sergey): Find a neat way to have config annotated.
    # config: Config

    # Storage directory where builder puts files which are requested to be
    # signed.
    # Consider this an input of the code signing server.
    unsigned_storage_dir: Path

    # Storage where signed files are stored.
    # Consider this an output of the code signer server.
    signed_storage_dir: Path

    # Platform the code is currently executing on.
    platform: util.Platform

    def __init__(self, config):
        self.config = config

        absolute_shared_storage_dir = config.SHARED_STORAGE_DIR.resolve()

        # Unsigned (signing server input) configuration.
        self.unsigned_storage_dir = absolute_shared_storage_dir / 'unsigned'

        # Signed (signing server output) configuration.
        self.signed_storage_dir = absolute_shared_storage_dir / 'signed'

        self.platform = util.get_current_platform()

    def cleanup_environment_for_builder(self) -> None:
        # TODO(sergey): Revisit need of cleaning up the existing files.
        # In practice it wasn't so helpful, and with multiple clients
        # talking to the same server it becomes even more tricky.
        pass

    def cleanup_environment_for_signing_server(self) -> None:
        # TODO(sergey): Revisit need of cleaning up the existing files.
        # In practice it wasn't so helpful, and with multiple clients
        # talking to the same server it becomes even more tricky.
        pass

    def generate_request_id(self) -> str:
        """
        Generate an unique identifier for code signing request.
        """
        return str(uuid.uuid4())

    def archive_info_for_request_id(
            self, path: Path, request_id: str) -> ArchiveWithIndicator:
        return ArchiveWithIndicator(
            path, f'{request_id}.tar', f'{request_id}.ready')

    def signed_archive_info_for_request_id(
            self, request_id: str) -> ArchiveWithIndicator:
        return self.archive_info_for_request_id(
            self.signed_storage_dir, request_id)

    def unsigned_archive_info_for_request_id(
            self, request_id: str) -> ArchiveWithIndicator:
        return self.archive_info_for_request_id(
            self.unsigned_storage_dir, request_id)

    ############################################################################
    # Buildbot worker side helpers.

    @abc.abstractmethod
    def check_file_is_to_be_signed(
            self, file: AbsoluteAndRelativeFileName) -> bool:
        """
        Check whether file is to be signed.

        Is used by both single file signing pipeline and recursive directory
        signing pipeline.

        This is where code signer is to check whether file is to be signed or
        not. This check might be based on a simple extension test or on actual
        test whether file have a digital signature already or not.
        """

    def collect_files_to_sign(self, path: Path) \
            -> List[AbsoluteAndRelativeFileName]:
        """
        Get all files which need to be signed from the given path.

        NOTE: The path might either be a file or directory.

        This function is run from the buildbot worker side.
        """

        # If there is a single file provided trust the buildbot worker that it
        # is eligible for signing.
        if path.is_file():
            file = AbsoluteAndRelativeFileName.from_path(path)
            if not self.check_file_is_to_be_signed(file):
                return []
            return [file]

        all_files = AbsoluteAndRelativeFileName.recursively_from_directory(
            path)
        files_to_be_signed = [file for file in all_files
                              if self.check_file_is_to_be_signed(file)]
        return files_to_be_signed

    def wait_for_signed_archive_or_die(self, request_id) -> None:
        """
        Wait until archive with signed files is available.

        Will only return if the archive with signed files is available. If there
        was an error during code sign procedure the SystemExit exception is
        raised, with the message set to the error reported by the codesign
        server.

        Will only wait for the configured time. If that time exceeds and there
        is still no responce from the signing server the application will exit
        with a non-zero exit code.

        """

        signed_archive_info = self.signed_archive_info_for_request_id(
            request_id)
        unsigned_archive_info = self.unsigned_archive_info_for_request_id(
            request_id)

        timeout_in_seconds = self.config.TIMEOUT_IN_SECONDS
        time_start = time.monotonic()
        while not signed_archive_info.is_ready():
            time.sleep(1)
            time_slept_in_seconds = time.monotonic() - time_start
            if time_slept_in_seconds > timeout_in_seconds:
                signed_archive_info.clean()
                unsigned_archive_info.clean()
                raise SystemExit("Signing server didn't finish signing in "
                                 f'{timeout_in_seconds} seconds, dying :(')

        archive_state = signed_archive_info.get_state()
        if archive_state.has_error():
            signed_archive_info.clean()
            unsigned_archive_info.clean()
            raise SystemExit(
                f'Error happenned during codesign procedure: {archive_state.error_message}')

    def copy_signed_files_to_directory(
            self, signed_dir: Path, destination_dir: Path) -> None:
        """
        Copy all files from signed_dir to destination_dir.

        This function will overwrite any existing file. Permissions are copied
        from the source files, but other metadata, such as timestamps, are not.
        """
        for signed_filepath in signed_dir.glob('**/*'):
            if not signed_filepath.is_file():
                continue

            relative_filepath = signed_filepath.relative_to(signed_dir)
            destination_filepath = destination_dir / relative_filepath
            destination_filepath.parent.mkdir(parents=True, exist_ok=True)

            shutil.copy(signed_filepath, destination_filepath)

    def run_buildbot_path_sign_pipeline(self, path: Path) -> None:
        """
        Run all steps needed to make given path signed.

        Path points to an unsigned file or a directory which contains unsigned
        files.

        If the path points to a single file then this file will be signed.
        This is used to sign a final bundle such as .msi on Windows or .dmg on
        macOS.

        NOTE: The code signed implementation might actually reject signing the
        file, in which case the file will be left unsigned. This isn't anything
        to be considered a failure situation, just might happen when buildbot
        worker can not detect whether signing is really required in a specific
        case or not.

        If the path points to a directory then code signer will sign all
        signable files from it (finding them recursively).
        """

        self.cleanup_environment_for_builder()

        # Make sure storage directory exists.
        self.unsigned_storage_dir.mkdir(parents=True, exist_ok=True)

        # Collect all files which needs to be signed and pack them into a single
        # archive which will be sent to the signing server.
        logger_builder.info('Collecting files which are to be signed...')
        files = self.collect_files_to_sign(path)
        if not files:
            logger_builder.info('No files to be signed, ignoring.')
            return
        logger_builder.info('Found %d files to sign.', len(files))

        request_id = self.generate_request_id()
        signed_archive_info = self.signed_archive_info_for_request_id(
            request_id)
        unsigned_archive_info = self.unsigned_archive_info_for_request_id(
            request_id)

        pack_files(files=files,
                   archive_filepath=unsigned_archive_info.archive_filepath)
        unsigned_archive_info.tag_ready()

        # Wait for the signing server to finish signing.
        logger_builder.info('Waiting signing server to sign the files...')
        self.wait_for_signed_archive_or_die(request_id)

        # Extract signed files from archive and move files to final location.
        with TemporaryDirectory(prefix='blender-buildbot-') as temp_dir_str:
            unpacked_signed_files_dir = Path(temp_dir_str)

            logger_builder.info('Extracting signed files from archive...')
            extract_files(
                archive_filepath=signed_archive_info.archive_filepath,
                extraction_dir=unpacked_signed_files_dir)

            destination_dir = path
            if destination_dir.is_file():
                destination_dir = destination_dir.parent
            self.copy_signed_files_to_directory(
                unpacked_signed_files_dir, destination_dir)

        logger_builder.info('Removing archive with signed files...')
        signed_archive_info.clean()

    ############################################################################
    # Signing server side helpers.

    def wait_for_sign_request(self) -> str:
        """
        Wait for the buildbot to request signing of an archive.

        Returns an identifier of signing request.
        """

        # TOOD(sergey): Support graceful shutdown on Ctrl-C.

        logger_server.info(
            f'Waiting for a request directory {self.unsigned_storage_dir} to appear.')
        while not self.unsigned_storage_dir.exists():
            time.sleep(1)

        logger_server.info(
            'Waiting for a READY indicator of any signing request.')
        request_id = None
        while request_id is None:
            for file in self.unsigned_storage_dir.iterdir():
                if file.suffix != '.ready':
                    continue
                request_id = file.stem
                logger_server.info(f'Found READY for request ID {request_id}.')
            if request_id is None:
                time.sleep(1)

        unsigned_archive_info = self.unsigned_archive_info_for_request_id(
            request_id)
        while not unsigned_archive_info.is_ready():
            time.sleep(1)

        return request_id

    @abc.abstractmethod
    def sign_all_files(self, files: List[AbsoluteAndRelativeFileName]) -> None:
        """
        Sign all files in the given directory.

        NOTE: Signing should happen in-place.
        """

    def run_signing_pipeline(self, request_id: str):
        """
        Run the full signing pipeline starting from the point when buildbot
        worker have requested signing.
        """

        # Make sure storage directory exists.
        self.signed_storage_dir.mkdir(parents=True, exist_ok=True)

        with TemporaryDirectory(prefix='blender-codesign-') as temp_dir_str:
            temp_dir = Path(temp_dir_str)

            signed_archive_info = self.signed_archive_info_for_request_id(
                request_id)
            unsigned_archive_info = self.unsigned_archive_info_for_request_id(
                request_id)

            logger_server.info('Extracting unsigned files from archive...')
            extract_files(
                archive_filepath=unsigned_archive_info.archive_filepath,
                extraction_dir=temp_dir)

            logger_server.info('Collecting all files which needs signing...')
            files = AbsoluteAndRelativeFileName.recursively_from_directory(
                temp_dir)

            logger_server.info('Signing all requested files...')
            try:
                self.sign_all_files(files)
            except CodeSignException as error:
                signed_archive_info.tag_ready(error_message=error.message)
                unsigned_archive_info.clean()
                logger_server.info('Signing is complete with errors.')
                return

            logger_server.info('Packing signed files...')
            pack_files(files=files,
                       archive_filepath=signed_archive_info.archive_filepath)
            signed_archive_info.tag_ready()

            logger_server.info('Removing signing request...')
            unsigned_archive_info.clean()

            logger_server.info('Signing is complete.')

    def run_signing_server(self):
        logger_server.info('Starting new code signing server...')
        self.cleanup_environment_for_signing_server()
        logger_server.info('Code signing server is ready')
        while True:
            logger_server.info('Waiting for the signing request in %s...',
                               self.unsigned_storage_dir)
            request_id = self.wait_for_sign_request()

            logger_server.info(
                f'Beging signign procedure for request ID {request_id}.')
            self.run_signing_pipeline(request_id)

    ############################################################################
    # Command executing.
    #
    # Abstracted to a degree that allows to run commands from a foreign
    # platform.
    # The goal with this is to allow performing dry-run tests of code signer
    # server from other platforms (for example, to test that macOS code signer
    # does what it is supposed to after doing a refactor on Linux).

    # TODO(sergey): What is the type annotation for the command?
    def run_command_or_mock(self, command, platform: util.Platform) -> None:
        """
        Run given command if current platform matches given one

        If the platform is different then it will only be printed allowing
        to verify logic of the code signing process.
        """

        if platform != self.platform:
            logger_server.info(
                f'Will run command for {platform}: {command}')
            return

        logger_server.info(f'Running command: {command}')
        subprocess.run(command)

    # TODO(sergey): What is the type annotation for the command?
    def check_output_or_mock(self, command,
                             platform: util.Platform,
                             allow_nonzero_exit_code=False) -> str:
        """
        Run given command if current platform matches given one

        If the platform is different then it will only be printed allowing
        to verify logic of the code signing process.

        If allow_nonzero_exit_code is truth then the output will be returned
        even if application quit with non-zero exit code.
        Otherwise an subprocess.CalledProcessError exception will be raised
        in such case.
        """

        if platform != self.platform:
            logger_server.info(
                f'Will run command for {platform}: {command}')
            return

        if allow_nonzero_exit_code:
            process = subprocess.Popen(command,
                                       stdout=subprocess.PIPE,
                                       stderr=subprocess.STDOUT)
            output = process.communicate()[0]
            return output.decode()

        logger_server.info(f'Running command: {command}')
        return subprocess.check_output(
            command, stderr=subprocess.STDOUT).decode()