Filesystem::copy()   C
last analyzed

Complexity

Conditions 13
Paths 30

Size

Total Lines 40
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 22
c 1
b 0
f 0
dl 0
loc 40
rs 6.6166
cc 13
nc 30
nop 3

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/*
4
 * This file is part of the Symfony package.
5
 *
6
 * (c) Fabien Potencier <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Symfony\Component\Filesystem;
13
14
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
15
use Symfony\Component\Filesystem\Exception\InvalidArgumentException;
16
use Symfony\Component\Filesystem\Exception\IOException;
17
18
/**
19
 * Provides basic utility to manipulate the file system.
20
 *
21
 * @author Fabien Potencier <[email protected]>
22
 */
23
class Filesystem
24
{
25
    private static $lastError;
26
27
    /**
28
     * Copies a file.
29
     *
30
     * If the target file is older than the origin file, it's always overwritten.
31
     * If the target file is newer, it is overwritten only when the
32
     * $overwriteNewerFiles option is set to true.
33
     *
34
     * @throws FileNotFoundException When originFile doesn't exist
35
     * @throws IOException           When copy fails
36
     */
37
    public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false)
38
    {
39
        $originIsLocal = stream_is_local($originFile) || 0 === stripos($originFile, 'file://');
40
        if ($originIsLocal && !is_file($originFile)) {
41
            throw new FileNotFoundException(sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile);
42
        }
43
44
        $this->mkdir(\dirname($targetFile));
45
46
        $doCopy = true;
47
        if (!$overwriteNewerFiles && null === parse_url($originFile, \PHP_URL_HOST) && is_file($targetFile)) {
48
            $doCopy = filemtime($originFile) > filemtime($targetFile);
49
        }
50
51
        if ($doCopy) {
52
            // https://bugs.php.net/64634
53
            if (!$source = self::box('fopen', $originFile, 'r')) {
54
                throw new IOException(sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile);
55
            }
56
57
            // Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default
58
            if (!$target = self::box('fopen', $targetFile, 'w', false, stream_context_create(['ftp' => ['overwrite' => true]]))) {
59
                throw new IOException(sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile);
60
            }
61
62
            $bytesCopied = stream_copy_to_stream($source, $target);
63
            fclose($source);
64
            fclose($target);
65
            unset($source, $target);
66
67
            if (!is_file($targetFile)) {
68
                throw new IOException(sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile);
69
            }
70
71
            if ($originIsLocal) {
72
                // Like `cp`, preserve executable permission bits
73
                self::box('chmod', $targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111));
74
75
                if ($bytesCopied !== $bytesOrigin = filesize($originFile)) {
76
                    throw new IOException(sprintf('Failed to copy the whole content of "%s" to "%s" (%g of %g bytes copied).', $originFile, $targetFile, $bytesCopied, $bytesOrigin), 0, null, $originFile);
77
                }
78
            }
79
        }
80
    }
81
82
    /**
83
     * Creates a directory recursively.
84
     *
85
     * @param string|iterable $dirs The directory path
86
     *
87
     * @throws IOException On any directory creation failure
88
     */
89
    public function mkdir($dirs, int $mode = 0777)
90
    {
91
        foreach ($this->toIterable($dirs) as $dir) {
92
            if (is_dir($dir)) {
93
                continue;
94
            }
95
96
            if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) {
97
                throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir);
98
            }
99
        }
100
    }
101
102
    /**
103
     * Checks the existence of files or directories.
104
     *
105
     * @param string|iterable $files A filename, an array of files, or a \Traversable instance to check
106
     *
107
     * @return bool true if the file exists, false otherwise
108
     */
109
    public function exists($files)
110
    {
111
        $maxPathLength = \PHP_MAXPATHLEN - 2;
112
113
        foreach ($this->toIterable($files) as $file) {
114
            if (\strlen($file) > $maxPathLength) {
115
                throw new IOException(sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file);
116
            }
117
118
            if (!file_exists($file)) {
119
                return false;
120
            }
121
        }
122
123
        return true;
124
    }
125
126
    /**
127
     * Sets access and modification time of file.
128
     *
129
     * @param string|iterable $files A filename, an array of files, or a \Traversable instance to create
130
     * @param int|null        $time  The touch time as a Unix timestamp, if not supplied the current system time is used
131
     * @param int|null        $atime The access time as a Unix timestamp, if not supplied the current system time is used
132
     *
133
     * @throws IOException When touch fails
134
     */
