Passed
Push — master ( b0c53b...bb5d18 )
by Alexander
02:03
created

FileRotator::compress()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 14
nc 5
nop 1
dl 0
loc 22
ccs 15
cts 15
cp 1
crap 5
rs 9.4888
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Log\Target\File;
6
7
use InvalidArgumentException;
8
use RuntimeException;
9
use Yiisoft\Files\FileHelper;
10
11
use function chmod;
12
use function copy;
13
use function extension_loaded;
14
use function fclose;
15
use function feof;
16
use function file_exists;
17
use function filesize;
18
use function flock;
19
use function fread;
20
use function ftruncate;
21
use function is_file;
22
use function rename;
23
use function sprintf;
24
use function substr;
25
use function unlink;
26
27
use const DIRECTORY_SEPARATOR;
28
use const LOCK_EX;
29
use const LOCK_SH;
30
use const LOCK_UN;
31
32
/**
33
 * FileRotator takes care of rotating files.
34
 *
35
 * If the size of the file exceeds {@see FileRotator::$maxFileSize} (in kilo-bytes),
36
 * a rotation will be performed, which renames the current file by suffixing the file name with '.1'.
37
 *
38
 * All existing files are moved backwards by one place, i.e., '.2' to '.3', '.1' to '.2', and so on. If compression
39
 * is enabled {@see FileRotator::$compressRotatedFiles}, the rotated files will be compressed into the '.gz' format.
40
 * The property {@see FileRotator::$maxFiles} specifies how many history files to keep.
41
 */
