PluginProbe ʕ •ᴥ•ʔ
10Web Booster – Website speed optimization, Cache & Page Speed optimizer / trunk
10Web Booster – Website speed optimization, Cache & Page Speed optimizer vtrunk
2.33.0 2.30.5 2.30.7 2.30.9 2.31.10 2.31.8 2.32.11 2.32.21 2.32.3 2.32.4 2.32.7 2.6.31 2.6.40 2.6.42 2.6.7 2.7.37 2.7.44 2.7.47 2.8.18 2.8.19 2.8.32 2.8.34 2.8.35 2.9.23 2.9.24 2.9.25 2.9.27 v2.27.4 trunk 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.17 2.0.18 2.0.21 2.0.22 2.0.25 2.0.26 2.0.27 2.0.3 2.0.7 2.0.9 2.10.46 2.10.65 2.10.66 2.10.68 2.11.41 2.11.42 2.11.43 2.12.15 2.12.21 2.12.22 2.12.23 2.12.26 2.13.37 2.13.40 2.13.41 2.13.42 2.13.44 2.13.45 2.13.47 2.14.49 2.14.50 2.15.18 2.17.21 2.17.23 2.18.17 2.19.44 2.19.45 2.19.46 2.19.49 2.2.12 2.2.15 2.2.16 2.2.18 2.2.8 2.20.31 2.20.32 2.20.33 2.21.11 2.21.12 2.21.16 2.21.25 2.22.32 2.23.13 2.23.15 2.23.16 2.23.18 2.24.12 2.24.14 2.24.18 2.25.14 2.26.6 2.28.10 2.28.13 2.28.14 2.28.7 2.29.1 2.29.2 2.29.3 2.3.0 2.3.1 2.3.2 2.3.3 2.30.18
tenweb-speed-optimizer / vendor / symfony / filesystem / Filesystem.php
tenweb-speed-optimizer / vendor / symfony / filesystem Last commit date
Exception 2 years ago CHANGELOG.md 2 years ago Filesystem.php 1 year ago LICENSE 2 years ago Path.php 2 years ago README.md 4 years ago composer.json 2 years ago
Filesystem.php
791 lines
1 <?php
2
3 /*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
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 <fabien@symfony.com>
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 && !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 // Like `cp`, preserve the file modification time
76 self::box('touch', $targetFile, filemtime($originFile));
77
78 if ($bytesCopied !== $bytesOrigin = filesize($originFile)) {
79 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);
80 }
81 }
82 }
83 }
84
85 /**
86 * Creates a directory recursively.
87 *
88 * @param string|iterable $dirs The directory path
89 *
90 * @throws IOException On any directory creation failure
91 */
92 public function mkdir($dirs, int $mode = 0777)
93 {
94 foreach ($this->toIterable($dirs) as $dir) {
95 if (is_dir($dir)) {
96 continue;
97 }
98
99 if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) {
100 throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir);
101 }
102 }
103 }
104
105 /**
106 * Checks the existence of files or directories.
107 *
108 * @param string|iterable $files A filename, an array of files, or a \Traversable instance to check
109 *
110 * @return bool
111 */
112 public function exists($files)
113 {
114 $maxPathLength = \PHP_MAXPATHLEN - 2;
115
116 foreach ($this->toIterable($files) as $file) {
117 if (\strlen($file) > $maxPathLength) {
118 throw new IOException(sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file);
119 }
120
121 if (!file_exists($file)) {
122 return false;
123 }
124 }
125
126 return true;
127 }
128
129 /**
130 * Sets access and modification time of file.
131 *
132 * @param string|iterable $files A filename, an array of files, or a \Traversable instance to create
133 * @param int|null $time The touch time as a Unix timestamp, if not supplied the current system time is used
134 * @param int|null $atime The access time as a Unix timestamp, if not supplied the current system time is used
135 *
136 * @throws IOException When touch fails
137 */
138 public function touch($files, ?int $time = null, ?int $atime = null)
139 {
140 foreach ($this->toIterable($files) as $file) {
141 if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) {
142 throw new IOException(sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file);
143 }
144 }
145 }
146
147 /**
148 * Removes files or directories.
149 *
150 * @param string|iterable $files A filename, an array of files, or a \Traversable instance to remove
151 *
152 * @throws IOException When removal fails
153 */
154 public function remove($files)
155 {
156 if ($files instanceof \Traversable) {
157 $files = iterator_to_array($files, false);
158 } elseif (!\is_array($files)) {
159 $files = [$files];
160 }
161
162 self::doRemove($files, false);
163 }
164
165 private static function doRemove(array $files, bool $isRecursive): void
166 {
167 $files = array_reverse($files);
168 foreach ($files as $file) {
169 if (is_link($file)) {
170 // See https://bugs.php.net/52176
171 if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) {
172 throw new IOException(sprintf('Failed to remove symlink "%s": ', $file).self::$lastError);
173 }
174 } elseif (is_dir($file)) {
175 if (!$isRecursive) {
176 $tmpName = \dirname(realpath($file)).'/.!'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-!'));
177
178 if (file_exists($tmpName)) {
179 try {
180 self::doRemove([$tmpName], true);
181 } catch (IOException $e) {
182 }
183 }
184
185 if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) {
186 $origFile = $file;
187 $file = $tmpName;
188 } else {
189 $origFile = null;
190 }
191 }
192
193 $files = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS);
194 self::doRemove(iterator_to_array($files, true), true);
195
196 if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) {
197 $lastError = self::$lastError;
198
199 if (null !== $origFile && self::box('rename', $file, $origFile)) {
200 $file = $origFile;
201 }
202
203 throw new IOException(sprintf('Failed to remove directory "%s": ', $file).$lastError);
204 }
205 } elseif (!self::box('unlink', $file) && ((self::$lastError && str_contains(self::$lastError, 'Permission denied')) || file_exists($file))) {
206 throw new IOException(sprintf('Failed to remove file "%s": ', $file).self::$lastError);
207 }
208 }
209 }
210
211 /**
212 * Change mode for an array of files or directories.
213 *
214 * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change mode
215 * @param int $mode The new mode (octal)
216 * @param int $umask The mode mask (octal)
217 * @param bool $recursive Whether change the mod recursively or not
218 *
219 * @throws IOException When the change fails
220 */
221 public function chmod($files, int $mode, int $umask = 0000, bool $recursive = false)
222 {
223 foreach ($this->toIterable($files) as $file) {
224 if ((\PHP_VERSION_ID < 80000 || \is_int($mode)) && !self::box('chmod', $file, $mode & ~$umask)) {
225 throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file);
226 }
227 if ($recursive && is_dir($file) && !is_link($file)) {
228 $this->chmod(new \FilesystemIterator($file), $mode, $umask, true);
229 }
230 }
231 }
232
233 /**
234 * Change the owner of an array of files or directories.
235 *
236 * This method always throws on Windows, as the underlying PHP function is not supported.
237 *
238 * @see https://www.php.net/chown
239 *
240 * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change owner
241 * @param string|int $user A user name or number
242 * @param bool $recursive Whether change the owner recursively or not
243 *
244 * @throws IOException When the change fails
245 */
246 public function chown($files, $user, bool $recursive = false)
247 {
248 foreach ($this->toIterable($files) as $file) {
249 if ($recursive && is_dir($file) && !is_link($file)) {
250 $this->chown(new \FilesystemIterator($file), $user, true);
251 }
252 if (is_link($file) && \function_exists('lchown')) {
253 if (!self::box('lchown', $file, $user)) {
254 throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file);
255 }
256 } else {
257 if (!self::box('chown', $file, $user)) {
258 throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file);
259 }
260 }
261 }
262 }
263
264 /**
265 * Change the group of an array of files or directories.
266 *
267 * This method always throws on Windows, as the underlying PHP function is not supported.
268 *
269 * @see https://www.php.net/chgrp
270 *
271 * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change group
272 * @param string|int $group A group name or number
273 * @param bool $recursive Whether change the group recursively or not
274 *
275 * @throws IOException When the change fails
276 */
277 public function chgrp($files, $group, bool $recursive = false)
278 {
279 foreach ($this->toIterable($files) as $file) {
280 if ($recursive && is_dir($file) && !is_link($file)) {
281 $this->chgrp(new \FilesystemIterator($file), $group, true);
282 }
283 if (is_link($file) && \function_exists('lchgrp')) {
284 if (!self::box('lchgrp', $file, $group)) {
285 throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file);
286 }
287 } else {
288 if (!self::box('chgrp', $file, $group)) {
289 throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file);
290 }
291 }
292 }
293 }
294
295 /**
296 * Renames a file or a directory.
297 *
298 * @throws IOException When target file or directory already exists
299 * @throws IOException When origin cannot be renamed
300 */
301 public function rename(string $origin, string $target, bool $overwrite = false)
302 {
303 // we check that target does not exist
304 if (!$overwrite && $this->isReadable($target)) {
305 throw new IOException(sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target);
306 }
307
308 if (!self::box('rename', $origin, $target)) {
309 if (is_dir($origin)) {
310 // See https://bugs.php.net/54097 & https://php.net/rename#113943
311 $this->mirror($origin, $target, null, ['override' => $overwrite, 'delete' => $overwrite]);
312 $this->remove($origin);
313
314 return;
315 }
316 throw new IOException(sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target);
317 }
318 }
319
320 /**
321 * Tells whether a file exists and is readable.
322 *
323 * @throws IOException When windows path is longer than 258 characters
324 */
325 private function isReadable(string $filename): bool
326 {
327 $maxPathLength = \PHP_MAXPATHLEN - 2;
328
329 if (\strlen($filename) > $maxPathLength) {
330 throw new IOException(sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename);
331 }
332
333 return is_readable($filename);
334 }
335
336 /**
337 * Creates a symbolic link or copy a directory.
338 *
339 * @throws IOException When symlink fails
340 */
341 public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false)
342 {
343 self::assertFunctionExists('symlink');
344
345 if ('\\' === \DIRECTORY_SEPARATOR) {
346 $originDir = strtr($originDir, '/', '\\');
347 $targetDir = strtr($targetDir, '/', '\\');
348
349 if ($copyOnWindows) {
350 $this->mirror($originDir, $targetDir);
351
352 return;
353 }
354 }
355
356 $this->mkdir(\dirname($targetDir));
357
358 if (is_link($targetDir)) {
359 if (readlink($targetDir) === $originDir) {
360 return;
361 }
362 $this->remove($targetDir);
363 }
364
365 if (!self::box('symlink', $originDir, $targetDir)) {
366 $this->linkException($originDir, $targetDir, 'symbolic');
367 }
368 }
369
370 /**
371 * Creates a hard link, or several hard links to a file.
372 *
373 * @param string|string[] $targetFiles The target file(s)
374 *
375 * @throws FileNotFoundException When original file is missing or not a file
376 * @throws IOException When link fails, including if link already exists
377 */
378 public function hardlink(string $originFile, $targetFiles)
379 {
380 self::assertFunctionExists('link');
381
382 if (!$this->exists($originFile)) {
383 throw new FileNotFoundException(null, 0, null, $originFile);
384 }
385
386 if (!is_file($originFile)) {
387 throw new FileNotFoundException(sprintf('Origin file "%s" is not a file.', $originFile));
388 }
389
390 foreach ($this->toIterable($targetFiles) as $targetFile) {
391 if (is_file($targetFile)) {
392 if (fileinode($originFile) === fileinode($targetFile)) {
393 continue;
394 }
395 $this->remove($targetFile);
396 }
397
398 if (!self::box('link', $originFile, $targetFile)) {
399 $this->linkException($originFile, $targetFile, 'hard');
400 }
401 }
402 }
403
404 /**
405 * @param string $linkType Name of the link type, typically 'symbolic' or 'hard'
406 */
407 private function linkException(string $origin, string $target, string $linkType)
408 {
409 if (self::$lastError) {
410 if ('\\' === \DIRECTORY_SEPARATOR && str_contains(self::$lastError, 'error code(1314)')) {
411 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);
412 }
413 }
414 throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target);
415 }
416
417 /**
418 * Resolves links in paths.
419 *
420 * With $canonicalize = false (default)
421 * - if $path does not exist or is not a link, returns null
422 * - if $path is a link, returns the next direct target of the link without considering the existence of the target
423 *
424 * With $canonicalize = true
425 * - if $path does not exist, returns null
426 * - if $path exists, returns its absolute fully resolved final version
427 *
428 * @return string|null
429 */
430 public function readlink(string $path, bool $canonicalize = false)
431 {
432 if (!$canonicalize && !is_link($path)) {
433 return null;
434 }
435
436 if ($canonicalize) {
437 if (!$this->exists($path)) {
438 return null;
439 }
440
441 if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70410) {
442 $path = readlink($path);
443 }
444
445 return realpath($path);
446 }
447
448 if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400) {
449 return realpath($path);
450 }
451
452 return readlink($path);
453 }
454
455 /**
456 * Given an existing path, convert it to a path relative to a given starting path.
457 *
458 * @return string
459 */
460 public function makePathRelative(string $endPath, string $startPath)
461 {
462 if (!$this->isAbsolutePath($startPath)) {
463 throw new InvalidArgumentException(sprintf('The start path "%s" is not absolute.', $startPath));
464 }
465
466 if (!$this->isAbsolutePath($endPath)) {
467 throw new InvalidArgumentException(sprintf('The end path "%s" is not absolute.', $endPath));
468 }
469
470 // Normalize separators on Windows
471 if ('\\' === \DIRECTORY_SEPARATOR) {
472 $endPath = str_replace('\\', '/', $endPath);
473 $startPath = str_replace('\\', '/', $startPath);
474 }
475
476 $splitDriveLetter = function ($path) {
477 return (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0]))
478 ? [substr($path, 2), strtoupper($path[0])]
479 : [$path, null];
480 };
481
482 $splitPath = function ($path) {
483 $result = [];
484
485 foreach (explode('/', trim($path, '/')) as $segment) {
486 if ('..' === $segment) {
487 array_pop($result);
488 } elseif ('.' !== $segment && '' !== $segment) {
489 $result[] = $segment;
490 }
491 }
492
493 return $result;
494 };
495
496 [$endPath, $endDriveLetter] = $splitDriveLetter($endPath);
497 [$startPath, $startDriveLetter] = $splitDriveLetter($startPath);
498
499 $startPathArr = $splitPath($startPath);
500 $endPathArr = $splitPath($endPath);
501
502 if ($endDriveLetter && $startDriveLetter && $endDriveLetter != $startDriveLetter) {
503 // End path is on another drive, so no relative path exists
504 return $endDriveLetter.':/'.($endPathArr ? implode('/', $endPathArr).'/' : '');
505 }
506
507 // Find for which directory the common path stops
508 $index = 0;
509 while (isset($startPathArr[$index]) && isset($endPathArr[$index]) && $startPathArr[$index] === $endPathArr[$index]) {
510 ++$index;
511 }
512
513 // Determine how deep the start path is relative to the common path (ie, "web/bundles" = 2 levels)
514 if (1 === \count($startPathArr) && '' === $startPathArr[0]) {
515 $depth = 0;
516 } else {
517 $depth = \count($startPathArr) - $index;
518 }
519
520 // Repeated "../" for each level need to reach the common path
521 $traverser = str_repeat('../', $depth);
522
523 $endPathRemainder = implode('/', \array_slice($endPathArr, $index));
524
525 // Construct $endPath from traversing to the common path, then to the remaining $endPath
526 $relativePath = $traverser.('' !== $endPathRemainder ? $endPathRemainder.'/' : '');
527
528 return '' === $relativePath ? './' : $relativePath;
529 }
530
531 /**
532 * Mirrors a directory to another.
533 *
534 * Copies files and directories from the origin directory into the target directory. By default:
535 *
536 * - existing files in the target directory will be overwritten, except if they are newer (see the `override` option)
537 * - files in the target directory that do not exist in the source directory will not be deleted (see the `delete` option)
538 *
539 * @param \Traversable|null $iterator Iterator that filters which files and directories to copy, if null a recursive iterator is created
540 * @param array $options An array of boolean options
541 * Valid options are:
542 * - $options['override'] If true, target files newer than origin files are overwritten (see copy(), defaults to false)
543 * - $options['copy_on_windows'] Whether to copy files instead of links on Windows (see symlink(), defaults to false)
544 * - $options['delete'] Whether to delete files that are not in the source directory (defaults to false)
545 *
546 * @throws IOException When file type is unknown
547 */
548 public function mirror(string $originDir, string $targetDir, ?\Traversable $iterator = null, array $options = [])
549 {
550 $targetDir = rtrim($targetDir, '/\\');
551 $originDir = rtrim($originDir, '/\\');
552 $originDirLen = \strlen($originDir);
553
554 if (!$this->exists($originDir)) {
555 throw new IOException(sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir);
556 }
557
558 // Iterate in destination folder to remove obsolete entries
559 if ($this->exists($targetDir) && isset($options['delete']) && $options['delete']) {
560 $deleteIterator = $iterator;
561 if (null === $deleteIterator) {
562 $flags = \FilesystemIterator::SKIP_DOTS;
563 $deleteIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($targetDir, $flags), \RecursiveIteratorIterator::CHILD_FIRST);
564 }
565 $targetDirLen = \strlen($targetDir);
566 foreach ($deleteIterator as $file) {
567 $origin = $originDir.substr($file->getPathname(), $targetDirLen);
568 if (!$this->exists($origin)) {
569 $this->remove($file);
570 }
571 }
572 }
573
574 $copyOnWindows = $options['copy_on_windows'] ?? false;
575
576 if (null === $iterator) {
577 $flags = $copyOnWindows ? \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS : \FilesystemIterator::SKIP_DOTS;
578 $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($originDir, $flags), \RecursiveIteratorIterator::SELF_FIRST);
579 }
580
581 $this->mkdir($targetDir);
582 $filesCreatedWhileMirroring = [];
583
584 foreach ($iterator as $file) {
585 if ($file->getPathname() === $targetDir || $file->getRealPath() === $targetDir || isset($filesCreatedWhileMirroring[$file->getRealPath()])) {
586 continue;
587 }
588
589 $target = $targetDir.substr($file->getPathname(), $originDirLen);
590 $filesCreatedWhileMirroring[$target] = true;
591
592 if (!$copyOnWindows && is_link($file)) {
593 $this->symlink($file->getLinkTarget(), $target);
594 } elseif (is_dir($file)) {
595 $this->mkdir($target);
596 } elseif (is_file($file)) {
597 $this->copy($file, $target, $options['override'] ?? false);
598 } else {
599 throw new IOException(sprintf('Unable to guess "%s" file type.', $file), 0, null, $file);
600 }
601 }
602 }
603
604 /**
605 * Returns whether the file path is an absolute path.
606 *
607 * @return bool
608 */
609 public function isAbsolutePath(string $file)
610 {
611 return '' !== $file && (strspn($file, '/\\', 0, 1)
612 || (\strlen($file) > 3 && ctype_alpha($file[0])
613 && ':' === $file[1]
614 && strspn($file, '/\\', 2, 1)
615 )
616 || null !== parse_url($file, \PHP_URL_SCHEME)
617 );
618 }
619
620 /**
621 * Creates a temporary file with support for custom stream wrappers.
622 *
623 * @param string $prefix The prefix of the generated temporary filename
624 * Note: Windows uses only the first three characters of prefix
625 * @param string $suffix The suffix of the generated temporary filename
626 *
627 * @return string The new temporary filename (with path), or throw an exception on failure
628 */
629 public function tempnam(string $dir, string $prefix/* , string $suffix = '' */)
630 {
631 $suffix = \func_num_args() > 2 ? func_get_arg(2) : '';
632 [$scheme, $hierarchy] = $this->getSchemeAndHierarchy($dir);
633
634 // If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem
635 if ((null === $scheme || 'file' === $scheme || 'gs' === $scheme) && '' === $suffix) {
636 // If tempnam failed or no scheme return the filename otherwise prepend the scheme
637 if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) {
638 if (null !== $scheme && 'gs' !== $scheme) {
639 return $scheme.'://'.$tmpFile;
640 }
641
642 return $tmpFile;
643 }
644
645 throw new IOException('A temporary file could not be created: '.self::$lastError);
646 }
647
648 // Loop until we create a valid temp file or have reached 10 attempts
649 for ($i = 0; $i < 10; ++$i) {
650 // Create a unique filename
651 $tmpFile = $dir.'/'.$prefix.uniqid(mt_rand(), true).$suffix;
652
653 // Use fopen instead of file_exists as some streams do not support stat
654 // Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability
655 if (!$handle = self::box('fopen', $tmpFile, 'x+')) {
656 continue;
657 }
658
659 // Close the file if it was successfully opened
660 self::box('fclose', $handle);
661
662 return $tmpFile;
663 }
664
665 throw new IOException('A temporary file could not be created: '.self::$lastError);
666 }
667
668 /**
669 * Atomically dumps content into a file.
670 *
671 * @param string|resource $content The data to write into the file
672 *
673 * @throws IOException if the file cannot be written to
674 */
675 public function dumpFile(string $filename, $content)
676 {
677 if (\is_array($content)) {
678 throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__));
679 }
680
681 $dir = \dirname($filename);
682
683 if (is_link($filename) && $linkTarget = $this->readlink($filename)) {
684 $this->dumpFile(Path::makeAbsolute($linkTarget, $dir), $content);
685
686 return;
687 }
688
689 if (!is_dir($dir)) {
690 $this->mkdir($dir);
691 }
692
693 // Will create a temp file with 0600 access rights
694 // when the filesystem supports chmod.
695 $tmpFile = $this->tempnam($dir, basename($filename));
696
697 try {
698 if (false === self::box('file_put_contents', $tmpFile, $content)) {
699 throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename);
700 }
701
702 self::box('chmod', $tmpFile, self::box('fileperms', $filename) ?: 0666 & ~umask());
703
704 $this->rename($tmpFile, $filename, true);
705 } finally {
706 if (file_exists($tmpFile)) {
707 if ('\\' === \DIRECTORY_SEPARATOR && !is_writable($tmpFile)) {
708 self::box('chmod', $tmpFile, self::box('fileperms', $tmpFile) | 0200);
709 }
710
711 self::box('unlink', $tmpFile);
712 }
713 }
714 }
715
716 /**
717 * Appends content to an existing file.
718 *
719 * @param string|resource $content The content to append
720 * @param bool $lock Whether the file should be locked when writing to it
721 *
722 * @throws IOException If the file is not writable
723 */
724 public function appendToFile(string $filename, $content/* , bool $lock = false */)
725 {
726 if (\is_array($content)) {
727 throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__));
728 }
729
730 $dir = \dirname($filename);
731
732 if (!is_dir($dir)) {
733 $this->mkdir($dir);
734 }
735
736 $lock = \func_num_args() > 2 && func_get_arg(2);
737
738 if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND | ($lock ? \LOCK_EX : 0))) {
739 throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename);
740 }
741 }
742
743 private function toIterable($files): iterable
744 {
745 return is_iterable($files) ? $files : [$files];
746 }
747
748 /**
749 * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> [file, tmp]).
750 */
751 private function getSchemeAndHierarchy(string $filename): array
752 {
753 $components = explode('://', $filename, 2);
754
755 return 2 === \count($components) ? [$components[0], $components[1]] : [null, $components[0]];
756 }
757
758 private static function assertFunctionExists(string $func): void
759 {
760 if (!\function_exists($func)) {
761 throw new IOException(sprintf('Unable to perform filesystem operation because the "%s()" function has been disabled.', $func));
762 }
763 }
764
765 /**
766 * @param mixed ...$args
767 *
768 * @return mixed
769 */
770 private static function box(string $func, ...$args)
771 {
772 self::assertFunctionExists($func);
773
774 self::$lastError = null;
775 set_error_handler(__CLASS__.'::handleError');
776 try {
777 return $func(...$args);
778 } finally {
779 restore_error_handler();
780 }
781 }
782
783 /**
784 * @internal
785 */
786 public static function handleError(int $type, string $msg)
787 {
788 self::$lastError = $msg;
789 }
790 }
791