135
    public function touch($files, int $time = null, int $atime = null)
136
    {
137
        foreach ($this->toIterable($files) as $file) {
138
            if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) {
139
                throw new IOException(sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file);
140
            }
141
        }
142
    }
143
144
    /**
145
     * Removes files or directories.
146
     *
147
     * @param string|iterable $files A filename, an array of files, or a \Traversable instance to remove
148
     *
149
     * @throws IOException When removal fails
150
     */
151
    public function remove($files)
152
    {
153
        if ($files instanceof \Traversable) {
154
            $files = iterator_to_array($files, false);
155
        } elseif (!\is_array($files)) {
156
            $files = [$files];
157
        }
158
159
        self::doRemove($files, false);
160
    }
161
162
    private static function doRemove(array $files, bool $isRecursive): void
163
    {
164
        $files = array_reverse($files);
165
        foreach ($files as $file) {
166
            if (is_link($file)) {
167
                // See https://bugs.php.net/52176
168
                if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) {
169
                    throw new IOException(sprintf('Failed to remove symlink "%s": ', $file).self::$lastError);
170
                }
171
            } elseif (is_dir($file)) {
172
                if (!$isRecursive) {
173
                    $tmpName = \dirname(realpath($file)).'/.'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-.'));
174
175
                    if (file_exists($tmpName)) {
176
                        try {
177
                            self::doRemove([$tmpName], true);
178
                        } catch (IOException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
179
                        }
180
                    }
181
182
                    if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) {
183
                        $origFile = $file;
184
                        $file = $tmpName;
185
                    } else {
186
                        $origFile = null;
187
                    }
188
                }
189
190
                $files = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS);
191
                self::doRemove(iterator_to_array($files, true), true);
192
193
                if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) {
194
                    $lastError = self::$lastError;
195
196
                    if (null !== $origFile && self::box('rename', $file, $origFile)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $origFile does not seem to be defined for all execution paths leading up to this point.
Loading history...
197
                        $file = $origFile;
198
                    }
199
200
                    throw new IOException(sprintf('Failed to remove directory "%s": ', $file).$lastError);
201
                }
202
            } elseif (!self::box('unlink', $file) && (false !== strpos(self::$lastError, 'Permission denied') || file_exists($file))) {
203
                throw new IOException(sprintf('Failed to remove file "%s": ', $file).self::$lastError);
204
            }
205
        }
206
    }
207
208
    /**
209
     * Change mode for an array of files or directories.
210
     *
211
     * @param string|iterable $files     A filename, an array of files, or a \Traversable instance to change mode
212
     * @param int             $mode      The new mode (octal)
213
     * @param int             $umask     The mode mask (octal)
214
     * @param bool            $recursive Whether change the mod recursively or not
215
     *
216
     * @throws IOException When the change fails
217
     */
218
    public function chmod($files, int $mode, int $umask = 0000, bool $recursive = false)
219
    {
220
        foreach ($this->toIterable($files) as $file) {
221
            if ((\PHP_VERSION_ID < 80000 || \is_int($mode)) && !self::box('chmod', $file, $mode & ~$umask)) {
222
                throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file);
223
            }
224
            if ($recursive && is_dir($file) && !is_link($file)) {
225
                $this->chmod(new \FilesystemIterator($file), $mode, $umask, true);
226
            }
227
        }
228
    }
229
230
    /**
231
     * Change the owner of an array of files or directories.
232
     *
233
     * @param string|iterable $files     A filename, an array of files, or a \Traversable instance to change owner
234
     * @param string|int      $user      A user name or number
235
     * @param bool            $recursive Whether change the owner recursively or not
236
     *
237
     * @throws IOException When the change fails
238
     */
239
    public function chown($files, $user, bool $recursive = false)
240
    {
241
        foreach ($this->toIterable($files) as $file) {
242
            if ($recursive && is_dir($file) && !is_link($file)) {
243
                $this->chown(new \FilesystemIterator($file), $user, true);
244
            }
245
            if (is_link($file) && \function_exists('lchown')) {
246
                if (!self::box('lchown', $file, $user)) {
247
                    throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file);
248
                }
249
            } else {
250
                if (!self::box('chown', $file, $user)) {
251
                    throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file);
252
                }
253
            }
254
        }
255
    }
