PageRenderTime 59ms CodeModel.GetById 26ms RepoModel.GetById 0ms app.codeStats 1ms

/src/filesystem/Filesystem.php

https://github.com/navyuginfo/libphutil
PHP | 1066 lines | 787 code | 80 blank | 199 comment | 51 complexity | bbe0f1739c2629cead6ca6c8c4658f32 MD5 | raw file
Possible License(s): Apache-2.0
  1. <?php
  2. /**
  3. * Simple wrapper class for common filesystem tasks like reading and writing
  4. * files. When things go wrong, this class throws detailed exceptions with
  5. * good information about what didn't work.
  6. *
  7. * Filesystem will resolve relative paths against PWD from the environment.
  8. * When Filesystem is unable to complete an operation, it throws a
  9. * FilesystemException.
  10. *
  11. * @task directory Directories
  12. * @task file Files
  13. * @task path Paths
  14. * @task exec Executables
  15. * @task assert Assertions
  16. * @group filesystem
  17. */
  18. final class Filesystem {
  19. /* -( Files )-------------------------------------------------------------- */
  20. /**
  21. * Read a file in a manner similar to file_get_contents(), but throw detailed
  22. * exceptions on failure.
  23. *
  24. * @param string File path to read. This file must exist and be readable,
  25. * or an exception will be thrown.
  26. * @return string Contents of the specified file.
  27. *
  28. * @task file
  29. */
  30. public static function readFile($path) {
  31. $path = self::resolvePath($path);
  32. self::assertExists($path);
  33. self::assertIsFile($path);
  34. self::assertReadable($path);
  35. $data = @file_get_contents($path);
  36. if ($data === false) {
  37. throw new FilesystemException(
  38. $path,
  39. "Failed to read file `{$path}'.");
  40. }
  41. return $data;
  42. }
  43. /**
  44. * Make assertions about the state of path in preparation for
  45. * writeFile() and writeFileIfChanged().
  46. */
  47. private static function assertWritableFile($path) {
  48. $path = self::resolvePath($path);
  49. $dir = dirname($path);
  50. self::assertExists($dir);
  51. self::assertIsDirectory($dir);
  52. // File either needs to not exist and have a writable parent, or be
  53. // writable itself.
  54. $exists = true;
  55. try {
  56. self::assertNotExists($path);
  57. $exists = false;
  58. } catch (Exception $ex) {
  59. self::assertWritable($path);
  60. }
  61. if (!$exists) {
  62. self::assertWritable($dir);
  63. }
  64. }
  65. /**
  66. * Write a file in a manner similar to file_put_contents(), but throw
  67. * detailed exceptions on failure. If the file already exists, it will be
  68. * overwritten.
  69. *
  70. * @param string File path to write. This file must be writable and its
  71. * parent directory must exist.
  72. * @param string Data to write.
  73. *
  74. * @task file
  75. */
  76. public static function writeFile($path, $data) {
  77. self::assertWritableFile($path);
  78. if (@file_put_contents($path, $data) === false) {
  79. throw new FilesystemException(
  80. $path,
  81. "Failed to write file `{$path}'.");
  82. }
  83. }
  84. /**
  85. * Write a file in a manner similar to file_put_contents(), but only
  86. * touch the file if the contents are different, and throw detailed
  87. * exceptions on failure.
  88. *
  89. * As this function is used in build steps to update code, if we write
  90. * a new file, we do so by writing to a temporary file and moving it
  91. * into place. This allows a concurrently reading process to see
  92. * a consistent view of the file without needing locking; any given
  93. * read of the file is guaranteed to be self-consistent and not see
  94. * partial file contents.
  95. *
  96. * @param string file path to write
  97. * @param string data to write
  98. *
  99. * @return boolean indicating whether the file was changed by this
  100. * function
  101. */
  102. public static function writeFileIfChanged($path, $data) {
  103. if (file_exists($path)) {
  104. $current = self::readFile($path);
  105. if ($current === $data) {
  106. return false;
  107. }
  108. }
  109. self::assertWritableFile($path);
  110. // Create the temporary file alongside the intended destination,
  111. // as this ensures that the rename() will be atomic (on the same fs)
  112. $dir = dirname($path);
  113. $temp = tempnam($dir, 'GEN');
  114. if (!$temp) {
  115. throw new FilesystemException(
  116. $dir,
  117. "unable to create temporary file in $dir"
  118. );
  119. }
  120. try {
  121. self::writeFile($temp, $data);
  122. // tempnam will always restrict ownership to us, broaden
  123. // it so that these files respect the actual umask
  124. self::changePermissions($temp, 0666 & ~umask());
  125. // This will appear atomic to concurrent readers
  126. $ok = rename($temp, $path);
  127. if (!$ok) {
  128. throw new FilesystemException(
  129. $path,
  130. "unable to move $temp to $path"
  131. );
  132. }
  133. } catch (Exception $e) {
  134. // Make best effort to remove temp file
  135. unlink($temp);
  136. throw $e;
  137. }
  138. return true;
  139. }
  140. /**
  141. * Write data to unique file, without overwriting existing files. This is
  142. * useful if you want to write a ".bak" file or something similar, but want
  143. * to make sure you don't overwrite something already on disk.
  144. *
  145. * This function will add a number to the filename if the base name already
  146. * exists, e.g. "example.bak", "example.bak.1", "example.bak.2", etc. (Don't
  147. * rely on this exact behavior, of course.)
  148. *
  149. * @param string Suggested filename, like "example.bak". This name will
  150. * be used if it does not exist, or some similar name will
  151. * be chosen if it does.
  152. * @param string Data to write to the file.
  153. * @return string Path to a newly created and written file which did not
  154. * previously exist, like "example.bak.3".
  155. * @task file
  156. */
  157. public static function writeUniqueFile($base, $data) {
  158. $full_path = Filesystem::resolvePath($base);
  159. $sequence = 0;
  160. assert_stringlike($data);
  161. // Try 'file', 'file.1', 'file.2', etc., until something doesn't exist.
  162. while (true) {
  163. $try_path = $full_path;
  164. if ($sequence) {
  165. $try_path .= '.'.$sequence;
  166. }
  167. $handle = @fopen($try_path, 'x');
  168. if ($handle) {
  169. $ok = fwrite($handle, $data);
  170. if ($ok === false) {
  171. throw new FilesystemException(
  172. $try_path,
  173. pht('Failed to write file data.'));
  174. }
  175. $ok = fclose($handle);
  176. if (!$ok) {
  177. throw new FilesystemException(
  178. $try_path,
  179. pht('Failed to close file handle.'));
  180. }
  181. return $try_path;
  182. }
  183. $sequence++;
  184. }
  185. }
  186. /**
  187. * Append to a file without having to deal with file handles, with
  188. * detailed exceptions on failure.
  189. *
  190. * @param string File path to write. This file must be writable or its
  191. * parent directory must exist and be writable.
  192. * @param string Data to write.
  193. *
  194. * @task file
  195. */
  196. public static function appendFile($path, $data) {
  197. $path = self::resolvePath($path);
  198. // Use self::writeFile() if the file doesn't already exist
  199. try {
  200. self::assertExists($path);
  201. } catch (FilesystemException $ex) {
  202. self::writeFile($path, $data);
  203. return;
  204. }
  205. // File needs to exist or the directory needs to be writable
  206. $dir = dirname($path);
  207. self::assertExists($dir);
  208. self::assertIsDirectory($dir);
  209. self::assertWritable($dir);
  210. assert_stringlike($data);
  211. if (($fh = fopen($path, 'a')) === false) {
  212. throw new FilesystemException(
  213. $path, "Failed to open file `{$path}'.");
  214. }
  215. $dlen = strlen($data);
  216. if (fwrite($fh, $data) !== $dlen) {
  217. throw new FilesystemException(
  218. $path,
  219. "Failed to write {$dlen} bytes to `{$path}'.");
  220. }
  221. if (!fflush($fh) || !fclose($fh)) {
  222. throw new FilesystemException(
  223. $path,
  224. "Failed closing file `{$path}' after write.");
  225. }
  226. }
  227. /**
  228. * Remove a file or directory.
  229. *
  230. * @param string File to a path or directory to remove.
  231. * @return void
  232. *
  233. * @task file
  234. */
  235. public static function remove($path) {
  236. if (!strlen($path)) {
  237. // Avoid removing PWD.
  238. throw new Exception('No path provided to remove().');
  239. }
  240. $path = self::resolvePath($path);
  241. if (!file_exists($path)) {
  242. return;
  243. }
  244. self::executeRemovePath($path);
  245. }
  246. /**
  247. * Rename a file or directory.
  248. *
  249. * @param string Old path.
  250. * @param string New path.
  251. *
  252. * @task file
  253. */
  254. public static function rename($old, $new) {
  255. $old = self::resolvePath($old);
  256. $new = self::resolvePath($new);
  257. self::assertExists($old);
  258. $ok = rename($old, $new);
  259. if (!$ok) {
  260. throw new FilesystemException(
  261. $new,
  262. "Failed to rename '{$old}' to '{$new}'!");
  263. }
  264. }
  265. /**
  266. * Internal. Recursively remove a file or an entire directory. Implements
  267. * the core function of @{method:remove} in a way that works on Windows.
  268. *
  269. * @param string File to a path or directory to remove.
  270. * @return void
  271. *
  272. * @task file
  273. */
  274. private static function executeRemovePath($path) {
  275. if (is_dir($path) && !is_link($path)) {
  276. foreach (Filesystem::listDirectory($path, true) as $child) {
  277. self::executeRemovePath($path.DIRECTORY_SEPARATOR.$child);
  278. }
  279. $ok = rmdir($path);
  280. if (!$ok) {
  281. throw new FilesystemException(
  282. $path,
  283. "Failed to remove directory '{$path}'!");
  284. }
  285. } else {
  286. $ok = unlink($path);
  287. if (!$ok) {
  288. throw new FilesystemException(
  289. $path,
  290. "Failed to remove file '{$path}'!");
  291. }
  292. }
  293. }
  294. /**
  295. * Change the permissions of a file or directory.
  296. *
  297. * @param string Path to the file or directory.
  298. * @param int Permission umask. Note that umask is in octal, so you
  299. * should specify it as, e.g., `0777', not `777'.
  300. * @return void
  301. *
  302. * @task file
  303. */
  304. public static function changePermissions($path, $umask) {
  305. $path = self::resolvePath($path);
  306. self::assertExists($path);
  307. if (!@chmod($path, $umask)) {
  308. $readable_umask = sprintf('%04o', $umask);
  309. throw new FilesystemException(
  310. $path, "Failed to chmod `{$path}' to `{$readable_umask}'.");
  311. }
  312. }
  313. /**
  314. * Get the last modified time of a file
  315. *
  316. * @param string Path to file
  317. * @return Time last modified
  318. *
  319. * @task file
  320. */
  321. public static function getModifiedTime($path) {
  322. $path = self::resolvePath($path);
  323. self::assertExists($path);
  324. self::assertIsFile($path);
  325. self::assertReadable($path);
  326. $modified_time = @filemtime($path);
  327. if ($modified_time === false) {
  328. throw new FilesystemException(
  329. $path,
  330. 'Failed to read modified time for '.$path);
  331. }
  332. return $modified_time;
  333. }
  334. /**
  335. * Read random bytes from /dev/urandom or equivalent. See also
  336. * @{method:readRandomCharacters}.
  337. *
  338. * @param int Number of bytes to read.
  339. * @return string Random bytestring of the provided length.
  340. *
  341. * @task file
  342. */
  343. public static function readRandomBytes($number_of_bytes) {
  344. $number_of_bytes = (int)$number_of_bytes;
  345. if ($number_of_bytes < 1) {
  346. throw new Exception(pht('You must generate at least 1 byte of entropy.'));
  347. }
  348. // Try to use `openssl_random_psuedo_bytes()` if it's available. This source
  349. // is the most widely available source, and works on Windows/Linux/OSX/etc.
  350. if (function_exists('openssl_random_pseudo_bytes')) {
  351. $strong = true;
  352. $data = openssl_random_pseudo_bytes($number_of_bytes, $strong);
  353. if (!$strong) {
  354. // NOTE: This indicates we're using a weak random source. This is
  355. // probably OK, but maybe we should be more strict here.
  356. }
  357. if ($data === false) {
  358. throw new Exception(
  359. pht('openssl_random_pseudo_bytes() failed to generate entropy!'));
  360. }
  361. if (strlen($data) != $number_of_bytes) {
  362. throw new Exception(
  363. pht(
  364. 'openssl_random_pseudo_bytes() returned an unexpected number of '.
  365. 'bytes (got %d, expected %d)!',
  366. strlen($data),
  367. $number_of_bytes));
  368. }
  369. return $data;
  370. }
  371. // Try to use `/dev/urandom` if it's available. This is usually available
  372. // on non-Windows systems, but some PHP config (open_basedir) and chrooting
  373. // may limit our access to it.
  374. $urandom = @fopen('/dev/urandom', 'rb');
  375. if ($urandom) {
  376. $data = @fread($urandom, $number_of_bytes);
  377. @fclose($urandom);
  378. if (strlen($data) != $number_of_bytes) {
  379. throw new FilesystemException(
  380. '/dev/urandom',
  381. 'Failed to read random bytes!');
  382. }
  383. return $data;
  384. }
  385. // (We might be able to try to generate entropy here from a weaker source
  386. // if neither of the above sources panned out, see some discussion in
  387. // T4153.)
  388. // We've failed to find any valid entropy source. Try to fail in the most
  389. // useful way we can, based on the platform.
  390. if (phutil_is_windows()) {
  391. throw new Exception(
  392. pht(
  393. 'Filesystem::readRandomBytes() requires the PHP OpenSSL extension '.
  394. 'to be installed and enabled to access an entropy source. On '.
  395. 'Windows, this extension is usually installed but not enabled by '.
  396. 'default. Enable it in your "php.ini".'));
  397. }
  398. throw new Exception(
  399. pht(
  400. 'Filesystem::readRandomBytes() requires the PHP OpenSSL extension '.
  401. 'or access to "/dev/urandom". Install or enable the OpenSSL '.
  402. 'extension, or make sure "/dev/urandom" is accessible.'));
  403. }
  404. /**
  405. * Read random alphanumeric characters from /dev/urandom or equivalent. This
  406. * method operates like @{method:readRandomBytes} but produces alphanumeric
  407. * output (a-z, 0-9) so it's appropriate for use in URIs and other contexts
  408. * where it needs to be human readable.
  409. *
  410. * @param int Number of characters to read.
  411. * @return string Random character string of the provided length.
  412. *
  413. * @task file
  414. */
  415. public static function readRandomCharacters($number_of_characters) {
  416. // NOTE: To produce the character string, we generate a random byte string
  417. // of the same length, select the high 5 bits from each byte, and
  418. // map that to 32 alphanumeric characters. This could be improved (we
  419. // could improve entropy per character with base-62, and some entropy
  420. // sources might be less entropic if we discard the low bits) but for
  421. // reasonable cases where we have a good entropy source and are just
  422. // generating some kind of human-readable secret this should be more than
  423. // sufficient and is vastly simpler than trying to do bit fiddling.
  424. $map = array_merge(range('a', 'z'), range('2', '7'));
  425. $result = '';
  426. $bytes = self::readRandomBytes($number_of_characters);
  427. for ($ii = 0; $ii < $number_of_characters; $ii++) {
  428. $result .= $map[ord($bytes[$ii]) >> 3];
  429. }
  430. return $result;
  431. }
  432. /**
  433. * Identify the MIME type of a file. This returns only the MIME type (like
  434. * text/plain), not the encoding (like charset=utf-8).
  435. *
  436. * @param string Path to the file to examine.
  437. * @param string Optional default mime type to return if the file's mime
  438. * type can not be identified.
  439. * @return string File mime type.
  440. *
  441. * @task file
  442. *
  443. * @phutil-external-symbol function mime_content_type
  444. * @phutil-external-symbol function finfo_open
  445. * @phutil-external-symbol function finfo_file
  446. */
  447. public static function getMimeType(
  448. $path,
  449. $default = 'application/octet-stream') {
  450. $path = self::resolvePath($path);
  451. self::assertExists($path);
  452. self::assertIsFile($path);
  453. self::assertReadable($path);
  454. $mime_type = null;
  455. // Fileinfo is the best approach since it doesn't rely on `file`, but
  456. // it isn't builtin for older versions of PHP.
  457. if (function_exists('finfo_open')) {
  458. $finfo = finfo_open(FILEINFO_MIME);
  459. if ($finfo) {
  460. $result = finfo_file($finfo, $path);
  461. if ($result !== false) {
  462. $mime_type = $result;
  463. }
  464. }
  465. }
  466. // If we failed Fileinfo, try `file`. This works well but not all systems
  467. // have the binary.
  468. if ($mime_type === null) {
  469. list($err, $stdout) = exec_manual(
  470. 'file --brief --mime %s',
  471. $path);
  472. if (!$err) {
  473. $mime_type = trim($stdout);
  474. }
  475. }
  476. // If we didn't get anywhere, try the deprecated mime_content_type()
  477. // function.
  478. if ($mime_type === null) {
  479. if (function_exists('mime_content_type')) {
  480. $result = mime_content_type($path);
  481. if ($result !== false) {
  482. $mime_type = $result;
  483. }
  484. }
  485. }
  486. // If we come back with an encoding, strip it off.
  487. if (strpos($mime_type, ';') !== false) {
  488. list($type, $encoding) = explode(';', $mime_type, 2);
  489. $mime_type = $type;
  490. }
  491. if ($mime_type === null) {
  492. $mime_type = $default;
  493. }
  494. return $mime_type;
  495. }
  496. /* -( Directories )-------------------------------------------------------- */
  497. /**
  498. * Create a directory in a manner similar to mkdir(), but throw detailed
  499. * exceptions on failure.
  500. *
  501. * @param string Path to directory. The parent directory must exist and
  502. * be writable.
  503. * @param int Permission umask. Note that umask is in octal, so you
  504. * should specify it as, e.g., `0777', not `777'.
  505. * @param boolean Recursively create directories. Default to false.
  506. * @return string Path to the created directory.
  507. *
  508. * @task directory
  509. */
  510. public static function createDirectory($path, $umask = 0755,
  511. $recursive = false) {
  512. $path = self::resolvePath($path);
  513. if (is_dir($path)) {
  514. if ($umask) {
  515. Filesystem::changePermissions($path, $umask);
  516. }
  517. return $path;
  518. }
  519. $dir = dirname($path);
  520. if ($recursive && !file_exists($dir)) {
  521. // Note: We could do this with the recursive third parameter of mkdir(),
  522. // but then we loose the helpful FilesystemExceptions we normally get.
  523. self::createDirectory($dir, $umask, true);
  524. }
  525. self::assertIsDirectory($dir);
  526. self::assertExists($dir);
  527. self::assertWritable($dir);
  528. self::assertNotExists($path);
  529. if (!mkdir($path, $umask)) {
  530. throw new FilesystemException(
  531. $path,
  532. "Failed to create directory `{$path}'.");
  533. }
  534. // Need to change premissions explicitly because mkdir does something
  535. // slightly different. mkdir(2) man page:
  536. // 'The parameter mode specifies the permissions to use. It is modified by
  537. // the process's umask in the usual way: the permissions of the created
  538. // directory are (mode & ~umask & 0777)."'
  539. if ($umask) {
  540. Filesystem::changePermissions($path, $umask);
  541. }
  542. return $path;
  543. }
  544. /**
  545. * Create a temporary directory and return the path to it. You are
  546. * responsible for removing it (e.g., with Filesystem::remove())
  547. * when you are done with it.
  548. *
  549. * @param string Optional directory prefix.
  550. * @param int Permissions to create the directory with. By default,
  551. * these permissions are very restrictive (0700).
  552. * @return string Path to newly created temporary directory.
  553. *
  554. * @task directory
  555. */
  556. public static function createTemporaryDirectory($prefix = '', $umask = 0700) {
  557. $prefix = preg_replace('/[^A-Z0-9._-]+/i', '', $prefix);
  558. $tmp = sys_get_temp_dir();
  559. if (!$tmp) {
  560. throw new FilesystemException(
  561. $tmp, 'Unable to determine system temporary directory.');
  562. }
  563. $base = $tmp.DIRECTORY_SEPARATOR.$prefix;
  564. $tries = 3;
  565. do {
  566. $dir = $base.substr(base_convert(md5(mt_rand()), 16, 36), 0, 16);
  567. try {
  568. self::createDirectory($dir, $umask);
  569. break;
  570. } catch (FilesystemException $ex) {
  571. // Ignore.
  572. }
  573. } while (--$tries);
  574. if (!$tries) {
  575. $df = disk_free_space($tmp);
  576. if ($df !== false && $df < 1024 * 1024) {
  577. throw new FilesystemException(
  578. $dir,
  579. pht('Failed to create a temporary directory: the disk is full.'));
  580. }
  581. throw new FilesystemException(
  582. $dir,
  583. pht("Failed to create a temporary directory in '%s'.", $tmp));
  584. }
  585. return $dir;
  586. }
  587. /**
  588. * List files in a directory.
  589. *
  590. * @param string Path, absolute or relative to PWD.
  591. * @param bool If false, exclude files beginning with a ".".
  592. *
  593. * @return array List of files and directories in the specified
  594. * directory, excluding `.' and `..'.
  595. *
  596. * @task directory
  597. */
  598. public static function listDirectory($path, $include_hidden = true) {
  599. $path = self::resolvePath($path);
  600. self::assertExists($path);
  601. self::assertIsDirectory($path);
  602. self::assertReadable($path);
  603. $list = @scandir($path);
  604. if ($list === false) {
  605. throw new FilesystemException(
  606. $path,
  607. "Unable to list contents of directory `{$path}'.");
  608. }
  609. foreach ($list as $k => $v) {
  610. if ($v == '.' || $v == '..' || (!$include_hidden && $v[0] == '.')) {
  611. unset($list[$k]);
  612. }
  613. }
  614. return array_values($list);
  615. }
  616. /**
  617. * Return all directories between a path and "/". Iterating over them walks
  618. * from the path to the root.
  619. *
  620. * @param string Path, absolute or relative to PWD.
  621. * @return list List of parent paths, including the provided path.
  622. * @task directory
  623. */
  624. public static function walkToRoot($path) {
  625. $path = self::resolvePath($path);
  626. if (is_link($path)) {
  627. $path = realpath($path);
  628. }
  629. $walk = array();
  630. $parts = explode(DIRECTORY_SEPARATOR, $path);
  631. foreach ($parts as $k => $part) {
  632. if (!strlen($part)) {
  633. unset($parts[$k]);
  634. }
  635. }
  636. do {
  637. if (phutil_is_windows()) {
  638. $walk[] = implode(DIRECTORY_SEPARATOR, $parts);
  639. } else {
  640. $walk[] = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts);
  641. }
  642. if (empty($parts)) {
  643. break;
  644. }
  645. array_pop($parts);
  646. } while (true);
  647. return $walk;
  648. }
  649. /* -( Paths )-------------------------------------------------------------- */
  650. /**
  651. * Canonicalize a path by resolving it relative to some directory (by
  652. * default PWD), following parent symlinks and removing artifacts. If the
  653. * path is itself a symlink it is left unresolved.
  654. *
  655. * @param string Path, absolute or relative to PWD.
  656. * @return string Canonical, absolute path.
  657. *
  658. * @task path
  659. */
  660. public static function resolvePath($path, $relative_to = null) {
  661. if (phutil_is_windows()) {
  662. $is_absolute = preg_match('/^[A-Za-z]+:/', $path);
  663. } else {
  664. $is_absolute = !strncmp($path, DIRECTORY_SEPARATOR, 1);
  665. }
  666. if (!$is_absolute) {
  667. if (!$relative_to) {
  668. $relative_to = getcwd();
  669. }
  670. $path = $relative_to.DIRECTORY_SEPARATOR.$path;
  671. }
  672. if (is_link($path)) {
  673. $parent_realpath = realpath(dirname($path));
  674. if ($parent_realpath !== false) {
  675. return $parent_realpath.DIRECTORY_SEPARATOR.basename($path);
  676. }
  677. }
  678. $realpath = realpath($path);
  679. if ($realpath !== false) {
  680. return $realpath;
  681. }
  682. // This won't work if the file doesn't exist or is on an unreadable mount
  683. // or something crazy like that. Try to resolve a parent so we at least
  684. // cover the nonexistent file case.
  685. $parts = explode(DIRECTORY_SEPARATOR, trim($path, DIRECTORY_SEPARATOR));
  686. while (end($parts) !== false) {
  687. array_pop($parts);
  688. if (phutil_is_windows()) {
  689. $attempt = implode(DIRECTORY_SEPARATOR, $parts);
  690. } else {
  691. $attempt = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts);
  692. }
  693. $realpath = realpath($attempt);
  694. if ($realpath !== false) {
  695. $path = $realpath.substr($path, strlen($attempt));
  696. break;
  697. }
  698. }
  699. return $path;
  700. }
  701. /**
  702. * Test whether a path is descendant from some root path after resolving all
  703. * symlinks and removing artifacts. Both paths must exists for the relation
  704. * to obtain. A path is always a descendant of itself as long as it exists.
  705. *
  706. * @param string Child path, absolute or relative to PWD.
  707. * @param string Root path, absolute or relative to PWD.
  708. * @return bool True if resolved child path is in fact a descendant of
  709. * resolved root path and both exist.
  710. * @task path
  711. */
  712. public static function isDescendant($path, $root) {
  713. try {
  714. self::assertExists($path);
  715. self::assertExists($root);
  716. } catch (FilesystemException $e) {
  717. return false;
  718. }
  719. $fs = new FileList(array($root));
  720. return $fs->contains($path);
  721. }
  722. /**
  723. * Convert a canonical path to its most human-readable format. It is
  724. * guaranteed that you can use resolvePath() to restore a path to its
  725. * canonical format.
  726. *
  727. * @param string Path, absolute or relative to PWD.
  728. * @param string Optionally, working directory to make files readable
  729. * relative to.
  730. * @return string Human-readable path.
  731. *
  732. * @task path
  733. */
  734. public static function readablePath($path, $pwd = null) {
  735. if ($pwd === null) {
  736. $pwd = getcwd();
  737. }
  738. foreach (array($pwd, self::resolvePath($pwd)) as $parent) {
  739. $parent = rtrim($parent, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
  740. $len = strlen($parent);
  741. if (!strncmp($parent, $path, $len)) {
  742. $path = substr($path, $len);
  743. return $path;
  744. }
  745. }
  746. return $path;
  747. }
  748. /**
  749. * Determine whether or not a path exists in the filesystem. This differs from
  750. * file_exists() in that it returns true for symlinks. This method does not
  751. * attempt to resolve paths before testing them.
  752. *
  753. * @param string Test for the existence of this path.
  754. * @return bool True if the path exists in the filesystem.
  755. * @task path
  756. */
  757. public static function pathExists($path) {
  758. return file_exists($path) || is_link($path);
  759. }
  760. /**
  761. * Determine if an executable binary (like `git` or `svn`) exists within
  762. * the configured `$PATH`.
  763. *
  764. * @param string Binary name, like `'git'` or `'svn'`.
  765. * @return bool True if the binary exists and is executable.
  766. * @task exec
  767. */
  768. public static function binaryExists($binary) {
  769. return self::resolveBinary($binary) !== null;
  770. }
  771. /**
  772. * Locates the full path that an executable binary (like `git` or `svn`) is at
  773. * the configured `$PATH`.
  774. *
  775. * @param string Binary name, like `'git'` or `'svn'`.
  776. * @return string The full binary path if it is present, or null.
  777. * @task exec
  778. */
  779. public static function resolveBinary($binary) {
  780. if (phutil_is_windows()) {
  781. list($err, $stdout) = exec_manual('where %s', $binary);
  782. $stdout = phutil_split_lines($stdout);
  783. // If `where %s` could not find anything, check for relative binary
  784. if ($err) {
  785. $path = Filesystem::resolvePath($binary);
  786. if (Filesystem::pathExists($path)) {
  787. return $path;
  788. }
  789. return null;
  790. }
  791. $stdout = head($stdout);
  792. } else {
  793. list($err, $stdout) = exec_manual('which %s', $binary);
  794. }
  795. return $err === 0 ? trim($stdout) : null;
  796. }
  797. /**
  798. * Determine if two paths are equivalent by resolving symlinks. This is
  799. * different from resolving both paths and comparing them because
  800. * resolvePath() only resolves symlinks in parent directories, not the
  801. * path itself.
  802. *
  803. * @param string First path to test for equivalence.
  804. * @param string Second path to test for equivalence.
  805. * @return bool True if both paths are equivalent, i.e. reference the same
  806. * entity in the filesystem.
  807. * @task path
  808. */
  809. public static function pathsAreEquivalent($u, $v) {
  810. $u = Filesystem::resolvePath($u);
  811. $v = Filesystem::resolvePath($v);
  812. $real_u = realpath($u);
  813. $real_v = realpath($v);
  814. if ($real_u) {
  815. $u = $real_u;
  816. }
  817. if ($real_v) {
  818. $v = $real_v;
  819. }
  820. return ($u == $v);
  821. }
  822. /* -( Assert )------------------------------------------------------------- */
  823. /**
  824. * Assert that something (e.g., a file, directory, or symlink) exists at a
  825. * specified location.
  826. *
  827. * @param string Assert that this path exists.
  828. * @return void
  829. *
  830. * @task assert
  831. */
  832. public static function assertExists($path) {
  833. if (!self::pathExists($path)) {
  834. throw new FilesystemException(
  835. $path,
  836. "Filesystem entity `{$path}' does not exist.");
  837. }
  838. }
  839. /**
  840. * Assert that nothing exists at a specified location.
  841. *
  842. * @param string Assert that this path does not exist.
  843. * @return void
  844. *
  845. * @task assert
  846. */
  847. public static function assertNotExists($path) {
  848. if (file_exists($path) || is_link($path)) {
  849. throw new FilesystemException(
  850. $path,
  851. "Path `{$path}' already exists!");
  852. }
  853. }
  854. /**
  855. * Assert that a path represents a file, strictly (i.e., not a directory).
  856. *
  857. * @param string Assert that this path is a file.
  858. * @return void
  859. *
  860. * @task assert
  861. */
  862. public static function assertIsFile($path) {
  863. if (!is_file($path)) {
  864. throw new FilesystemException(
  865. $path,
  866. "Requested path `{$path}' is not a file.");
  867. }
  868. }
  869. /**
  870. * Assert that a path represents a directory, strictly (i.e., not a file).
  871. *
  872. * @param string Assert that this path is a directory.
  873. * @return void
  874. *
  875. * @task assert
  876. */
  877. public static function assertIsDirectory($path) {
  878. if (!is_dir($path)) {
  879. throw new FilesystemException(
  880. $path,
  881. "Requested path `{$path}' is not a directory.");
  882. }
  883. }
  884. /**
  885. * Assert that a file or directory exists and is writable.
  886. *
  887. * @param string Assert that this path is writable.
  888. * @return void
  889. *
  890. * @task assert
  891. */
  892. public static function assertWritable($path) {
  893. if (!is_writable($path)) {
  894. throw new FilesystemException(
  895. $path,
  896. "Requested path `{$path}' is not writable.");
  897. }
  898. }
  899. /**
  900. * Assert that a file or directory exists and is readable.
  901. *
  902. * @param string Assert that this path is readable.
  903. * @return void
  904. *
  905. * @task assert
  906. */
  907. public static function assertReadable($path) {
  908. if (!is_readable($path)) {
  909. throw new FilesystemException(
  910. $path,
  911. "Path `{$path}' is not readable.");
  912. }
  913. }
  914. }