Test Failed
Push — test ( a53f3f...e32fe9 )
by Tom
02:31
created

BinaryUnPackager::verifyFileHash()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 4
eloc 10
c 1
b 0
f 1
nc 4
nop 3
dl 0
loc 16
rs 9.9332
1
<?php
2
3
/* this file is part of pipelines */
4
5
namespace Ktomk\Pipelines\Runner\Docker;
6
7
use Ktomk\Pipelines\Cli\Exec;
8
use Ktomk\Pipelines\Lib;
9
use Ktomk\Pipelines\LibFs;
10
use Ktomk\Pipelines\Runner\Directories;
11
12
/**
13
 * Class DockerBinaryUnpackager
14
 *
15
 * Knows how to handle a docker client binary package structure to provide
16
 * the docker client binary as pathname.
17
 *
18
 *   Binaries are packaged in the docker project below https://download.docker.com/linux/static/stable/...
19
 * in versioned .tgz files (e.g. x86_64/docker-19.03.1.tgz).
20
 *
21
 *   The docker binary is a single file inside such a .tgz file, which es getting extracted and then
22
 * is called the binary in the local store.
23
 *
24
 *   Both the download of the .tgz file as well as the extraction of the docker binary from it is
25
 * verified against a checksum each so that downloading and extracting can be verified as successful.
26
 *
27
 *  Binaries are stored in pipelines XDG_DATA_HOME in the static-docker folder, e.g.
28
 *
29
 *  'XDG_DATA_HOME', 'static-docker'
30
 *   FIXME needs an integration test
31
 *
32
 *   Before downloading, it is checked if the .tgz package is already cached.
33
 *
34
 *
35
 *
36
 * @package Ktomk\Pipelines\Runner\Docker
37
 */