256
257
    /**
258
     * Change the group of an array of files or directories.
259
     *
260
     * @param string|iterable $files     A filename, an array of files, or a \Traversable instance to change group
261
     * @param string|int      $group     A group name or number
262
     * @param bool            $recursive Whether change the group recursively or not
263
     *
264
     * @throws IOException When the change fails
265
     */
266
    public function chgrp($files, $group, bool $recursive = false)
267
    {
268
        foreach ($this->toIterable($files) as $file) {
269
            if ($recursive && is_dir($file) && !is_link($file)) {
270
                $this->chgrp(new \FilesystemIterator($file), $group, true);
271
            }
272
            if (is_link($file) && \function_exists('lchgrp')) {
273
                if (!self::box('lchgrp', $file, $group)) {
274
                    throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file);
275
                }
276
            } else {
277
                if (!self::box('chgrp', $file, $group)) {
278
                    throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file);
279
                }
280
            }
281
        }
282
    }
283
284
    /**
285
     * Renames a file or a directory.
286
     *
287
     * @throws IOException When target file or directory already exists
288
     * @throws IOException When origin cannot be renamed
289
     */
290
    public function rename(string $origin, string $target, bool $overwrite = false)
291
    {
292
        // we check that target does not exist
293
        if (!$overwrite && $this->isReadable($target)) {
294
            throw new IOException(sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target);
295
        }
296
297
        if (!self::box('rename', $origin, $target)) {
298
            if (is_dir($origin)) {
299
                // See https://bugs.php.net/54097 & https://php.net/rename#113943
300
                $this->mirror($origin, $target, null, ['override' => $overwrite, 'delete' => $overwrite]);
301
                $this->remove($origin);
302
303
                return;
304
            }
305
            throw new IOException(sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target);
306
        }
307
    }
308
309
    /**
310
     * Tells whether a file exists and is readable.
311
     *
312
     * @throws IOException When windows path is longer than 258 characters
313
     */
314
    private function isReadable(string $filename): bool
315
    {
316
        $maxPathLength = \PHP_MAXPATHLEN - 2;
317
318
        if (\strlen($filename) > $maxPathLength) {
319
            throw new IOException(sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename);
320
        }
321
322
        return is_readable($filename);
323
    }
324
325
    /**
326
     * Creates a symbolic link or copy a directory.
327
     *
328
     * @throws IOException When symlink fails
329
     */
330
    public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false)
331
    {
332
        if ('\\' === \DIRECTORY_SEPARATOR) {
333
            $originDir = strtr($originDir, '/', '\\');
334
            $targetDir = strtr($targetDir, '/', '\\');
335
336
            if ($copyOnWindows) {
337
                $this->mirror($originDir, $targetDir);
338
339
                return;
340
            }
341
        }
342
343
        $this->mkdir(\dirname($targetDir));
344
345
        if (is_link($targetDir)) {
346
            if (readlink($targetDir) === $originDir) {
347
                return;
348
            }
349
            $this->remove($targetDir);
350
        }
351
352
        if (!self::box('symlink', $originDir, $targetDir)) {
353
            $this->linkException($originDir, $targetDir, 'symbolic');
354
        }
355
    }
356
357
    /**
358
     * Creates a hard link, or several hard links to a file.
359
     *
360
     * @param string|string[] $targetFiles The target file(s)
361
     *
362
     * @throws FileNotFoundException When original file is missing or not a file
363
     * @throws IOException           When link fails, including if link already exists
364
     */
365
    public function hardlink(string $originFile, $targetFiles)
366
    {
367
        if (!$this->exists($originFile)) {
368
            throw new FileNotFoundException(null, 0, null, $originFile);
369
        }
370
371
        if (!is_file($originFile)) {
372
            throw new FileNotFoundException(sprintf('Origin file "%s" is not a file.', $originFile));
373
        }
374
375
        foreach ($this->toIterable($targetFiles) as $targetFile) {
376
            if (is_file($targetFile)) {
377
                if (fileinode($originFile) === fileinode($targetFile)) {
378
                    continue;
379
                }
380
                $this->remove($targetFile);
381
            }
382
383
            if (!self::box('link', $originFile, $targetFile)) {
384
                $this->linkException($originFile, $targetFile, 'hard');
385
            }
386
        }
387
    }
388
389
    /**
390
     * @param string $linkType Name of the link type, typically 'symbolic' or 'hard'
391
     */
392
    private function linkException(string $origin, string $target, string $linkType)
