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