Passed
Push — master ( cc6660...f0f570 )
by Sergei
02:13
created

FileRotator::checkCannotBeLowerThanOne()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 2
dl 0
loc 6
ccs 5
cts 5
cp 1
crap 2
rs 10
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 extension_loaded;
13
use function fclose;
14
use function feof;
15
use function file_exists;
16
use function filesize;
17
use function flock;
18
use function fread;
19
use function ftruncate;
20
use function is_file;
21
use function rename;
22
use function sprintf;
23
use function substr;
24
use function unlink;
25
26
use const LOCK_EX;
27
use const LOCK_SH;
28
use const LOCK_UN;
29
30
/**
31
 * FileRotator takes care of rotating files.
32
 *
33
 * If the size of the file exceeds {@see FileRotator::$maxFileSize} (in kilo-bytes),
34
 * a rotation will be performed, which renames the current file by suffixing the file name with '.1'.
35
 *
36
 * All existing files are moved backwards by one place, i.e., '.2' to '.3', '.1' to '.2', and so on. If compression
37
 * is enabled {@see FileRotator::$compressRotatedFiles}, the rotated files will be compressed into the '.gz' format.
38
 * The property {@see FileRotator::$maxFiles} specifies how many history files to keep.
39
 */
40
final class FileRotator implements FileRotatorInterface
41
{
42
    /**
43
     * The extension of the compressed rotated files.
44
     */
45
    private const COMPRESS_EXTENSION = '.gz';
46
47
    /**
48
     * @var int The maximum file size, in kilo-bytes. Defaults to 10240, meaning 10MB.
49
     */
50
    private int $maxFileSize;
51
52
    /**
53
     * @var int The number of files used for rotation. Defaults to 5.
54
     */
55
    private int $maxFiles;
56
57
    /**
58
     * @var int|null The permission to be set for newly created files.
59
     * This value will be used by PHP chmod() function. No umask will be applied.
60
     * If not set, the permission will be determined by the current environment.
61
     */
62
    private ?int $fileMode;
63
64
    /**
65
     * @var bool Whether to compress rotated files with gzip. Defaults to `false`.
66
     *
67
     * If compression is enabled, the rotated files will be compressed into the '.gz' format.
68
     */
69
    private bool $compressRotatedFiles;
70
71
    /**
72
     * @param int $maxFileSize The maximum file size, in kilo-bytes. Defaults to 10240, meaning 10MB.
73
     * @param int $maxFiles The number of files used for rotation. Defaults to 5.
74
     * @param int|null $fileMode The permission to be set for newly created files.
75
     * @param bool $compressRotatedFiles Whether to compress rotated files with gzip.
76
     */
77 10
    public function __construct(
78
        int $maxFileSize = 10240,
79
        int $maxFiles = 5,
80
        int $fileMode = null,
81
        bool $compressRotatedFiles = false
82
    ) {
83 10
        $this->checkCannotBeLowerThanOne($maxFileSize, '$maxFileSize');
84 9
        $this->checkCannotBeLowerThanOne($maxFiles, '$maxFiles');
85
86 8
        $this->maxFileSize = $maxFileSize;
87 8
        $this->maxFiles = $maxFiles;
88 8
        $this->fileMode = $fileMode;
89
90 8
        if ($compressRotatedFiles && !extension_loaded('zlib')) {
91
            throw new RuntimeException(sprintf(
92
                'The %s requires the PHP extension "ext-zlib" to compress rotated files.',
93
                self::class,
94
            ));
95
        }
96
97 8
        $this->compressRotatedFiles = $compressRotatedFiles;
98
    }
99
100 4
    public function rotateFile(string $file): void
101
    {
102 4
        for ($i = $this->maxFiles; $i >= 0; --$i) {
103
            // `$i === 0` is the original file
104 4
            $rotateFile = $file . ($i === 0 ? '' : '.' . $i);
105 4
            $newFile = $file . '.' . ($i + 1);
106
107 4
            if ($i === $this->maxFiles) {
108 4
                $this->safeRemove($this->compressRotatedFiles ? $rotateFile . self::COMPRESS_EXTENSION : $rotateFile);
109 4
                continue;
110
            }
111
112 4
            if ($this->compressRotatedFiles && is_file($rotateFile . self::COMPRESS_EXTENSION)) {
113 1
                $this->rotate($rotateFile . self::COMPRESS_EXTENSION, $newFile . self::COMPRESS_EXTENSION);
114 1
                continue;
115
            }
116
117 4
            if (!is_file($rotateFile)) {
118 4
                continue;
119
            }
120
121 4
            $this->rotate($rotateFile, $newFile);
122
123 4
            if ($i === 0) {
124 4
                $this->clear($rotateFile);
125
            }
126
        }
127
    }
128
129 7
    public function shouldRotateFile(string $file): bool
130
    {
131 7
        return file_exists($file) && @filesize($file) > ($this->maxFileSize * 1024);
132
    }
133
134
    /***
135
     * Renames rotated file into new file.
136
     *
137
     * @param string $rotateFile
138
     * @param string $newFile
139
     */
140 4
    private function rotate(string $rotateFile, string $newFile): void
141
    {
142 4
        $this->safeRemove($newFile);
143 4
        rename($rotateFile, $newFile);
144
145 4
        if ($this->compressRotatedFiles && !$this->isCompressed($newFile)) {
146 2
            $this->compress($newFile);
147 2
            $newFile .= self::COMPRESS_EXTENSION;
148
        }
149
150 4
        if ($this->fileMode !== null) {
151 2
            chmod($newFile, $this->fileMode);
152
        }
153
    }
154
155
    /**
156
     * Compresses a file with gzip and renames it by appending `.gz` to the file.
157
     *
158
     * @param string $file
159
     */
160 2
    private function compress(string $file): void
161
    {
162 2
        $filePointer = FileHelper::openFile($file, 'rb');
163 2
        flock($filePointer, LOCK_SH);
164 2
        $gzFile = $file . self::COMPRESS_EXTENSION;
165 2
        $gzFilePointer = gzopen($gzFile, 'wb9');
166
167 2
        while (!feof($filePointer)) {
168 2
            gzwrite($gzFilePointer, fread($filePointer, 8192));
169
        }
170
171 2
        flock($filePointer, LOCK_UN);
172 2
        fclose($filePointer);
173 2
        gzclose($gzFilePointer);
174 2
        @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

174
        /** @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...
175
    }
176
177
    /***
178
     * Clears the file without closing any other process open handles.
179
     *
180
     * @param string $file
181
     */
182 4
    private function clear(string $file): void
183
    {
184 4
        $filePointer = FileHelper::openFile($file, 'ab');
185
186 4
        flock($filePointer, LOCK_EX);
187 4
        ftruncate($filePointer, 0);
188 4
        flock($filePointer, LOCK_UN);
189 4
        fclose($filePointer);
190
    }
191
192
    /**
193
     * Checks the existence of file and removes it.
194
     *
195
     * @param string $file
196
     */
197 4
    private function safeRemove(string $file): void
198
    {
199 4
        if (is_file($file)) {
200 2
            @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

200
            /** @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...
201
        }
202
    }
203
204
    /**
205
     * Whether the file is compressed.
206
     *
207
     * @param string $file
208
     *
209
     * @return bool
210
     */
211 2
    private function isCompressed(string $file): bool
212
    {
213 2
        return substr($file, -3, 3) === self::COMPRESS_EXTENSION;
214
    }
215
216
    /**
217
     * Checks that the value cannot be lower than one.
218
     *
219
     * @param int $value The value to be checked.
220
     * @param string $argumentName The name of the argument to check.
221
     */
222 10
    private function checkCannotBeLowerThanOne(int $value, string $argumentName): void
223
    {
224 10
        if ($value < 1) {
225 2
            throw new InvalidArgumentException(sprintf(
226 2
                'The argument "%s" cannot be lower than 1.',
227 2
                $argumentName,
228 2
            ));
229
        }
230
    }
231
}
232