1<?php 2 3/** 4 * Simple wrapper class for common filesystem tasks like reading and writing 5 * files. When things go wrong, this class throws detailed exceptions with 6 * good information about what didn't work. 7 * 8 * Filesystem will resolve relative paths against PWD from the environment. 9 * When Filesystem is unable to complete an operation, it throws a 10 * FilesystemException. 11 * 12 * @task directory Directories 13 * @task file Files 14 * @task path Paths 15 * @task exec Executables 16 * @task assert Assertions 17 */ 18final class Filesystem extends Phobject { 19 20 21/* -( Files )-------------------------------------------------------------- */ 22 23 24 /** 25 * Read a file in a manner similar to file_get_contents(), but throw detailed 26 * exceptions on failure. 27 * 28 * @param string File path to read. This file must exist and be readable, 29 * or an exception will be thrown. 30 * @return string Contents of the specified file. 31 * 32 * @task file 33 */ 34 public static function readFile($path) { 35 $path = self::resolvePath($path); 36 37 self::assertExists($path); 38 self::assertIsFile($path); 39 self::assertReadable($path); 40 41 $data = @file_get_contents($path); 42 if ($data === false) { 43 throw new FilesystemException( 44 $path, 45 pht("Failed to read file '%s'.", $path)); 46 } 47 48 return $data; 49 } 50 51 /** 52 * Make assertions about the state of path in preparation for 53 * writeFile() and writeFileIfChanged(). 54 */ 55 private static function assertWritableFile($path) { 56 $path = self::resolvePath($path); 57 $dir = dirname($path); 58 59 self::assertExists($dir); 60 self::assertIsDirectory($dir); 61 62 // File either needs to not exist and have a writable parent, or be 63 // writable itself. 64 $exists = true; 65 try { 66 self::assertNotExists($path); 67 $exists = false; 68 } catch (Exception $ex) { 69 self::assertWritable($path); 70 } 71 72 if (!$exists) { 73 self::assertWritable($dir); 74 } 75 } 76 77 /** 78 * Write a file in a manner similar to file_put_contents(), but throw 79 * detailed exceptions on failure. If the file already exists, it will be 80 * overwritten. 81 * 82 * @param string File path to write. This file must be writable and its 83 * parent directory must exist. 84 * @param string Data to write. 85 * 86 * @task file 87 */ 88 public static function writeFile($path, $data) { 89 self::assertWritableFile($path); 90 91 if (@file_put_contents($path, $data) === false) { 92 throw new FilesystemException( 93 $path, 94 pht("Failed to write file '%s'.", $path)); 95 } 96 } 97 98 /** 99 * Write a file in a manner similar to `file_put_contents()`, but only touch 100 * the file if the contents are different, and throw detailed exceptions on 101 * failure. 102 * 103 * As this function is used in build steps to update code, if we write a new 104 * file, we do so by writing to a temporary file and moving it into place. 105 * This allows a concurrently reading process to see a consistent view of the 106 * file without needing locking; any given read of the file is guaranteed to 107 * be self-consistent and not see partial file contents. 108 * 109 * @param string file path to write 110 * @param string data to write 111 * 112 * @return boolean indicating whether the file was changed by this function. 113 */ 114 public static function writeFileIfChanged($path, $data) { 115 if (file_exists($path)) { 116 $current = self::readFile($path); 117 if ($current === $data) { 118 return false; 119 } 120 } 121 self::assertWritableFile($path); 122 123 // Create the temporary file alongside the intended destination, 124 // as this ensures that the rename() will be atomic (on the same fs) 125 $dir = dirname($path); 126 $temp = tempnam($dir, 'GEN'); 127 if (!$temp) { 128 throw new FilesystemException( 129 $dir, 130 pht('Unable to create temporary file in %s.', $dir)); 131 } 132 try { 133 self::writeFile($temp, $data); 134 // tempnam will always restrict ownership to us, broaden 135 // it so that these files respect the actual umask 136 self::changePermissions($temp, 0666 & ~umask()); 137 // This will appear atomic to concurrent readers 138 $ok = rename($temp, $path); 139 if (!$ok) { 140 throw new FilesystemException( 141 $path, 142 pht('Unable to move %s to %s.', $temp, $path)); 143 } 144 } catch (Exception $e) { 145 // Make best effort to remove temp file 146 unlink($temp); 147 throw $e; 148 } 149 return true; 150 } 151 152 153 /** 154 * Write data to unique file, without overwriting existing files. This is 155 * useful if you want to write a ".bak" file or something similar, but want 156 * to make sure you don't overwrite something already on disk. 157 * 158 * This function will add a number to the filename if the base name already 159 * exists, e.g. "example.bak", "example.bak.1", "example.bak.2", etc. (Don't 160 * rely on this exact behavior, of course.) 161 * 162 * @param string Suggested filename, like "example.bak". This name will 163 * be used if it does not exist, or some similar name will 164 * be chosen if it does. 165 * @param string Data to write to the file. 166 * @return string Path to a newly created and written file which did not 167 * previously exist, like "example.bak.3". 168 * @task file 169 */ 170 public static function writeUniqueFile($base, $data) { 171 $full_path = self::resolvePath($base); 172 $sequence = 0; 173 assert_stringlike($data); 174 // Try 'file', 'file.1', 'file.2', etc., until something doesn't exist. 175 176 while (true) { 177 $try_path = $full_path; 178 if ($sequence) { 179 $try_path .= '.'.$sequence; 180 } 181 182 $handle = @fopen($try_path, 'x'); 183 if ($handle) { 184 $ok = fwrite($handle, $data); 185 if ($ok === false) { 186 throw new FilesystemException( 187 $try_path, 188 pht('Failed to write file data.')); 189 } 190 191 $ok = fclose($handle); 192 if (!$ok) { 193 throw new FilesystemException( 194 $try_path, 195 pht('Failed to close file handle.')); 196 } 197 198 return $try_path; 199 } 200 201 $sequence++; 202 } 203 } 204 205 206 /** 207 * Append to a file without having to deal with file handles, with 208 * detailed exceptions on failure. 209 * 210 * @param string File path to write. This file must be writable or its 211 * parent directory must exist and be writable. 212 * @param string Data to write. 213 * 214 * @task file 215 */ 216 public static function appendFile($path, $data) { 217 $path = self::resolvePath($path); 218 219 // Use self::writeFile() if the file doesn't already exist 220 try { 221 self::assertExists($path); 222 } catch (FilesystemException $ex) { 223 self::writeFile($path, $data); 224 return; 225 } 226 227 // File needs to exist or the directory needs to be writable 228 $dir = dirname($path); 229 self::assertExists($dir); 230 self::assertIsDirectory($dir); 231 self::assertWritable($dir); 232 assert_stringlike($data); 233 234 if (($fh = fopen($path, 'a')) === false) { 235 throw new FilesystemException( 236 $path, 237 pht("Failed to open file '%s'.", $path)); 238 } 239 $dlen = strlen($data); 240 if (fwrite($fh, $data) !== $dlen) { 241 throw new FilesystemException( 242 $path, 243 pht("Failed to write %d bytes to '%s'.", $dlen, $path)); 244 } 245 if (!fflush($fh) || !fclose($fh)) { 246 throw new FilesystemException( 247 $path, 248 pht("Failed closing file '%s' after write.", $path)); 249 } 250 } 251 252 253 /** 254 * Copy a file, preserving file attributes (if relevant for the OS). 255 * 256 * @param string File path to copy from. This file must exist and be 257 * readable, or an exception will be thrown. 258 * @param string File path to copy to. If a file exists at this path 259 * already, it wll be overwritten. 260 * 261 * @task file 262 */ 263 public static function copyFile($from, $to) { 264 $from = self::resolvePath($from); 265 $to = self::resolvePath($to); 266 267 self::assertExists($from); 268 self::assertIsFile($from); 269 self::assertReadable($from); 270 271 if (phutil_is_windows()) { 272 execx('copy /Y %s %s', $from, $to); 273 } else { 274 execx('cp -p %s %s', $from, $to); 275 } 276 } 277 278 279 /** 280 * Remove a file or directory. 281 * 282 * @param string File to a path or directory to remove. 283 * @return void 284 * 285 * @task file 286 */ 287 public static function remove($path) { 288 if (!strlen($path)) { 289 // Avoid removing PWD. 290 throw new Exception( 291 pht( 292 'No path provided to %s.', 293 __FUNCTION__.'()')); 294 } 295 296 $path = self::resolvePath($path); 297 298 if (!file_exists($path)) { 299 return; 300 } 301 302 self::executeRemovePath($path); 303 } 304 305 /** 306 * Rename a file or directory. 307 * 308 * @param string Old path. 309 * @param string New path. 310 * 311 * @task file 312 */ 313 public static function rename($old, $new) { 314 $old = self::resolvePath($old); 315 $new = self::resolvePath($new); 316 317 self::assertExists($old); 318 319 $ok = rename($old, $new); 320 if (!$ok) { 321 throw new FilesystemException( 322 $new, 323 pht("Failed to rename '%s' to '%s'!", $old, $new)); 324 } 325 } 326 327 328 /** 329 * Internal. Recursively remove a file or an entire directory. Implements 330 * the core function of @{method:remove} in a way that works on Windows. 331 * 332 * @param string File to a path or directory to remove. 333 * @return void 334 * 335 * @task file 336 */ 337 private static function executeRemovePath($path) { 338 if (is_dir($path) && !is_link($path)) { 339 foreach (self::listDirectory($path, true) as $child) { 340 self::executeRemovePath($path.DIRECTORY_SEPARATOR.$child); 341 } 342 $ok = rmdir($path); 343 if (!$ok) { 344 throw new FilesystemException( 345 $path, 346 pht("Failed to remove directory '%s'!", $path)); 347 } 348 } else { 349 $ok = unlink($path); 350 if (!$ok) { 351 throw new FilesystemException( 352 $path, 353 pht("Failed to remove file '%s'!", $path)); 354 } 355 } 356 } 357 358 359 /** 360 * Change the permissions of a file or directory. 361 * 362 * @param string Path to the file or directory. 363 * @param int Permission umask. Note that umask is in octal, so you 364 * should specify it as, e.g., `0777', not `777'. 365 * @return void 366 * 367 * @task file 368 */ 369 public static function changePermissions($path, $umask) { 370 $path = self::resolvePath($path); 371 372 self::assertExists($path); 373 374 if (!@chmod($path, $umask)) { 375 $readable_umask = sprintf('%04o', $umask); 376 throw new FilesystemException( 377 $path, 378 pht("Failed to chmod '%s' to '%s'.", $path, $readable_umask)); 379 } 380 } 381 382 383 /** 384 * Get the last modified time of a file 385 * 386 * @param string Path to file 387 * @return int Time last modified 388 * 389 * @task file 390 */ 391 public static function getModifiedTime($path) { 392 $path = self::resolvePath($path); 393 self::assertExists($path); 394 self::assertIsFile($path); 395 self::assertReadable($path); 396 397 $modified_time = @filemtime($path); 398 399 if ($modified_time === false) { 400 throw new FilesystemException( 401 $path, 402 pht('Failed to read modified time for %s.', $path)); 403 } 404 405 return $modified_time; 406 } 407 408 409 /** 410 * Read random bytes from /dev/urandom or equivalent. See also 411 * @{method:readRandomCharacters}. 412 * 413 * @param int Number of bytes to read. 414 * @return string Random bytestring of the provided length. 415 * 416 * @task file 417 */ 418 public static function readRandomBytes($number_of_bytes) { 419 $number_of_bytes = (int)$number_of_bytes; 420 if ($number_of_bytes < 1) { 421 throw new Exception(pht('You must generate at least 1 byte of entropy.')); 422 } 423 424 // Under PHP 7.2.0 and newer, we have a reasonable builtin. For older 425 // versions, we fall back to various sources which have a roughly similar 426 // effect. 427 if (function_exists('random_bytes')) { 428 return random_bytes($number_of_bytes); 429 } 430 431 // Try to use `openssl_random_pseudo_bytes()` if it's available. This source 432 // is the most widely available source, and works on Windows/Linux/OSX/etc. 433 434 if (function_exists('openssl_random_pseudo_bytes')) { 435 $strong = true; 436 $data = openssl_random_pseudo_bytes($number_of_bytes, $strong); 437 438 if (!$strong) { 439 // NOTE: This indicates we're using a weak random source. This is 440 // probably OK, but maybe we should be more strict here. 441 } 442 443 if ($data === false) { 444 throw new Exception( 445 pht( 446 '%s failed to generate entropy!', 447 'openssl_random_pseudo_bytes()')); 448 } 449 450 if (strlen($data) != $number_of_bytes) { 451 throw new Exception( 452 pht( 453 '%s returned an unexpected number of bytes (got %s, expected %s)!', 454 'openssl_random_pseudo_bytes()', 455 new PhutilNumber(strlen($data)), 456 new PhutilNumber($number_of_bytes))); 457 } 458 459 return $data; 460 } 461 462 463 // Try to use `/dev/urandom` if it's available. This is usually available 464 // on non-Windows systems, but some PHP config (open_basedir) and chrooting 465 // may limit our access to it. 466 467 $urandom = @fopen('/dev/urandom', 'rb'); 468 if ($urandom) { 469 $data = @fread($urandom, $number_of_bytes); 470 @fclose($urandom); 471 if (strlen($data) != $number_of_bytes) { 472 throw new FilesystemException( 473 '/dev/urandom', 474 pht('Failed to read random bytes!')); 475 } 476 return $data; 477 } 478 479 // (We might be able to try to generate entropy here from a weaker source 480 // if neither of the above sources panned out, see some discussion in 481 // T4153.) 482 483 // We've failed to find any valid entropy source. Try to fail in the most 484 // useful way we can, based on the platform. 485 486 if (phutil_is_windows()) { 487 throw new Exception( 488 pht( 489 '%s requires the PHP OpenSSL extension to be installed and enabled '. 490 'to access an entropy source. On Windows, this extension is usually '. 491 'installed but not enabled by default. Enable it in your "php.ini".', 492 __METHOD__.'()')); 493 } 494 495 throw new Exception( 496 pht( 497 '%s requires the PHP OpenSSL extension or access to "%s". Install or '. 498 'enable the OpenSSL extension, or make sure "%s" is accessible.', 499 __METHOD__.'()', 500 '/dev/urandom', 501 '/dev/urandom')); 502 } 503 504 505 /** 506 * Read random alphanumeric characters from /dev/urandom or equivalent. This 507 * method operates like @{method:readRandomBytes} but produces alphanumeric 508 * output (a-z, 0-9) so it's appropriate for use in URIs and other contexts 509 * where it needs to be human readable. 510 * 511 * @param int Number of characters to read. 512 * @return string Random character string of the provided length. 513 * 514 * @task file 515 */ 516 public static function readRandomCharacters($number_of_characters) { 517 518 // NOTE: To produce the character string, we generate a random byte string 519 // of the same length, select the high 5 bits from each byte, and 520 // map that to 32 alphanumeric characters. This could be improved (we 521 // could improve entropy per character with base-62, and some entropy 522 // sources might be less entropic if we discard the low bits) but for 523 // reasonable cases where we have a good entropy source and are just 524 // generating some kind of human-readable secret this should be more than 525 // sufficient and is vastly simpler than trying to do bit fiddling. 526 527 $map = array_merge(range('a', 'z'), range('2', '7')); 528 529 $result = ''; 530 $bytes = self::readRandomBytes($number_of_characters); 531 for ($ii = 0; $ii < $number_of_characters; $ii++) { 532 $result .= $map[ord($bytes[$ii]) >> 3]; 533 } 534 535 return $result; 536 } 537 538 539 /** 540 * Generate a random integer value in a given range. 541 * 542 * This method uses less-entropic random sources under older versions of PHP. 543 * 544 * @param int Minimum value, inclusive. 545 * @param int Maximum value, inclusive. 546 */ 547 public static function readRandomInteger($min, $max) { 548 if (!is_int($min)) { 549 throw new Exception(pht('Minimum value must be an integer.')); 550 } 551 552 if (!is_int($max)) { 553 throw new Exception(pht('Maximum value must be an integer.')); 554 } 555 556 if ($min > $max) { 557 throw new Exception( 558 pht( 559 'Minimum ("%d") must not be greater than maximum ("%d").', 560 $min, 561 $max)); 562 } 563 564 // Under PHP 7.2.0 and newer, we can just use "random_int()". This function 565 // is intended to generate cryptographically usable entropy. 566 if (function_exists('random_int')) { 567 return random_int($min, $max); 568 } 569 570 // We could find a stronger source for this, but correctly converting raw 571 // bytes to an integer range without biases is fairly hard and it seems 572 // like we're more likely to get that wrong than suffer a PRNG prediction 573 // issue by falling back to "mt_rand()". 574 575 if (($max - $min) > mt_getrandmax()) { 576 throw new Exception( 577 pht('mt_rand() range is smaller than the requested range.')); 578 } 579 580 $result = mt_rand($min, $max); 581 if (!is_int($result)) { 582 throw new Exception(pht('Bad return value from mt_rand().')); 583 } 584 585 return $result; 586 } 587 588 589 /** 590 * Identify the MIME type of a file. This returns only the MIME type (like 591 * text/plain), not the encoding (like charset=utf-8). 592 * 593 * @param string Path to the file to examine. 594 * @param string Optional default mime type to return if the file's mime 595 * type can not be identified. 596 * @return string File mime type. 597 * 598 * @task file 599 * 600 * @phutil-external-symbol function mime_content_type 601 * @phutil-external-symbol function finfo_open 602 * @phutil-external-symbol function finfo_file 603 */ 604 public static function getMimeType( 605 $path, 606 $default = 'application/octet-stream') { 607 608 $path = self::resolvePath($path); 609 610 self::assertExists($path); 611 self::assertIsFile($path); 612 self::assertReadable($path); 613 614 $mime_type = null; 615 616 // Fileinfo is the best approach since it doesn't rely on `file`, but 617 // it isn't builtin for older versions of PHP. 618 619 if (function_exists('finfo_open')) { 620 $finfo = finfo_open(FILEINFO_MIME); 621 if ($finfo) { 622 $result = finfo_file($finfo, $path); 623 if ($result !== false) { 624 $mime_type = $result; 625 } 626 } 627 } 628 629 // If we failed Fileinfo, try `file`. This works well but not all systems 630 // have the binary. 631 632 if ($mime_type === null) { 633 list($err, $stdout) = exec_manual( 634 'file --brief --mime %s', 635 $path); 636 if (!$err) { 637 $mime_type = trim($stdout); 638 } 639 } 640 641 // If we didn't get anywhere, try the deprecated mime_content_type() 642 // function. 643 644 if ($mime_type === null) { 645 if (function_exists('mime_content_type')) { 646 $result = mime_content_type($path); 647 if ($result !== false) { 648 $mime_type = $result; 649 } 650 } 651 } 652 653 // If we come back with an encoding, strip it off. 654 if (strpos($mime_type, ';') !== false) { 655 list($type, $encoding) = explode(';', $mime_type, 2); 656 $mime_type = $type; 657 } 658 659 if ($mime_type === null) { 660 $mime_type = $default; 661 } 662 663 return $mime_type; 664 } 665 666 667/* -( Directories )-------------------------------------------------------- */ 668 669 670 /** 671 * Create a directory in a manner similar to mkdir(), but throw detailed 672 * exceptions on failure. 673 * 674 * @param string Path to directory. The parent directory must exist and 675 * be writable. 676 * @param int Permission umask. Note that umask is in octal, so you 677 * should specify it as, e.g., `0777', not `777'. 678 * @param boolean Recursively create directories. Default to false. 679 * @return string Path to the created directory. 680 * 681 * @task directory 682 */ 683 public static function createDirectory( 684 $path, 685 $umask = 0755, 686 $recursive = false) { 687 688 $path = self::resolvePath($path); 689 690 if (is_dir($path)) { 691 if ($umask) { 692 self::changePermissions($path, $umask); 693 } 694 return $path; 695 } 696 697 $dir = dirname($path); 698 if ($recursive && !file_exists($dir)) { 699 // Note: We could do this with the recursive third parameter of mkdir(), 700 // but then we loose the helpful FilesystemExceptions we normally get. 701 self::createDirectory($dir, $umask, true); 702 } 703 704 self::assertIsDirectory($dir); 705 self::assertExists($dir); 706 self::assertWritable($dir); 707 self::assertNotExists($path); 708 709 if (!mkdir($path, $umask)) { 710 throw new FilesystemException( 711 $path, 712 pht("Failed to create directory '%s'.", $path)); 713 } 714 715 // Need to change permissions explicitly because mkdir does something 716 // slightly different. mkdir(2) man page: 717 // 'The parameter mode specifies the permissions to use. It is modified by 718 // the process's umask in the usual way: the permissions of the created 719 // directory are (mode & ~umask & 0777)."' 720 if ($umask) { 721 self::changePermissions($path, $umask); 722 } 723 724 return $path; 725 } 726 727 728 /** 729 * Create a temporary directory and return the path to it. You are 730 * responsible for removing it (e.g., with Filesystem::remove()) 731 * when you are done with it. 732 * 733 * @param string Optional directory prefix. 734 * @param int Permissions to create the directory with. By default, 735 * these permissions are very restrictive (0700). 736 * @param string Optional root directory. If not provided, the system 737 * temporary directory (often "/tmp") will be used. 738 * @return string Path to newly created temporary directory. 739 * 740 * @task directory 741 */ 742 public static function createTemporaryDirectory( 743 $prefix = '', 744 $umask = 0700, 745 $root_directory = null) { 746 $prefix = preg_replace('/[^A-Z0-9._-]+/i', '', $prefix); 747 748 if ($root_directory !== null) { 749 $tmp = $root_directory; 750 self::assertExists($tmp); 751 self::assertIsDirectory($tmp); 752 self::assertWritable($tmp); 753 } else { 754 $tmp = sys_get_temp_dir(); 755 if (!$tmp) { 756 throw new FilesystemException( 757 $tmp, 758 pht('Unable to determine system temporary directory.')); 759 } 760 } 761 762 $base = $tmp.DIRECTORY_SEPARATOR.$prefix; 763 764 $tries = 3; 765 do { 766 $dir = $base.substr(base_convert(md5(mt_rand()), 16, 36), 0, 16); 767 try { 768 self::createDirectory($dir, $umask); 769 break; 770 } catch (FilesystemException $ex) { 771 // Ignore. 772 } 773 } while (--$tries); 774 775 if (!$tries) { 776 $df = disk_free_space($tmp); 777 if ($df !== false && $df < 1024 * 1024) { 778 throw new FilesystemException( 779 $dir, 780 pht('Failed to create a temporary directory: the disk is full.')); 781 } 782 783 throw new FilesystemException( 784 $dir, 785 pht("Failed to create a temporary directory in '%s'.", $tmp)); 786 } 787 788 return $dir; 789 } 790 791 792 /** 793 * List files in a directory. 794 * 795 * @param string Path, absolute or relative to PWD. 796 * @param bool If false, exclude files beginning with a ".". 797 * 798 * @return array List of files and directories in the specified 799 * directory, excluding `.' and `..'. 800 * 801 * @task directory 802 */ 803 public static function listDirectory($path, $include_hidden = true) { 804 $path = self::resolvePath($path); 805 806 self::assertExists($path); 807 self::assertIsDirectory($path); 808 self::assertReadable($path); 809 810 $list = @scandir($path); 811 if ($list === false) { 812 throw new FilesystemException( 813 $path, 814 pht("Unable to list contents of directory '%s'.", $path)); 815 } 816 817 foreach ($list as $k => $v) { 818 if ($v == '.' || $v == '..' || (!$include_hidden && $v[0] == '.')) { 819 unset($list[$k]); 820 } 821 } 822 823 return array_values($list); 824 } 825 826 827 /** 828 * Return all directories between a path and the specified root directory 829 * (defaulting to "/"). Iterating over them walks from the path to the root. 830 * 831 * @param string Path, absolute or relative to PWD. 832 * @param string The root directory. 833 * @return list<string> List of parent paths, including the provided path. 834 * @task directory 835 */ 836 public static function walkToRoot($path, $root = null) { 837 $path = self::resolvePath($path); 838 839 if (is_link($path)) { 840 $path = realpath($path); 841 } 842 843 // NOTE: On Windows, paths start like "C:\", so "/" does not contain 844 // every other path. We could possibly special case "/" to have the same 845 // meaning on Windows that it does on Linux, but just special case the 846 // common case for now. See PHI817. 847 if ($root !== null) { 848 $root = self::resolvePath($root); 849 850 if (is_link($root)) { 851 $root = realpath($root); 852 } 853 854 // NOTE: We don't use `isDescendant()` here because we don't want to 855 // reject paths which don't exist on disk. 856 $root_list = new FileList(array($root)); 857 if (!$root_list->contains($path)) { 858 return array(); 859 } 860 } else { 861 if (phutil_is_windows()) { 862 $root = null; 863 } else { 864 $root = '/'; 865 } 866 } 867 868 $walk = array(); 869 $parts = explode(DIRECTORY_SEPARATOR, $path); 870 foreach ($parts as $k => $part) { 871 if (!strlen($part)) { 872 unset($parts[$k]); 873 } 874 } 875 876 while (true) { 877 if (phutil_is_windows()) { 878 $next = implode(DIRECTORY_SEPARATOR, $parts); 879 } else { 880 $next = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts); 881 } 882 883 $walk[] = $next; 884 if ($next == $root) { 885 break; 886 } 887 888 if (!$parts) { 889 break; 890 } 891 892 array_pop($parts); 893 } 894 895 return $walk; 896 } 897 898 899/* -( Paths )-------------------------------------------------------------- */ 900 901 902 /** 903 * Checks if a path is specified as an absolute path. 904 * 905 * @param string 906 * @return bool 907 */ 908 public static function isAbsolutePath($path) { 909 if (phutil_is_windows()) { 910 return (bool)preg_match('/^[A-Za-z]+:/', $path); 911 } else { 912 return !strncmp($path, DIRECTORY_SEPARATOR, 1); 913 } 914 } 915 916 /** 917 * Canonicalize a path by resolving it relative to some directory (by 918 * default PWD), following parent symlinks and removing artifacts. If the 919 * path is itself a symlink it is left unresolved. 920 * 921 * @param string Path, absolute or relative to PWD. 922 * @return string Canonical, absolute path. 923 * 924 * @task path 925 */ 926 public static function resolvePath($path, $relative_to = null) { 927 $is_absolute = self::isAbsolutePath($path); 928 929 if (!$is_absolute) { 930 if (!$relative_to) { 931 $relative_to = getcwd(); 932 } 933 $path = $relative_to.DIRECTORY_SEPARATOR.$path; 934 } 935 936 if (is_link($path)) { 937 $parent_realpath = realpath(dirname($path)); 938 if ($parent_realpath !== false) { 939 return $parent_realpath.DIRECTORY_SEPARATOR.basename($path); 940 } 941 } 942 943 $realpath = realpath($path); 944 if ($realpath !== false) { 945 return $realpath; 946 } 947 948 949 // This won't work if the file doesn't exist or is on an unreadable mount 950 // or something crazy like that. Try to resolve a parent so we at least 951 // cover the nonexistent file case. 952 953 // We're also normalizing path separators to whatever is normal for the 954 // environment. 955 956 if (phutil_is_windows()) { 957 $parts = trim($path, '/\\'); 958 $parts = preg_split('([/\\\\])', $parts); 959 960 // Normalize the directory separators in the path. If we find a parent 961 // below, we'll overwrite this with a better resolved path. 962 $path = str_replace('/', '\\', $path); 963 } else { 964 $parts = trim($path, '/'); 965 $parts = explode('/', $parts); 966 } 967 968 while ($parts) { 969 array_pop($parts); 970 if (phutil_is_windows()) { 971 $attempt = implode(DIRECTORY_SEPARATOR, $parts); 972 } else { 973 $attempt = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts); 974 } 975 $realpath = realpath($attempt); 976 if ($realpath !== false) { 977 $path = $realpath.substr($path, strlen($attempt)); 978 break; 979 } 980 } 981 982 return $path; 983 } 984 985 /** 986 * Test whether a path is descendant from some root path after resolving all 987 * symlinks and removing artifacts. Both paths must exists for the relation 988 * to obtain. A path is always a descendant of itself as long as it exists. 989 * 990 * @param string Child path, absolute or relative to PWD. 991 * @param string Root path, absolute or relative to PWD. 992 * @return bool True if resolved child path is in fact a descendant of 993 * resolved root path and both exist. 994 * @task path 995 */ 996 public static function isDescendant($path, $root) { 997 try { 998 self::assertExists($path); 999 self::assertExists($root); 1000 } catch (FilesystemException $e) { 1001 return false; 1002 } 1003 $fs = new FileList(array($root)); 1004 return $fs->contains($path); 1005 } 1006 1007 /** 1008 * Convert a canonical path to its most human-readable format. It is 1009 * guaranteed that you can use resolvePath() to restore a path to its 1010 * canonical format. 1011 * 1012 * @param string Path, absolute or relative to PWD. 1013 * @param string Optionally, working directory to make files readable 1014 * relative to. 1015 * @return string Human-readable path. 1016 * 1017 * @task path 1018 */ 1019 public static function readablePath($path, $pwd = null) { 1020 if ($pwd === null) { 1021 $pwd = getcwd(); 1022 } 1023 1024 foreach (array($pwd, self::resolvePath($pwd)) as $parent) { 1025 $parent = rtrim($parent, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; 1026 $len = strlen($parent); 1027 if (!strncmp($parent, $path, $len)) { 1028 $path = substr($path, $len); 1029 return $path; 1030 } 1031 } 1032 1033 return $path; 1034 } 1035 1036 /** 1037 * Determine whether or not a path exists in the filesystem. This differs from 1038 * file_exists() in that it returns true for symlinks. This method does not 1039 * attempt to resolve paths before testing them. 1040 * 1041 * @param string Test for the existence of this path. 1042 * @return bool True if the path exists in the filesystem. 1043 * @task path 1044 */ 1045 public static function pathExists($path) { 1046 return file_exists($path) || is_link($path); 1047 } 1048 1049 1050 /** 1051 * Determine if an executable binary (like `git` or `svn`) exists within 1052 * the configured `$PATH`. 1053 * 1054 * @param string Binary name, like `'git'` or `'svn'`. 1055 * @return bool True if the binary exists and is executable. 1056 * @task exec 1057 */ 1058 public static function binaryExists($binary) { 1059 return self::resolveBinary($binary) !== null; 1060 } 1061 1062 1063 /** 1064 * Locates the full path that an executable binary (like `git` or `svn`) is at 1065 * the configured `$PATH`. 1066 * 1067 * @param string Binary name, like `'git'` or `'svn'`. 1068 * @return string The full binary path if it is present, or null. 1069 * @task exec 1070 */ 1071 public static function resolveBinary($binary) { 1072 if (phutil_is_windows()) { 1073 list($err, $stdout) = exec_manual('where %s', $binary); 1074 $stdout = phutil_split_lines($stdout); 1075 1076 // If `where %s` could not find anything, check for relative binary 1077 if ($err) { 1078 $path = self::resolvePath($binary); 1079 if (self::pathExists($path)) { 1080 return $path; 1081 } 1082 return null; 1083 } 1084 $stdout = head($stdout); 1085 } else { 1086 list($err, $stdout) = exec_manual('which %s', $binary); 1087 } 1088 1089 return $err === 0 ? trim($stdout) : null; 1090 } 1091 1092 1093 /** 1094 * Determine if two paths are equivalent by resolving symlinks. This is 1095 * different from resolving both paths and comparing them because 1096 * resolvePath() only resolves symlinks in parent directories, not the 1097 * path itself. 1098 * 1099 * @param string First path to test for equivalence. 1100 * @param string Second path to test for equivalence. 1101 * @return bool True if both paths are equivalent, i.e. reference the same 1102 * entity in the filesystem. 1103 * @task path 1104 */ 1105 public static function pathsAreEquivalent($u, $v) { 1106 $u = self::resolvePath($u); 1107 $v = self::resolvePath($v); 1108 1109 $real_u = realpath($u); 1110 $real_v = realpath($v); 1111 1112 if ($real_u) { 1113 $u = $real_u; 1114 } 1115 if ($real_v) { 1116 $v = $real_v; 1117 } 1118 return ($u == $v); 1119 } 1120 1121 public static function concatenatePaths(array $components) { 1122 $components = implode(DIRECTORY_SEPARATOR, $components); 1123 1124 // Replace any extra sequences of directory separators with a single 1125 // separator, so we don't end up with "path//to///thing.c". 1126 $components = preg_replace( 1127 '('.preg_quote(DIRECTORY_SEPARATOR).'{2,})', 1128 DIRECTORY_SEPARATOR, 1129 $components); 1130 1131 return $components; 1132 } 1133 1134/* -( Assert )------------------------------------------------------------- */ 1135 1136 1137 /** 1138 * Assert that something (e.g., a file, directory, or symlink) exists at a 1139 * specified location. 1140 * 1141 * @param string Assert that this path exists. 1142 * @return void 1143 * 1144 * @task assert 1145 */ 1146 public static function assertExists($path) { 1147 if (self::pathExists($path)) { 1148 return; 1149 } 1150 1151 // Before we claim that the path doesn't exist, try to find a parent we 1152 // don't have "+x" on. If we find one, tailor the error message so we don't 1153 // say "does not exist" in cases where the path does exist, we just don't 1154 // have permission to test its existence. 1155 foreach (self::walkToRoot($path) as $parent) { 1156 if (!self::pathExists($parent)) { 1157 continue; 1158 } 1159 1160 if (!is_dir($parent)) { 1161 continue; 1162 } 1163 1164 if (phutil_is_windows()) { 1165 // Do nothing. On Windows, there's no obvious equivalent to the 1166 // check below because "is_executable(...)" always appears to return 1167 // "false" for any directory. 1168 } else if (!is_executable($parent)) { 1169 // On Linux, note that we don't need read permission ("+r") on parent 1170 // directories to determine that a path exists, only execute ("+x"). 1171 throw new FilesystemException( 1172 $path, 1173 pht( 1174 'Filesystem path "%s" can not be accessed because a parent '. 1175 'directory ("%s") is not executable (the current process does '. 1176 'not have "+x" permission).', 1177 $path, 1178 $parent)); 1179 } 1180 } 1181 1182 throw new FilesystemException( 1183 $path, 1184 pht( 1185 'Filesystem path "%s" does not exist.', 1186 $path)); 1187 } 1188 1189 1190 /** 1191 * Assert that nothing exists at a specified location. 1192 * 1193 * @param string Assert that this path does not exist. 1194 * @return void 1195 * 1196 * @task assert 1197 */ 1198 public static function assertNotExists($path) { 1199 if (file_exists($path) || is_link($path)) { 1200 throw new FilesystemException( 1201 $path, 1202 pht("Path '%s' already exists!", $path)); 1203 } 1204 } 1205 1206 1207 /** 1208 * Assert that a path represents a file, strictly (i.e., not a directory). 1209 * 1210 * @param string Assert that this path is a file. 1211 * @return void 1212 * 1213 * @task assert 1214 */ 1215 public static function assertIsFile($path) { 1216 if (!is_file($path)) { 1217 throw new FilesystemException( 1218 $path, 1219 pht("Requested path '%s' is not a file.", $path)); 1220 } 1221 } 1222 1223 1224 /** 1225 * Assert that a path represents a directory, strictly (i.e., not a file). 1226 * 1227 * @param string Assert that this path is a directory. 1228 * @return void 1229 * 1230 * @task assert 1231 */ 1232 public static function assertIsDirectory($path) { 1233 if (!is_dir($path)) { 1234 throw new FilesystemException( 1235 $path, 1236 pht("Requested path '%s' is not a directory.", $path)); 1237 } 1238 } 1239 1240 1241 /** 1242 * Assert that a file or directory exists and is writable. 1243 * 1244 * @param string Assert that this path is writable. 1245 * @return void 1246 * 1247 * @task assert 1248 */ 1249 public static function assertWritable($path) { 1250 if (!is_writable($path)) { 1251 throw new FilesystemException( 1252 $path, 1253 pht("Requested path '%s' is not writable.", $path)); 1254 } 1255 } 1256 1257 1258 /** 1259 * Assert that a file or directory exists and is readable. 1260 * 1261 * @param string Assert that this path is readable. 1262 * @return void 1263 * 1264 * @task assert 1265 */ 1266 public static function assertReadable($path) { 1267 if (!is_readable($path)) { 1268 throw new FilesystemException( 1269 $path, 1270 pht("Path '%s' is not readable.", $path)); 1271 } 1272 } 1273 1274} 1275