393
    {
394
        if (self::$lastError) {
395
            if ('\\' === \DIRECTORY_SEPARATOR && false !== strpos(self::$lastError, 'error code(1314)')) {
396
                throw new IOException(sprintf('Unable to create "%s" link due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', $linkType), 0, null, $target);
397
            }
398
        }
399
        throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target);
400
    }
401
402
    /**
403
     * Resolves links in paths.
404
     *
405
     * With $canonicalize = false (default)
406
     *      - if $path does not exist or is not a link, returns null
407
     *      - if $path is a link, returns the next direct target of the link without considering the existence of the target
408
     *
409
     * With $canonicalize = true
410
     *      - if $path does not exist, returns null
411
     *      - if $path exists, returns its absolute fully resolved final version
412
     *
413
     * @return string|null
414
     */
415
    public function readlink(string $path, bool $canonicalize = false)
416
    {
417
        if (!$canonicalize && !is_link($path)) {
418
            return null;
419
        }
420
421
        if ($canonicalize) {
422
            if (!$this->exists($path)) {
423
                return null;
424
            }
425
426
            if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70410) {
427
                $path = readlink($path);
428
            }
429
430
            return realpath($path);
431
        }
432
433
        if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400) {
434
            return realpath($path);
435
        }
436
437
        return readlink($path);
438
    }
439
440
    /**
441
     * Given an existing path, convert it to a path relative to a given starting path.
442
     *
443
     * @return string Path of target relative to starting path
444
     */
445
    public function makePathRelative(string $endPath, string $startPath)
446
    {
447
        if (!$this->isAbsolutePath($startPath)) {
448
            throw new InvalidArgumentException(sprintf('The start path "%s" is not absolute.', $startPath));
449
        }
450
451
        if (!$this->isAbsolutePath($endPath)) {
452
            throw new InvalidArgumentException(sprintf('The end path "%s" is not absolute.', $endPath));
453
        }
454
455
        // Normalize separators on Windows
456
        if ('\\' === \DIRECTORY_SEPARATOR) {
457
            $endPath = str_replace('\\', '/', $endPath);
458
            $startPath = str_replace('\\', '/', $startPath);
459
        }
460
461
        $splitDriveLetter = function ($path) {
462
            return (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0]))
463
                ? [substr($path, 2), strtoupper($path[0])]
464
                : [$path, null];
465
        };
466
467
        $splitPath = function ($path) {
468
            $result = [];
469
470
            foreach (explode('/', trim($path, '/')) as $segment) {
471
                if ('..' === $segment) {
472
                    array_pop($result);
473
                } elseif ('.' !== $segment && '' !== $segment) {
474
                    $result[] = $segment;
475
                }
476
            }
477
478
            return $result;
479
        };
480
481
        [$endPath, $endDriveLetter] = $splitDriveLetter($endPath);
482
        [$startPath, $startDriveLetter] = $splitDriveLetter($startPath);
483
484
        $startPathArr = $splitPath($startPath);
485
        $endPathArr = $splitPath($endPath);
486
487
        if ($endDriveLetter && $startDriveLetter && $endDriveLetter != $startDriveLetter) {
488
            // End path is on another drive, so no relative path exists
489
            return $endDriveLetter.':/'.($endPathArr ? implode('/', $endPathArr).'/' : '');
490
        }
491
492
        // Find for which directory the common path stops
493
        $index = 0;
494
        while (isset($startPathArr[$index]) && isset($endPathArr[$index]) && $startPathArr[$index] === $endPathArr[$index]) {
495
            ++$index;
496
        }
497
498
        // Determine how deep the start path is relative to the common path (ie, "web/bundles" = 2 levels)
499
        if (1 === \count($startPathArr) && '' === $startPathArr[0]) {
500
            $depth = 0;
501
        } else {
502
            $depth = \count($startPathArr) - $index;
503
        }
504
505
        // Repeated "../" for each level need to reach the common path
506
        $traverser = str_repeat('../', $depth);
507
508
        $endPathRemainder = implode('/', \array_slice($endPathArr, $index));
509
510
        // Construct $endPath from traversing to the common path, then to the remaining $endPath
511
        $relativePath = $traverser.('' !== $endPathRemainder ? $endPathRemainder.'/' : '');
512
513
        return '' === $relativePath ? './' : $relativePath;
514
    }