38
class BinaryUnPackager
39
{
40
    const BYTES_80MB = 83886080;
41
42
    /**
43
     * @var Exec
44
     */
45
    private $exec;
46
47
    /**
48
     * @var string
49
     */
50
    private $packageDirectory;
51
52
    /**
53
     * @var string
54
     */
55
    private $binariesDirectory;
56
57
    /**
58
     * Create binary unpackager based on default directories.
59
     *
60
     * @param Exec $exec
61
     * @param Directories $directories
62
     * @return BinaryUnPackager
63
     */
64
    public static function fromDirectories(Exec $exec, Directories $directories)
65
    {
66
        $packageDirectory = $directories->getBaseDirectory('XDG_CACHE_HOME', 'package-docker');
67
        $binariesDirectory = $directories->getBaseDirectory('XDG_DATA_HOME', 'static-docker');
68
69
        return new self($exec, $packageDirectory, $binariesDirectory);
70
    }
71
72
    /**
73
     * BinaryUnPackager constructor.
74
     *
75
     * @param Exec $exec unpackager is using shell commands w/ tar for unpacking
76
     * @param string $packageDirectory where to store package downloads to (e.g. HTTP cache)
77
     * @param string $binariesDirectory where to store binaries to (can be kept ongoing to run pipelines)
78
     */
79
    public function __construct(Exec $exec, $packageDirectory, $binariesDirectory)
80
    {
81
        $this->exec = $exec;
82
        $this->packageDirectory = $packageDirectory;
83
        $this->binariesDirectory = $binariesDirectory;
84
    }
85
86
    /**
87
     * Get binary path from local store.
88
     *
89
     * @param array $package
90
     * @return string
91
     */
92
    public function getLocalBinary(array $package)
93
    {
94
        $package = $this->preparePackage($package);
95
96
        $binLocal = $package['prep']['bin_local'];
97
        $pkgLocal = $package['prep']['pkg_local'];
98
99
        if (!LibFs::isReadableFile($binLocal) && !LibFs::isReadableFile($pkgLocal)) {
100
            $this->download($package);
101
        }
102
103
        $this->extract($package);
104
105
        $message = sprintf('Verify and rename or remove the file to get it downloaded again from %s', $package['uri']);
106
        $this->verifyFileHash($binLocal, $package['binary_sha256'], $message);
107
108
        return $binLocal;
109
    }
110
111
    /**
112
     * @param array $package
113
     * @return array
114
     */
115
    public function preparePackage(array $package)
116
    {
117
        $cache = libFs::mkDir($this->packageDirectory);
118
        $share = libFs::mkDir($this->binariesDirectory);
119
120
        $pkgBase = sprintf('%s/%s', $cache, basename($package['uri']));
121
        $binBase = sprintf('%s/%s', $share, $package['name']);
122
123
        $package['prep'] = array(
124
            'cache' => $cache,
125
            'pkg_base' => $pkgBase,
126
            'pkg_local' => sprintf('%s.%s', $pkgBase, $package['sha256']),
127
            'share' => $share,
128
            'bin_base' => $binBase,
129
            'bin_local' => sprintf('%s.%s', $binBase, $package['binary_sha256']),
130
        );
131
132
        return $package;
133
    }
134
135
    /**
136
     * @param string $tgz path to tar-gz (.tgz) file
137
     * @param string $path path in package to extract from
138
     * @param string $dest path to extract to
139
     */
140
    public function extractFromTgzFile($tgz, $path, $dest)
141
    {
142
        if (!LibFs::isReadableFile($tgz)) {
143
            throw new \UnexpectedValueException(sprintf('Not a readable file: %s', $tgz));
144
        }
145
146
        LibFs::rm($dest);
147
        $status = $this->exec->pass(
148
            sprintf('> %s tar', Lib::quoteArg($dest)),
149
            array('-xOzf', $tgz, $path)
150
        );
151
152
        if (0 !== $status) {
153
            LibFs::rm($dest);
154
155
            throw new \UnexpectedValueException(sprintf('Nonzero tar exit status: %d', $status));
156
        }
157
    }
158
159
    /**
160
     * verify sha256 has of a file
161
     *
162
     * @param string $file
163
     * @param string $hash sha256 hash of the file
164
     * @param string $message [optional] additional error message information
165
     */
166
    public function verifyFileHash($file, $hash, $message = '')
167
    {
168
        if (!LibFs::isReadableFile($file)) {
169
            throw new \UnexpectedValueException(sprintf('not a readable file: "%s"', $file));
170
        }
171
172
        $actual = hash_file('sha256', $file);
173
        if ($actual !== $hash) {
174
            $buffer = sprintf(
175
                'sha256 checksum mismatch: "%s" for file "%s".',
176
                $hash,
177
                $file
178
            );
179
            strlen($message) && $buffer .= ' ' . $message;
180
181
            throw new \UnexpectedValueException($buffer);
182
        }
183
    }
184
185
    /**
186
     * @param array $package
187
     */
188
    private function download(array $package)
189
    {
190
        $base = $package['prep']['pkg_base'];
191
192
        // Download the package if not in the http-cache
193
        stream_copy_to_stream(
194
            $src = fopen($package['uri'], 'rb'),
0 ignored issues
show
Bug introduced by
It seems like $src = fopen($package['uri'], 'rb') can also be of type false; however, parameter $source of stream_copy_to_stream() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

194
            /** @scrutinizer ignore-type */ $src = fopen($package['uri'], 'rb'),
Loading history...
195
            $dest = fopen(libFs::rm($base), 'wb'),
0 ignored issues
show
Bug introduced by
It seems like $dest = fopen(Ktomk\Pipe...LibFs::rm($base), 'wb') can also be of type false; however, parameter $dest of stream_copy_to_stream() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

195
            /** @scrutinizer ignore-type */ $dest = fopen(libFs::rm($base), 'wb'),
Loading history...
196
            self::BYTES_80MB
197
        );
198
        fclose($src);
0 ignored issues
show
Bug introduced by
It seems like $src can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

198
        fclose(/** @scrutinizer ignore-type */ $src);
Loading history...
199
        fclose($dest);
200
        $hash = hash_file('sha256', $base);
201
        $pkgLocalIn = sprintf('%s.%s', $base, $hash);
202
        if (LibFs::IsReadableFile($pkgLocalIn)) {
203
            throw new \UnexpectedValueException(sprintf('Download collision: %s', $pkgLocalIn));
204
        }
205
        LibFs::Rename($base, $pkgLocalIn);
206
    }
207
208
    private function extract(array $package)
209
    {
210
        $binLocal = $package['prep']['bin_local'];
211
212
        if (LibFs::isReadableFile($binLocal)) {
213
            $this->verifyFileHash($binLocal, $package['binary_sha256']);
214
215
            return;
216
        }
217
218
        $pkgLocal = $package['prep']['pkg_local'];
219
        $base = $package['prep']['bin_base'];
220
221
        $this->extractFromTgzFile($pkgLocal, $package['binary'], $base);
222
223
        $hash = hash_file('sha256', $base);
224
        $binLocal = sprintf('%s.%s', $base, $hash);
225
        if (LibFs::isReadableFile($binLocal)) {
226
            LibFs::rm($base);
227
228
            throw new \UnexpectedValueException(sprintf('Extraction collision: "%s"', $binLocal));
229
        }
230
231
        LibFs::Rename($base, $binLocal);
232
    }
233
}
234