Changeset View
Changeset View
Standalone View
Standalone View
src/filesystem/Filesystem.php
- This file was added.
| <?php | |||||
| /** | |||||
| * Simple wrapper class for common filesystem tasks like reading and writing | |||||
| * files. When things go wrong, this class throws detailed exceptions with | |||||
| * good information about what didn't work. | |||||
| * | |||||
| * Filesystem will resolve relative paths against PWD from the environment. | |||||
| * When Filesystem is unable to complete an operation, it throws a | |||||
| * FilesystemException. | |||||
| * | |||||
| * @task directory Directories | |||||
| * @task file Files | |||||
| * @task path Paths | |||||
| * @task exec Executables | |||||
| * @task assert Assertions | |||||
| */ | |||||
| final class Filesystem extends Phobject { | |||||
| /* -( Files )-------------------------------------------------------------- */ | |||||
| /** | |||||
| * Read a file in a manner similar to file_get_contents(), but throw detailed | |||||
| * exceptions on failure. | |||||
| * | |||||
| * @param string File path to read. This file must exist and be readable, | |||||
| * or an exception will be thrown. | |||||
| * @return string Contents of the specified file. | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function readFile($path) { | |||||
| $path = self::resolvePath($path); | |||||
| self::assertExists($path); | |||||
| self::assertIsFile($path); | |||||
| self::assertReadable($path); | |||||
| $data = @file_get_contents($path); | |||||
| if ($data === false) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to read file '%s'.", $path)); | |||||
| } | |||||
| return $data; | |||||
| } | |||||
| /** | |||||
| * Make assertions about the state of path in preparation for | |||||
| * writeFile() and writeFileIfChanged(). | |||||
| */ | |||||
| private static function assertWritableFile($path) { | |||||
| $path = self::resolvePath($path); | |||||
| $dir = dirname($path); | |||||
| self::assertExists($dir); | |||||
| self::assertIsDirectory($dir); | |||||
| // File either needs to not exist and have a writable parent, or be | |||||
| // writable itself. | |||||
| $exists = true; | |||||
| try { | |||||
| self::assertNotExists($path); | |||||
| $exists = false; | |||||
| } catch (Exception $ex) { | |||||
| self::assertWritable($path); | |||||
| } | |||||
| if (!$exists) { | |||||
| self::assertWritable($dir); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Write a file in a manner similar to file_put_contents(), but throw | |||||
| * detailed exceptions on failure. If the file already exists, it will be | |||||
| * overwritten. | |||||
| * | |||||
| * @param string File path to write. This file must be writable and its | |||||
| * parent directory must exist. | |||||
| * @param string Data to write. | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function writeFile($path, $data) { | |||||
| self::assertWritableFile($path); | |||||
| if (@file_put_contents($path, $data) === false) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to write file '%s'.", $path)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Write a file in a manner similar to `file_put_contents()`, but only touch | |||||
| * the file if the contents are different, and throw detailed exceptions on | |||||
| * failure. | |||||
| * | |||||
| * As this function is used in build steps to update code, if we write a new | |||||
| * file, we do so by writing to a temporary file and moving it into place. | |||||
| * This allows a concurrently reading process to see a consistent view of the | |||||
| * file without needing locking; any given read of the file is guaranteed to | |||||
| * be self-consistent and not see partial file contents. | |||||
| * | |||||
| * @param string file path to write | |||||
| * @param string data to write | |||||
| * | |||||
| * @return boolean indicating whether the file was changed by this function. | |||||
| */ | |||||
| public static function writeFileIfChanged($path, $data) { | |||||
| if (file_exists($path)) { | |||||
| $current = self::readFile($path); | |||||
| if ($current === $data) { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| self::assertWritableFile($path); | |||||
| // Create the temporary file alongside the intended destination, | |||||
| // as this ensures that the rename() will be atomic (on the same fs) | |||||
| $dir = dirname($path); | |||||
| $temp = tempnam($dir, 'GEN'); | |||||
| if (!$temp) { | |||||
| throw new FilesystemException( | |||||
| $dir, | |||||
| pht('Unable to create temporary file in %s.', $dir)); | |||||
| } | |||||
| try { | |||||
| self::writeFile($temp, $data); | |||||
| // tempnam will always restrict ownership to us, broaden | |||||
| // it so that these files respect the actual umask | |||||
| self::changePermissions($temp, 0666 & ~umask()); | |||||
| // This will appear atomic to concurrent readers | |||||
| $ok = rename($temp, $path); | |||||
| if (!$ok) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht('Unable to move %s to %s.', $temp, $path)); | |||||
| } | |||||
| } catch (Exception $e) { | |||||
| // Make best effort to remove temp file | |||||
| unlink($temp); | |||||
| throw $e; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| /** | |||||
| * Write data to unique file, without overwriting existing files. This is | |||||
| * useful if you want to write a ".bak" file or something similar, but want | |||||
| * to make sure you don't overwrite something already on disk. | |||||
| * | |||||
| * This function will add a number to the filename if the base name already | |||||
| * exists, e.g. "example.bak", "example.bak.1", "example.bak.2", etc. (Don't | |||||
| * rely on this exact behavior, of course.) | |||||
| * | |||||
| * @param string Suggested filename, like "example.bak". This name will | |||||
| * be used if it does not exist, or some similar name will | |||||
| * be chosen if it does. | |||||
| * @param string Data to write to the file. | |||||
| * @return string Path to a newly created and written file which did not | |||||
| * previously exist, like "example.bak.3". | |||||
| * @task file | |||||
| */ | |||||
| public static function writeUniqueFile($base, $data) { | |||||
| $full_path = self::resolvePath($base); | |||||
| $sequence = 0; | |||||
| assert_stringlike($data); | |||||
| // Try 'file', 'file.1', 'file.2', etc., until something doesn't exist. | |||||
| while (true) { | |||||
| $try_path = $full_path; | |||||
| if ($sequence) { | |||||
| $try_path .= '.'.$sequence; | |||||
| } | |||||
| $handle = @fopen($try_path, 'x'); | |||||
| if ($handle) { | |||||
| $ok = fwrite($handle, $data); | |||||
| if ($ok === false) { | |||||
| throw new FilesystemException( | |||||
| $try_path, | |||||
| pht('Failed to write file data.')); | |||||
| } | |||||
| $ok = fclose($handle); | |||||
| if (!$ok) { | |||||
| throw new FilesystemException( | |||||
| $try_path, | |||||
| pht('Failed to close file handle.')); | |||||
| } | |||||
| return $try_path; | |||||
| } | |||||
| $sequence++; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Append to a file without having to deal with file handles, with | |||||
| * detailed exceptions on failure. | |||||
| * | |||||
| * @param string File path to write. This file must be writable or its | |||||
| * parent directory must exist and be writable. | |||||
| * @param string Data to write. | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function appendFile($path, $data) { | |||||
| $path = self::resolvePath($path); | |||||
| // Use self::writeFile() if the file doesn't already exist | |||||
| try { | |||||
| self::assertExists($path); | |||||
| } catch (FilesystemException $ex) { | |||||
| self::writeFile($path, $data); | |||||
| return; | |||||
| } | |||||
| // File needs to exist or the directory needs to be writable | |||||
| $dir = dirname($path); | |||||
| self::assertExists($dir); | |||||
| self::assertIsDirectory($dir); | |||||
| self::assertWritable($dir); | |||||
| assert_stringlike($data); | |||||
| if (($fh = fopen($path, 'a')) === false) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to open file '%s'.", $path)); | |||||
| } | |||||
| $dlen = strlen($data); | |||||
| if (fwrite($fh, $data) !== $dlen) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to write %d bytes to '%s'.", $dlen, $path)); | |||||
| } | |||||
| if (!fflush($fh) || !fclose($fh)) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed closing file '%s' after write.", $path)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Copy a file, preserving file attributes (if relevant for the OS). | |||||
| * | |||||
| * @param string File path to copy from. This file must exist and be | |||||
| * readable, or an exception will be thrown. | |||||
| * @param string File path to copy to. If a file exists at this path | |||||
| * already, it wll be overwritten. | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function copyFile($from, $to) { | |||||
| $from = self::resolvePath($from); | |||||
| $to = self::resolvePath($to); | |||||
| self::assertExists($from); | |||||
| self::assertIsFile($from); | |||||
| self::assertReadable($from); | |||||
| if (phutil_is_windows()) { | |||||
| execx('copy /Y %s %s', $from, $to); | |||||
| } else { | |||||
| execx('cp -p %s %s', $from, $to); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Remove a file or directory. | |||||
| * | |||||
| * @param string File to a path or directory to remove. | |||||
| * @return void | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function remove($path) { | |||||
| if (!strlen($path)) { | |||||
| // Avoid removing PWD. | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'No path provided to %s.', | |||||
| __FUNCTION__.'()')); | |||||
| } | |||||
| $path = self::resolvePath($path); | |||||
| if (!file_exists($path)) { | |||||
| return; | |||||
| } | |||||
| self::executeRemovePath($path); | |||||
| } | |||||
| /** | |||||
| * Rename a file or directory. | |||||
| * | |||||
| * @param string Old path. | |||||
| * @param string New path. | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function rename($old, $new) { | |||||
| $old = self::resolvePath($old); | |||||
| $new = self::resolvePath($new); | |||||
| self::assertExists($old); | |||||
| $ok = rename($old, $new); | |||||
| if (!$ok) { | |||||
| throw new FilesystemException( | |||||
| $new, | |||||
| pht("Failed to rename '%s' to '%s'!", $old, $new)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Internal. Recursively remove a file or an entire directory. Implements | |||||
| * the core function of @{method:remove} in a way that works on Windows. | |||||
| * | |||||
| * @param string File to a path or directory to remove. | |||||
| * @return void | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| private static function executeRemovePath($path) { | |||||
| if (is_dir($path) && !is_link($path)) { | |||||
| foreach (self::listDirectory($path, true) as $child) { | |||||
| self::executeRemovePath($path.DIRECTORY_SEPARATOR.$child); | |||||
| } | |||||
| $ok = rmdir($path); | |||||
| if (!$ok) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to remove directory '%s'!", $path)); | |||||
| } | |||||
| } else { | |||||
| $ok = unlink($path); | |||||
| if (!$ok) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to remove file '%s'!", $path)); | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Change the permissions of a file or directory. | |||||
| * | |||||
| * @param string Path to the file or directory. | |||||
| * @param int Permission umask. Note that umask is in octal, so you | |||||
| * should specify it as, e.g., `0777', not `777'. | |||||
| * @return void | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function changePermissions($path, $umask) { | |||||
| $path = self::resolvePath($path); | |||||
| self::assertExists($path); | |||||
| if (!@chmod($path, $umask)) { | |||||
| $readable_umask = sprintf('%04o', $umask); | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to chmod '%s' to '%s'.", $path, $readable_umask)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Get the last modified time of a file | |||||
| * | |||||
| * @param string Path to file | |||||
| * @return int Time last modified | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function getModifiedTime($path) { | |||||
| $path = self::resolvePath($path); | |||||
| self::assertExists($path); | |||||
| self::assertIsFile($path); | |||||
| self::assertReadable($path); | |||||
| $modified_time = @filemtime($path); | |||||
| if ($modified_time === false) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht('Failed to read modified time for %s.', $path)); | |||||
| } | |||||
| return $modified_time; | |||||
| } | |||||
| /** | |||||
| * Read random bytes from /dev/urandom or equivalent. See also | |||||
| * @{method:readRandomCharacters}. | |||||
| * | |||||
| * @param int Number of bytes to read. | |||||
| * @return string Random bytestring of the provided length. | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function readRandomBytes($number_of_bytes) { | |||||
| $number_of_bytes = (int)$number_of_bytes; | |||||
| if ($number_of_bytes < 1) { | |||||
| throw new Exception(pht('You must generate at least 1 byte of entropy.')); | |||||
| } | |||||
| // Under PHP 7.2.0 and newer, we have a reasonable builtin. For older | |||||
| // versions, we fall back to various sources which have a roughly similar | |||||
| // effect. | |||||
| if (function_exists('random_bytes')) { | |||||
| return random_bytes($number_of_bytes); | |||||
| } | |||||
| // Try to use `openssl_random_pseudo_bytes()` if it's available. This source | |||||
| // is the most widely available source, and works on Windows/Linux/OSX/etc. | |||||
| if (function_exists('openssl_random_pseudo_bytes')) { | |||||
| $strong = true; | |||||
| $data = openssl_random_pseudo_bytes($number_of_bytes, $strong); | |||||
| if (!$strong) { | |||||
| // NOTE: This indicates we're using a weak random source. This is | |||||
| // probably OK, but maybe we should be more strict here. | |||||
| } | |||||
| if ($data === false) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| '%s failed to generate entropy!', | |||||
| 'openssl_random_pseudo_bytes()')); | |||||
| } | |||||
| if (strlen($data) != $number_of_bytes) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| '%s returned an unexpected number of bytes (got %s, expected %s)!', | |||||
| 'openssl_random_pseudo_bytes()', | |||||
| new PhutilNumber(strlen($data)), | |||||
| new PhutilNumber($number_of_bytes))); | |||||
| } | |||||
| return $data; | |||||
| } | |||||
| // Try to use `/dev/urandom` if it's available. This is usually available | |||||
| // on non-Windows systems, but some PHP config (open_basedir) and chrooting | |||||
| // may limit our access to it. | |||||
| $urandom = @fopen('/dev/urandom', 'rb'); | |||||
| if ($urandom) { | |||||
| $data = @fread($urandom, $number_of_bytes); | |||||
| @fclose($urandom); | |||||
| if (strlen($data) != $number_of_bytes) { | |||||
| throw new FilesystemException( | |||||
| '/dev/urandom', | |||||
| pht('Failed to read random bytes!')); | |||||
| } | |||||
| return $data; | |||||
| } | |||||
| // (We might be able to try to generate entropy here from a weaker source | |||||
| // if neither of the above sources panned out, see some discussion in | |||||
| // T4153.) | |||||
| // We've failed to find any valid entropy source. Try to fail in the most | |||||
| // useful way we can, based on the platform. | |||||
| if (phutil_is_windows()) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| '%s requires the PHP OpenSSL extension to be installed and enabled '. | |||||
| 'to access an entropy source. On Windows, this extension is usually '. | |||||
| 'installed but not enabled by default. Enable it in your "s".', | |||||
| __METHOD__.'()', | |||||
| 'php.ini')); | |||||
| } | |||||
| throw new Exception( | |||||
| pht( | |||||
| '%s requires the PHP OpenSSL extension or access to "%s". Install or '. | |||||
| 'enable the OpenSSL extension, or make sure "%s" is accessible.', | |||||
| __METHOD__.'()', | |||||
| '/dev/urandom', | |||||
| '/dev/urandom')); | |||||
| } | |||||
| /** | |||||
| * Read random alphanumeric characters from /dev/urandom or equivalent. This | |||||
| * method operates like @{method:readRandomBytes} but produces alphanumeric | |||||
| * output (a-z, 0-9) so it's appropriate for use in URIs and other contexts | |||||
| * where it needs to be human readable. | |||||
| * | |||||
| * @param int Number of characters to read. | |||||
| * @return string Random character string of the provided length. | |||||
| * | |||||
| * @task file | |||||
| */ | |||||
| public static function readRandomCharacters($number_of_characters) { | |||||
| // NOTE: To produce the character string, we generate a random byte string | |||||
| // of the same length, select the high 5 bits from each byte, and | |||||
| // map that to 32 alphanumeric characters. This could be improved (we | |||||
| // could improve entropy per character with base-62, and some entropy | |||||
| // sources might be less entropic if we discard the low bits) but for | |||||
| // reasonable cases where we have a good entropy source and are just | |||||
| // generating some kind of human-readable secret this should be more than | |||||
| // sufficient and is vastly simpler than trying to do bit fiddling. | |||||
| $map = array_merge(range('a', 'z'), range('2', '7')); | |||||
| $result = ''; | |||||
| $bytes = self::readRandomBytes($number_of_characters); | |||||
| for ($ii = 0; $ii < $number_of_characters; $ii++) { | |||||
| $result .= $map[ord($bytes[$ii]) >> 3]; | |||||
| } | |||||
| return $result; | |||||
| } | |||||
| /** | |||||
| * Generate a random integer value in a given range. | |||||
| * | |||||
| * This method uses less-entropic random sources under older versions of PHP. | |||||
| * | |||||
| * @param int Minimum value, inclusive. | |||||
| * @param int Maximum value, inclusive. | |||||
| */ | |||||
| public static function readRandomInteger($min, $max) { | |||||
| if (!is_int($min)) { | |||||
| throw new Exception(pht('Minimum value must be an integer.')); | |||||
| } | |||||
| if (!is_int($max)) { | |||||
| throw new Exception(pht('Maximum value must be an integer.')); | |||||
| } | |||||
| if ($min > $max) { | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Minimum ("%d") must not be greater than maximum ("%d").', | |||||
| $min, | |||||
| $max)); | |||||
| } | |||||
| // Under PHP 7.2.0 and newer, we can just use "random_int()". This function | |||||
| // is intended to generate cryptographically usable entropy. | |||||
| if (function_exists('random_int')) { | |||||
| return random_int($min, $max); | |||||
| } | |||||
| // We could find a stronger source for this, but correctly converting raw | |||||
| // bytes to an integer range without biases is fairly hard and it seems | |||||
| // like we're more likely to get that wrong than suffer a PRNG prediction | |||||
| // issue by falling back to "mt_rand()". | |||||
| if (($max - $min) > mt_getrandmax()) { | |||||
| throw new Exception( | |||||
| pht('mt_rand() range is smaller than the requested range.')); | |||||
| } | |||||
| $result = mt_rand($min, $max); | |||||
| if (!is_int($result)) { | |||||
| throw new Exception(pht('Bad return value from mt_rand().')); | |||||
| } | |||||
| return $result; | |||||
| } | |||||
| /** | |||||
| * Identify the MIME type of a file. This returns only the MIME type (like | |||||
| * text/plain), not the encoding (like charset=utf-8). | |||||
| * | |||||
| * @param string Path to the file to examine. | |||||
| * @param string Optional default mime type to return if the file's mime | |||||
| * type can not be identified. | |||||
| * @return string File mime type. | |||||
| * | |||||
| * @task file | |||||
| * | |||||
| * @phutil-external-symbol function mime_content_type | |||||
| * @phutil-external-symbol function finfo_open | |||||
| * @phutil-external-symbol function finfo_file | |||||
| */ | |||||
| public static function getMimeType( | |||||
| $path, | |||||
| $default = 'application/octet-stream') { | |||||
| $path = self::resolvePath($path); | |||||
| self::assertExists($path); | |||||
| self::assertIsFile($path); | |||||
| self::assertReadable($path); | |||||
| $mime_type = null; | |||||
| // Fileinfo is the best approach since it doesn't rely on `file`, but | |||||
| // it isn't builtin for older versions of PHP. | |||||
| if (function_exists('finfo_open')) { | |||||
| $finfo = finfo_open(FILEINFO_MIME); | |||||
| if ($finfo) { | |||||
| $result = finfo_file($finfo, $path); | |||||
| if ($result !== false) { | |||||
| $mime_type = $result; | |||||
| } | |||||
| } | |||||
| } | |||||
| // If we failed Fileinfo, try `file`. This works well but not all systems | |||||
| // have the binary. | |||||
| if ($mime_type === null) { | |||||
| list($err, $stdout) = exec_manual( | |||||
| 'file --brief --mime %s', | |||||
| $path); | |||||
| if (!$err) { | |||||
| $mime_type = trim($stdout); | |||||
| } | |||||
| } | |||||
| // If we didn't get anywhere, try the deprecated mime_content_type() | |||||
| // function. | |||||
| if ($mime_type === null) { | |||||
| if (function_exists('mime_content_type')) { | |||||
| $result = mime_content_type($path); | |||||
| if ($result !== false) { | |||||
| $mime_type = $result; | |||||
| } | |||||
| } | |||||
| } | |||||
| // If we come back with an encoding, strip it off. | |||||
| if (strpos($mime_type, ';') !== false) { | |||||
| list($type, $encoding) = explode(';', $mime_type, 2); | |||||
| $mime_type = $type; | |||||
| } | |||||
| if ($mime_type === null) { | |||||
| $mime_type = $default; | |||||
| } | |||||
| return $mime_type; | |||||
| } | |||||
| /* -( Directories )-------------------------------------------------------- */ | |||||
| /** | |||||
| * Create a directory in a manner similar to mkdir(), but throw detailed | |||||
| * exceptions on failure. | |||||
| * | |||||
| * @param string Path to directory. The parent directory must exist and | |||||
| * be writable. | |||||
| * @param int Permission umask. Note that umask is in octal, so you | |||||
| * should specify it as, e.g., `0777', not `777'. | |||||
| * @param boolean Recursively create directories. Default to false. | |||||
| * @return string Path to the created directory. | |||||
| * | |||||
| * @task directory | |||||
| */ | |||||
| public static function createDirectory( | |||||
| $path, | |||||
| $umask = 0755, | |||||
| $recursive = false) { | |||||
| $path = self::resolvePath($path); | |||||
| if (is_dir($path)) { | |||||
| if ($umask) { | |||||
| self::changePermissions($path, $umask); | |||||
| } | |||||
| return $path; | |||||
| } | |||||
| $dir = dirname($path); | |||||
| if ($recursive && !file_exists($dir)) { | |||||
| // Note: We could do this with the recursive third parameter of mkdir(), | |||||
| // but then we loose the helpful FilesystemExceptions we normally get. | |||||
| self::createDirectory($dir, $umask, true); | |||||
| } | |||||
| self::assertIsDirectory($dir); | |||||
| self::assertExists($dir); | |||||
| self::assertWritable($dir); | |||||
| self::assertNotExists($path); | |||||
| if (!mkdir($path, $umask)) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Failed to create directory '%s'.", $path)); | |||||
| } | |||||
| // Need to change permissions explicitly because mkdir does something | |||||
| // slightly different. mkdir(2) man page: | |||||
| // 'The parameter mode specifies the permissions to use. It is modified by | |||||
| // the process's umask in the usual way: the permissions of the created | |||||
| // directory are (mode & ~umask & 0777)."' | |||||
| if ($umask) { | |||||
| self::changePermissions($path, $umask); | |||||
| } | |||||
| return $path; | |||||
| } | |||||
| /** | |||||
| * Create a temporary directory and return the path to it. You are | |||||
| * responsible for removing it (e.g., with Filesystem::remove()) | |||||
| * when you are done with it. | |||||
| * | |||||
| * @param string Optional directory prefix. | |||||
| * @param int Permissions to create the directory with. By default, | |||||
| * these permissions are very restrictive (0700). | |||||
| * @param string Optional root directory. If not provided, the system | |||||
| * temporary directory (often "/tmp") will be used. | |||||
| * @return string Path to newly created temporary directory. | |||||
| * | |||||
| * @task directory | |||||
| */ | |||||
| public static function createTemporaryDirectory( | |||||
| $prefix = '', | |||||
| $umask = 0700, | |||||
| $root_directory = null) { | |||||
| $prefix = preg_replace('/[^A-Z0-9._-]+/i', '', $prefix); | |||||
| if ($root_directory !== null) { | |||||
| $tmp = $root_directory; | |||||
| self::assertExists($tmp); | |||||
| self::assertIsDirectory($tmp); | |||||
| self::assertWritable($tmp); | |||||
| } else { | |||||
| $tmp = sys_get_temp_dir(); | |||||
| if (!$tmp) { | |||||
| throw new FilesystemException( | |||||
| $tmp, | |||||
| pht('Unable to determine system temporary directory.')); | |||||
| } | |||||
| } | |||||
| $base = $tmp.DIRECTORY_SEPARATOR.$prefix; | |||||
| $tries = 3; | |||||
| do { | |||||
| $dir = $base.substr(base_convert(md5(mt_rand()), 16, 36), 0, 16); | |||||
| try { | |||||
| self::createDirectory($dir, $umask); | |||||
| break; | |||||
| } catch (FilesystemException $ex) { | |||||
| // Ignore. | |||||
| } | |||||
| } while (--$tries); | |||||
| if (!$tries) { | |||||
| $df = disk_free_space($tmp); | |||||
| if ($df !== false && $df < 1024 * 1024) { | |||||
| throw new FilesystemException( | |||||
| $dir, | |||||
| pht('Failed to create a temporary directory: the disk is full.')); | |||||
| } | |||||
| throw new FilesystemException( | |||||
| $dir, | |||||
| pht("Failed to create a temporary directory in '%s'.", $tmp)); | |||||
| } | |||||
| return $dir; | |||||
| } | |||||
| /** | |||||
| * List files in a directory. | |||||
| * | |||||
| * @param string Path, absolute or relative to PWD. | |||||
| * @param bool If false, exclude files beginning with a ".". | |||||
| * | |||||
| * @return array List of files and directories in the specified | |||||
| * directory, excluding `.' and `..'. | |||||
| * | |||||
| * @task directory | |||||
| */ | |||||
| public static function listDirectory($path, $include_hidden = true) { | |||||
| $path = self::resolvePath($path); | |||||
| self::assertExists($path); | |||||
| self::assertIsDirectory($path); | |||||
| self::assertReadable($path); | |||||
| $list = @scandir($path); | |||||
| if ($list === false) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Unable to list contents of directory '%s'.", $path)); | |||||
| } | |||||
| foreach ($list as $k => $v) { | |||||
| if ($v == '.' || $v == '..' || (!$include_hidden && $v[0] == '.')) { | |||||
| unset($list[$k]); | |||||
| } | |||||
| } | |||||
| return array_values($list); | |||||
| } | |||||
| /** | |||||
| * Return all directories between a path and the specified root directory | |||||
| * (defaulting to "/"). Iterating over them walks from the path to the root. | |||||
| * | |||||
| * @param string Path, absolute or relative to PWD. | |||||
| * @param string The root directory. | |||||
| * @return list<string> List of parent paths, including the provided path. | |||||
| * @task directory | |||||
| */ | |||||
| public static function walkToRoot($path, $root = null) { | |||||
| $path = self::resolvePath($path); | |||||
| if (is_link($path)) { | |||||
| $path = realpath($path); | |||||
| } | |||||
| // NOTE: On Windows, paths start like "C:\", so "/" does not contain | |||||
| // every other path. We could possibly special case "/" to have the same | |||||
| // meaning on Windows that it does on Linux, but just special case the | |||||
| // common case for now. See PHI817. | |||||
| if ($root !== null) { | |||||
| $root = self::resolvePath($root); | |||||
| if (is_link($root)) { | |||||
| $root = realpath($root); | |||||
| } | |||||
| // NOTE: We don't use `isDescendant()` here because we don't want to | |||||
| // reject paths which don't exist on disk. | |||||
| $root_list = new FileList(array($root)); | |||||
| if (!$root_list->contains($path)) { | |||||
| return array(); | |||||
| } | |||||
| } else { | |||||
| if (phutil_is_windows()) { | |||||
| $root = null; | |||||
| } else { | |||||
| $root = '/'; | |||||
| } | |||||
| } | |||||
| $walk = array(); | |||||
| $parts = explode(DIRECTORY_SEPARATOR, $path); | |||||
| foreach ($parts as $k => $part) { | |||||
| if (!strlen($part)) { | |||||
| unset($parts[$k]); | |||||
| } | |||||
| } | |||||
| while (true) { | |||||
| if (phutil_is_windows()) { | |||||
| $next = implode(DIRECTORY_SEPARATOR, $parts); | |||||
| } else { | |||||
| $next = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts); | |||||
| } | |||||
| $walk[] = $next; | |||||
| if ($next == $root) { | |||||
| break; | |||||
| } | |||||
| if (!$parts) { | |||||
| break; | |||||
| } | |||||
| array_pop($parts); | |||||
| } | |||||
| return $walk; | |||||
| } | |||||
| /* -( Paths )-------------------------------------------------------------- */ | |||||
| /** | |||||
| * Checks if a path is specified as an absolute path. | |||||
| * | |||||
| * @param string | |||||
| * @return bool | |||||
| */ | |||||
| public static function isAbsolutePath($path) { | |||||
| if (phutil_is_windows()) { | |||||
| return (bool)preg_match('/^[A-Za-z]+:/', $path); | |||||
| } else { | |||||
| return !strncmp($path, DIRECTORY_SEPARATOR, 1); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Canonicalize a path by resolving it relative to some directory (by | |||||
| * default PWD), following parent symlinks and removing artifacts. If the | |||||
| * path is itself a symlink it is left unresolved. | |||||
| * | |||||
| * @param string Path, absolute or relative to PWD. | |||||
| * @return string Canonical, absolute path. | |||||
| * | |||||
| * @task path | |||||
| */ | |||||
| public static function resolvePath($path, $relative_to = null) { | |||||
| $is_absolute = self::isAbsolutePath($path); | |||||
| if (!$is_absolute) { | |||||
| if (!$relative_to) { | |||||
| $relative_to = getcwd(); | |||||
| } | |||||
| $path = $relative_to.DIRECTORY_SEPARATOR.$path; | |||||
| } | |||||
| if (is_link($path)) { | |||||
| $parent_realpath = realpath(dirname($path)); | |||||
| if ($parent_realpath !== false) { | |||||
| return $parent_realpath.DIRECTORY_SEPARATOR.basename($path); | |||||
| } | |||||
| } | |||||
| $realpath = realpath($path); | |||||
| if ($realpath !== false) { | |||||
| return $realpath; | |||||
| } | |||||
| // This won't work if the file doesn't exist or is on an unreadable mount | |||||
| // or something crazy like that. Try to resolve a parent so we at least | |||||
| // cover the nonexistent file case. | |||||
| $parts = explode(DIRECTORY_SEPARATOR, trim($path, DIRECTORY_SEPARATOR)); | |||||
| while (end($parts) !== false) { | |||||
| array_pop($parts); | |||||
| if (phutil_is_windows()) { | |||||
| $attempt = implode(DIRECTORY_SEPARATOR, $parts); | |||||
| } else { | |||||
| $attempt = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts); | |||||
| } | |||||
| $realpath = realpath($attempt); | |||||
| if ($realpath !== false) { | |||||
| $path = $realpath.substr($path, strlen($attempt)); | |||||
| break; | |||||
| } | |||||
| } | |||||
| return $path; | |||||
| } | |||||
| /** | |||||
| * Test whether a path is descendant from some root path after resolving all | |||||
| * symlinks and removing artifacts. Both paths must exists for the relation | |||||
| * to obtain. A path is always a descendant of itself as long as it exists. | |||||
| * | |||||
| * @param string Child path, absolute or relative to PWD. | |||||
| * @param string Root path, absolute or relative to PWD. | |||||
| * @return bool True if resolved child path is in fact a descendant of | |||||
| * resolved root path and both exist. | |||||
| * @task path | |||||
| */ | |||||
| public static function isDescendant($path, $root) { | |||||
| try { | |||||
| self::assertExists($path); | |||||
| self::assertExists($root); | |||||
| } catch (FilesystemException $e) { | |||||
| return false; | |||||
| } | |||||
| $fs = new FileList(array($root)); | |||||
| return $fs->contains($path); | |||||
| } | |||||
| /** | |||||
| * Convert a canonical path to its most human-readable format. It is | |||||
| * guaranteed that you can use resolvePath() to restore a path to its | |||||
| * canonical format. | |||||
| * | |||||
| * @param string Path, absolute or relative to PWD. | |||||
| * @param string Optionally, working directory to make files readable | |||||
| * relative to. | |||||
| * @return string Human-readable path. | |||||
| * | |||||
| * @task path | |||||
| */ | |||||
| public static function readablePath($path, $pwd = null) { | |||||
| if ($pwd === null) { | |||||
| $pwd = getcwd(); | |||||
| } | |||||
| foreach (array($pwd, self::resolvePath($pwd)) as $parent) { | |||||
| $parent = rtrim($parent, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; | |||||
| $len = strlen($parent); | |||||
| if (!strncmp($parent, $path, $len)) { | |||||
| $path = substr($path, $len); | |||||
| return $path; | |||||
| } | |||||
| } | |||||
| return $path; | |||||
| } | |||||
| /** | |||||
| * Determine whether or not a path exists in the filesystem. This differs from | |||||
| * file_exists() in that it returns true for symlinks. This method does not | |||||
| * attempt to resolve paths before testing them. | |||||
| * | |||||
| * @param string Test for the existence of this path. | |||||
| * @return bool True if the path exists in the filesystem. | |||||
| * @task path | |||||
| */ | |||||
| public static function pathExists($path) { | |||||
| return file_exists($path) || is_link($path); | |||||
| } | |||||
| /** | |||||
| * Determine if an executable binary (like `git` or `svn`) exists within | |||||
| * the configured `$PATH`. | |||||
| * | |||||
| * @param string Binary name, like `'git'` or `'svn'`. | |||||
| * @return bool True if the binary exists and is executable. | |||||
| * @task exec | |||||
| */ | |||||
| public static function binaryExists($binary) { | |||||
| return self::resolveBinary($binary) !== null; | |||||
| } | |||||
| /** | |||||
| * Locates the full path that an executable binary (like `git` or `svn`) is at | |||||
| * the configured `$PATH`. | |||||
| * | |||||
| * @param string Binary name, like `'git'` or `'svn'`. | |||||
| * @return string The full binary path if it is present, or null. | |||||
| * @task exec | |||||
| */ | |||||
| public static function resolveBinary($binary) { | |||||
| if (phutil_is_windows()) { | |||||
| list($err, $stdout) = exec_manual('where %s', $binary); | |||||
| $stdout = phutil_split_lines($stdout); | |||||
| // If `where %s` could not find anything, check for relative binary | |||||
| if ($err) { | |||||
| $path = self::resolvePath($binary); | |||||
| if (self::pathExists($path)) { | |||||
| return $path; | |||||
| } | |||||
| return null; | |||||
| } | |||||
| $stdout = head($stdout); | |||||
| } else { | |||||
| list($err, $stdout) = exec_manual('which %s', $binary); | |||||
| } | |||||
| return $err === 0 ? trim($stdout) : null; | |||||
| } | |||||
| /** | |||||
| * Determine if two paths are equivalent by resolving symlinks. This is | |||||
| * different from resolving both paths and comparing them because | |||||
| * resolvePath() only resolves symlinks in parent directories, not the | |||||
| * path itself. | |||||
| * | |||||
| * @param string First path to test for equivalence. | |||||
| * @param string Second path to test for equivalence. | |||||
| * @return bool True if both paths are equivalent, i.e. reference the same | |||||
| * entity in the filesystem. | |||||
| * @task path | |||||
| */ | |||||
| public static function pathsAreEquivalent($u, $v) { | |||||
| $u = self::resolvePath($u); | |||||
| $v = self::resolvePath($v); | |||||
| $real_u = realpath($u); | |||||
| $real_v = realpath($v); | |||||
| if ($real_u) { | |||||
| $u = $real_u; | |||||
| } | |||||
| if ($real_v) { | |||||
| $v = $real_v; | |||||
| } | |||||
| return ($u == $v); | |||||
| } | |||||
| /* -( Assert )------------------------------------------------------------- */ | |||||
| /** | |||||
| * Assert that something (e.g., a file, directory, or symlink) exists at a | |||||
| * specified location. | |||||
| * | |||||
| * @param string Assert that this path exists. | |||||
| * @return void | |||||
| * | |||||
| * @task assert | |||||
| */ | |||||
| public static function assertExists($path) { | |||||
| if (self::pathExists($path)) { | |||||
| return; | |||||
| } | |||||
| // Before we claim that the path doesn't exist, try to find a parent we | |||||
| // don't have "+x" on. If we find one, tailor the error message so we don't | |||||
| // say "does not exist" in cases where the path does exist, we just don't | |||||
| // have permission to test its existence. | |||||
| foreach (self::walkToRoot($path) as $parent) { | |||||
| if (!self::pathExists($parent)) { | |||||
| continue; | |||||
| } | |||||
| if (!is_dir($parent)) { | |||||
| continue; | |||||
| } | |||||
| if (phutil_is_windows()) { | |||||
| // Do nothing. On Windows, there's no obvious equivalent to the | |||||
| // check below because "is_executable(...)" always appears to return | |||||
| // "false" for any directory. | |||||
| } else if (!is_executable($parent)) { | |||||
| // On Linux, note that we don't need read permission ("+r") on parent | |||||
| // directories to determine that a path exists, only execute ("+x"). | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht( | |||||
| 'Filesystem path "%s" can not be accessed because a parent '. | |||||
| 'directory ("%s") is not executable (the current process does '. | |||||
| 'not have "+x" permission).', | |||||
| $path, | |||||
| $parent)); | |||||
| } | |||||
| } | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht( | |||||
| 'Filesystem path "%s" does not exist.', | |||||
| $path)); | |||||
| } | |||||
| /** | |||||
| * Assert that nothing exists at a specified location. | |||||
| * | |||||
| * @param string Assert that this path does not exist. | |||||
| * @return void | |||||
| * | |||||
| * @task assert | |||||
| */ | |||||
| public static function assertNotExists($path) { | |||||
| if (file_exists($path) || is_link($path)) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Path '%s' already exists!", $path)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Assert that a path represents a file, strictly (i.e., not a directory). | |||||
| * | |||||
| * @param string Assert that this path is a file. | |||||
| * @return void | |||||
| * | |||||
| * @task assert | |||||
| */ | |||||
| public static function assertIsFile($path) { | |||||
| if (!is_file($path)) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Requested path '%s' is not a file.", $path)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Assert that a path represents a directory, strictly (i.e., not a file). | |||||
| * | |||||
| * @param string Assert that this path is a directory. | |||||
| * @return void | |||||
| * | |||||
| * @task assert | |||||
| */ | |||||
| public static function assertIsDirectory($path) { | |||||
| if (!is_dir($path)) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Requested path '%s' is not a directory.", $path)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Assert that a file or directory exists and is writable. | |||||
| * | |||||
| * @param string Assert that this path is writable. | |||||
| * @return void | |||||
| * | |||||
| * @task assert | |||||
| */ | |||||
| public static function assertWritable($path) { | |||||
| if (!is_writable($path)) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Requested path '%s' is not writable.", $path)); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Assert that a file or directory exists and is readable. | |||||
| * | |||||
| * @param string Assert that this path is readable. | |||||
| * @return void | |||||
| * | |||||
| * @task assert | |||||
| */ | |||||
| public static function assertReadable($path) { | |||||
| if (!is_readable($path)) { | |||||
| throw new FilesystemException( | |||||
| $path, | |||||
| pht("Path '%s' is not readable.", $path)); | |||||
| } | |||||
| } | |||||
| } | |||||