515
516
    /**
517
     * Mirrors a directory to another.
518
     *
519
     * Copies files and directories from the origin directory into the target directory. By default:
520
     *
521
     *  - existing files in the target directory will be overwritten, except if they are newer (see the `override` option)
522
     *  - files in the target directory that do not exist in the source directory will not be deleted (see the `delete` option)
523
     *
524
     * @param \Traversable|null $iterator Iterator that filters which files and directories to copy, if null a recursive iterator is created
525
     * @param array             $options  An array of boolean options
526
     *                                    Valid options are:
527
     *                                    - $options['override'] If true, target files newer than origin files are overwritten (see copy(), defaults to false)
528
     *                                    - $options['copy_on_windows'] Whether to copy files instead of links on Windows (see symlink(), defaults to false)
529
     *                                    - $options['delete'] Whether to delete files that are not in the source directory (defaults to false)
530
     *
531
     * @throws IOException When file type is unknown
532
     */
533
    public function mirror(string $originDir, string $targetDir, \Traversable $iterator = null, array $options = [])
534
    {
535
        $targetDir = rtrim($targetDir, '/\\');
536
        $originDir = rtrim($originDir, '/\\');
537
        $originDirLen = \strlen($originDir);
538
539
        if (!$this->exists($originDir)) {
540
            throw new IOException(sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir);
541
        }
542
543
        // Iterate in destination folder to remove obsolete entries
544
        if ($this->exists($targetDir) && isset($options['delete']) && $options['delete']) {
545
            $deleteIterator = $iterator;
546
            if (null === $deleteIterator) {
547
                $flags = \FilesystemIterator::SKIP_DOTS;
548
                $deleteIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($targetDir, $flags), \RecursiveIteratorIterator::CHILD_FIRST);
549
            }
550
            $targetDirLen = \strlen($targetDir);
551
            foreach ($deleteIterator as $file) {
552
                $origin = $originDir.substr($file->getPathname(), $targetDirLen);
553
                if (!$this->exists($origin)) {
554
                    $this->remove($file);
555
                }
556
            }
557
        }
558
559
        $copyOnWindows = $options['copy_on_windows'] ?? false;
560
561
        if (null === $iterator) {
562
            $flags = $copyOnWindows ? \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS : \FilesystemIterator::SKIP_DOTS;
563
            $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($originDir, $flags), \RecursiveIteratorIterator::SELF_FIRST);
564
        }
565
566
        $this->mkdir($targetDir);
567
        $filesCreatedWhileMirroring = [];
568
569
        foreach ($iterator as $file) {
570
            if ($file->getPathname() === $targetDir || $file->getRealPath() === $targetDir || isset($filesCreatedWhileMirroring[$file->getRealPath()])) {
571
                continue;
572
            }
573
574
            $target = $targetDir.substr($file->getPathname(), $originDirLen);
575
            $filesCreatedWhileMirroring[$target] = true;
576
577
            if (!$copyOnWindows && is_link($file)) {
578
                $this->symlink($file->getLinkTarget(), $target);
579
            } elseif (is_dir($file)) {
580
                $this->mkdir($target);
581
            } elseif (is_file($file)) {
582
                $this->copy($file, $target, $options['override'] ?? false);
583
            } else {
584
                throw new IOException(sprintf('Unable to guess "%s" file type.', $file), 0, null, $file);
585
            }
586
        }
587
    }
588
589
    /**
590
     * Returns whether the file path is an absolute path.
591
     *
592
     * @return bool
593
     */
594
    public function isAbsolutePath(string $file)
595
    {
596
        return '' !== $file && (strspn($file, '/\\', 0, 1)
597
            || (\strlen($file) > 3 && ctype_alpha($file[0])
598
                && ':' === $file[1]
599
                && strspn($file, '/\\', 2, 1)
600
            )
601
            || null !== parse_url($file, \PHP_URL_SCHEME)
602
        );
603
    }
604
605
    /**
606
     * Creates a temporary file with support for custom stream wrappers.
607
     *
608
     * @param string $prefix The prefix of the generated temporary filename
609
     *                       Note: Windows uses only the first three characters of prefix
610
     * @param string $suffix The suffix of the generated temporary filename
611
     *
612
     * @return string The new temporary filename (with path), or throw an exception on failure
613
     */
614
    public function tempnam(string $dir, string $prefix/*, string $suffix = ''*/)