42
final class FileRotator implements FileRotatorInterface
43
{
44
    /**
45
     * The extension of the compressed rotated files.
46
     */
47
    private const COMPRESS_EXTENSION = '.gz';
48
49
    /**
50
     * @var int The maximum file size, in kilo-bytes. Defaults to 10240, meaning 10MB.
51
     */
52
    private int $maxFileSize;
53
54
    /**
55
     * @var int The number of files used for rotation. Defaults to 5.
56
     */
57
    private int $maxFiles;
58
59
    /**
60
     * @var int|null The permission to be set for newly created files.
61
     * This value will be used by PHP chmod() function. No umask will be applied.
62
     * If not set, the permission will be determined by the current environment.
63
     */
64
    private ?int $fileMode;
65
66
    /**
67
     * @var bool|null Whether to rotate files by copy and truncate in contrast to rotation by
68
     * renaming files. Defaults to `true` to be more compatible with log tailers and is windows
69
     * systems which do not play well with rename on open files. Rotation by renaming however is
70
     * a bit faster.
71
     *
72
     * The problem with windows systems where the [rename()](http://www.php.net/manual/en/function.rename.php)
73
     * function does not work with files that are opened by some process is described in a
74
     * [comment by Martin Pelletier](http://www.php.net/manual/en/function.rename.php#102274) in
75
     * the PHP documentation. By setting rotateByCopy to `true` you can work
76
     * around this problem.
77
     */
78
    private ?bool $rotateByCopy;
79
80
    /**
81
     * @var bool Whether or not to compress rotated files with gzip. Defaults to `false`.
82
     *
83
     * If compression is enabled, the rotated files will be compressed into the '.gz' format.
84
     */
85
    private bool $compressRotatedFiles;
86
87
    /**
88
     * @param int $maxFileSize The maximum file size, in kilo-bytes. Defaults to 10240, meaning 10MB.
89
     * @param int $maxFiles The number of files used for rotation. Defaults to 5.
90
     * @param int|null $fileMode The permission to be set for newly created files.
91
     * @param bool|null $rotateByCopy Whether to rotate files by copying and truncating or renaming them.
92
     * @param bool $compressRotatedFiles Whether or not to compress rotated files with gzip.
93
     */
94 15
    public function __construct(
95
        int $maxFileSize = 10240,
96
        int $maxFiles = 5,
97
        int $fileMode = null,
98
        bool $rotateByCopy = null,
99
        bool $compressRotatedFiles = false
100
    ) {
101 15
        $this->checkCannotBeLowerThanOne($maxFileSize, '$maxFileSize');
102 14
        $this->checkCannotBeLowerThanOne($maxFiles, '$maxFiles');
103
104 13
        $this->maxFileSize = $maxFileSize;
105 13
        $this->maxFiles = $maxFiles;
106 13
        $this->fileMode = $fileMode;
107 13
        $this->rotateByCopy = $rotateByCopy ?? $this->isRunningOnWindows();
108
109 13
        if ($compressRotatedFiles && !extension_loaded('zlib')) {
110
            throw new RuntimeException(sprintf(
111
                'The %s requires the PHP extension "ext-zlib" to compress rotated files.',
112
                self::class,
113
            ));
114
        }
115
116 13
        $this->compressRotatedFiles = $compressRotatedFiles;
117 13
    }
118
119 8
    public function rotateFile(string $file): void
120
    {
121 8
        for ($i = $this->maxFiles; $i >= 0; --$i) {
122
            // `$i === 0` is the original file
123 8
            $rotateFile = $file . ($i === 0 ? '' : '.' . $i);
124 8
            $newFile = $file . '.' . ($i + 1);
125
126 8
            if ($i === $this->maxFiles) {
127 8
                $this->safeRemove($this->compressRotatedFiles ? $rotateFile . self::COMPRESS_EXTENSION : $rotateFile);
128 8
                continue;
129
            }
130
131 8
            if ($this->compressRotatedFiles && is_file($rotateFile . self::COMPRESS_EXTENSION)) {
132 2
                $this->rotate($rotateFile . self::COMPRESS_EXTENSION, $newFile . self::COMPRESS_EXTENSION);
133 2
                continue;
134
            }
135
136 8
            if (!is_file($rotateFile)) {
137 8
                continue;
138
            }
139
140 8
            $this->rotate($rotateFile, $newFile);
141 8
            $this->compress($newFile);
142
143 8
            if ($i === 0) {
144 8
                $this->clear($rotateFile);
145
            }
146
        }
147 8
    }
148
149 13
    public function shouldRotateFile(string $file): bool
150
    {
151 13
        return file_exists($file) && @filesize($file) > ($this->maxFileSize * 1024);
152
    }
153
154
    /***
155
     * Copies or renames rotated file into new file.
156
     *
157
     * @param string $rotateFile
158
     * @param string $newFile
159
     */
160 8
    private function rotate(string $rotateFile, string $newFile): void
161
    {
162 8
        if ($this->rotateByCopy !== true) {
163 4
            $this->safeRemove($newFile);
164 4
            rename($rotateFile, $newFile);
165 4
            return;
166
        }
167
168 4
        copy($rotateFile, $newFile);
169
170 4
        if ($this->fileMode !== null && (!$this->compressRotatedFiles || $this->isCompressed($newFile))) {
171 1
            chmod($newFile, $this->fileMode);
172
        }
173 4
    }
174
175
    /**
176
     * Compresses a file with gzip and renames it by appending `.gz` to the file.
177
     *
178
     * @param string $file
179
     */
180 8
    private function compress(string $file): void
181
    {
182 8
        if (!$this->compressRotatedFiles || $this->isCompressed($file)) {
183 4
            return;
184
        }
185
186 4
        $filePointer = FileHelper::openFile($file, 'rb');
187 4
        flock($filePointer, LOCK_SH);
188 4
        $gzFile = $file . self::COMPRESS_EXTENSION;
189 4
        $gzFilePointer = gzopen($gzFile, 'wb9');
190
191 4
        while (!feof($filePointer)) {
192 4
            gzwrite($gzFilePointer, fread($filePointer, 8192));
193
        }
194
195 4
        flock($filePointer, LOCK_UN);
196 4
        fclose($filePointer);
197 4
        gzclose($gzFilePointer);
198 4
        @unlink($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

198
        /** @scrutinizer ignore-unhandled */ @unlink($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
199
200 4
        if ($this->fileMode !== null) {
201 2
            chmod($gzFile, $this->fileMode);
202
        }
203 4
    }
204
205
    /***
206
     * Clears the file without closing any other process open handles.
207
     *
208
     * @param string $file
209
     */
210 8
    private function clear(string $file): void
211
    {
212 8
        $filePointer = FileHelper::openFile($file, 'ab');
213
214 8
        flock($filePointer, LOCK_EX);
215 8
        ftruncate($filePointer, 0);
216 8
        flock($filePointer, LOCK_UN);
217 8
        fclose($filePointer);
218 8
    }
219
220
    /**
221
     * Checks the existence of file and removes it.
222
     *
223
     * @param string $file
224
     */
225 8
    private function safeRemove(string $file): void
226
    {
227 8
        if (is_file($file)) {
228 4
            @unlink($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

228
            /** @scrutinizer ignore-unhandled */ @unlink($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
229
        }
230 8
    }
231
232
    /**
233
     * Whether the file is compressed.
234
     *
235
     * @param string $file
236
     *
237
     * @return bool
238
     */
239 4
    private function isCompressed(string $file): bool
240
    {
241 4
        return substr($file, -3, 3) === self::COMPRESS_EXTENSION;
242
    }
243
244
    /**
245
     * Whether it works on Windows OS.
246
     *
247
     * @return bool
248
     */
249 1
    private function isRunningOnWindows(): bool
250
    {
251 1
        return DIRECTORY_SEPARATOR === '\\';
252
    }
253
254
    /**
255
     * Checks that the value cannot be lower than one.
256
     *
257
     * @param int $value The value to be checked.
258
     * @param string $argumentName The name of the argument to check.
259
     */
260 15
    private function checkCannotBeLowerThanOne(int $value, string $argumentName): void
261
    {
262 15
        if ($value < 1) {
263 2
            throw new InvalidArgumentException(sprintf(
264 2
                'The argument "%s" cannot be lower than 1.',
265 2
                $argumentName,
266
            ));
267
        }
268 14
    }
269
}
270