615
    {
616
        $suffix = \func_num_args() > 2 ? func_get_arg(2) : '';
617
        [$scheme, $hierarchy] = $this->getSchemeAndHierarchy($dir);
618
619
        // If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem
620
        if ((null === $scheme || 'file' === $scheme || 'gs' === $scheme) && '' === $suffix) {
621
            // If tempnam failed or no scheme return the filename otherwise prepend the scheme
622
            if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) {
623
                if (null !== $scheme && 'gs' !== $scheme) {
624
                    return $scheme.'://'.$tmpFile;
625
                }
626
627
                return $tmpFile;
628
            }
629
630
            throw new IOException('A temporary file could not be created: '.self::$lastError);
631
        }
632
633
        // Loop until we create a valid temp file or have reached 10 attempts
634
        for ($i = 0; $i < 10; ++$i) {
635
            // Create a unique filename
636
            $tmpFile = $dir.'/'.$prefix.uniqid(mt_rand(), true).$suffix;
637
638
            // Use fopen instead of file_exists as some streams do not support stat
639
            // Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability
640
            if (!$handle = self::box('fopen', $tmpFile, 'x+')) {
641
                continue;
642
            }
643
644
            // Close the file if it was successfully opened
645
            self::box('fclose', $handle);
646
647
            return $tmpFile;
648
        }
649
650
        throw new IOException('A temporary file could not be created: '.self::$lastError);
651
    }
652
653
    /**
654
     * Atomically dumps content into a file.
655
     *
656
     * @param string|resource $content The data to write into the file
657
     *
658
     * @throws IOException if the file cannot be written to
659
     */
660
    public function dumpFile(string $filename, $content)
661
    {
662
        if (\is_array($content)) {
0 ignored issues
show
introduced by
The condition is_array($content) is always false.
Loading history...
663
            throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__));
664
        }
665
666
        $dir = \dirname($filename);
667
668
        if (!is_dir($dir)) {
669
            $this->mkdir($dir);
670
        }
671
672
        if (!is_writable($dir)) {
673
            throw new IOException(sprintf('Unable to write to the "%s" directory.', $dir), 0, null, $dir);
674
        }
675
676
        // Will create a temp file with 0600 access rights
677
        // when the filesystem supports chmod.
678
        $tmpFile = $this->tempnam($dir, basename($filename));
679
680
        try {
681
            if (false === self::box('file_put_contents', $tmpFile, $content)) {
682
                throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename);
683
            }
684
685
            self::box('chmod', $tmpFile, file_exists($filename) ? fileperms($filename) : 0666 & ~umask());
686
687
            $this->rename($tmpFile, $filename, true);
688
        } finally {
689
            if (file_exists($tmpFile)) {
690
                self::box('unlink', $tmpFile);
691
            }
692
        }
693
    }
694
695
    /**
696
     * Appends content to an existing file.
697
     *
698
     * @param string|resource $content The content to append
699
     *
700
     * @throws IOException If the file is not writable
701
     */
702
    public function appendToFile(string $filename, $content)
703
    {
704
        if (\is_array($content)) {
0 ignored issues
show
introduced by
The condition is_array($content) is always false.
Loading history...
705
            throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__));
706
        }
707
708
        $dir = \dirname($filename);
709
710
        if (!is_dir($dir)) {
711
            $this->mkdir($dir);
712
        }
713
714
        if (!is_writable($dir)) {
715
            throw new IOException(sprintf('Unable to write to the "%s" directory.', $dir), 0, null, $dir);
716
        }
717
718
        if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND)) {
719
            throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename);
720
        }
721
    }
722
723
    private function toIterable($files): iterable
724
    {
725
        return \is_array($files) || $files instanceof \Traversable ? $files : [$files];
726
    }
727
728
    /**
729
     * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> [file, tmp]).
730
     */
731
    private function getSchemeAndHierarchy(string $filename): array
732
    {
733
        $components = explode('://', $filename, 2);
734
735
        return 2 === \count($components) ? [$components[0], $components[1]] : [null, $components[0]];
736
    }
737
738
    /**
739
     * @param mixed ...$args
740
     *
741
     * @return mixed
742
     */
743
    private static function box(callable $func, ...$args)
744
    {
745
        self::$lastError = null;
746
        set_error_handler(__CLASS__.'::handleError');
747
        try {
748
            $result = $func(...$args);
749
            restore_error_handler();
750
751
            return $result;
752
        } catch (\Throwable $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
753
        }
754
        restore_error_handler();
755
756
        throw $e;
757
    }
758
759
    /**
760
     * @internal
761
     */
762
    public static function handleError($type, $msg)
763
    {
764
        self::$lastError = $msg;
765
    }
766
}
767