diff --git a/src/filesystem/Filesystem.php b/src/filesystem/Filesystem.php index 82ad11d..f2c6b07 100644 --- a/src/filesystem/Filesystem.php +++ b/src/filesystem/Filesystem.php @@ -1,995 +1,1013 @@ <?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 * @group filesystem */ final class Filesystem { /* -( 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, "Failed to read file `{$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, "Failed to write file `{$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, "unable to create temporary file in $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, "unable to move $temp to $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 = Filesystem::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); fclose($handle); if (!$ok) { throw new FilesystemException( $try_path, "Failed to write file data."); } 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, "Failed to open file `{$path}'."); } $dlen = strlen($data); if (fwrite($fh, $data) !== $dlen) { throw new FilesystemException( $path, "Failed to write {$dlen} bytes to `{$path}'."); } if (!fflush($fh) || !fclose($fh)) { throw new FilesystemException( $path, "Failed closing file `{$path}' after write."); } } /** * 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("No path provided to remove()."); } $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, "Failed to rename '{$old}' to '{$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 (Filesystem::listDirectory($path, true) as $child) { self::executeRemovePath($path.DIRECTORY_SEPARATOR.$child); } $ok = rmdir($path); if (!$ok) { throw new FilesystemException( $path, "Failed to remove directory '{$path}'!"); } } else { $ok = unlink($path); if (!$ok) { throw new FilesystemException( $path, "Failed to remove file '{$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, "Failed to chmod `{$path}' to `{$readable_umask}'."); } } /** * Get the last modified time of a file * * @param string Path to file * @return 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, 'Failed to read modified time for '.$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 * * @phutil-external-symbol class COM */ public static function readRandomBytes($number_of_bytes) { if (phutil_is_windows()) { if (!function_exists('openssl_random_pseudo_bytes')) { if (version_compare(PHP_VERSION, '5.3.0') < 0) { throw new Exception( 'Filesystem::readRandomBytes() requires at least PHP 5.3 under '. 'Windows.'); } throw new Exception( 'Filesystem::readRandomBytes() requires OpenSSL extension under '. 'Windows.'); } $strong = true; return openssl_random_pseudo_bytes($number_of_bytes, $strong); } $urandom = @fopen('/dev/urandom', 'rb'); if (!$urandom) { throw new FilesystemException( '/dev/urandom', 'Failed to open /dev/urandom for reading!'); } $data = @fread($urandom, $number_of_bytes); if (strlen($data) != $number_of_bytes) { throw new FilesystemException( '/dev/urandom', 'Failed to read random bytes!'); } @fclose($urandom); return $data; } /** * 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; } /** * 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'. By * default, these permissions are very liberal (0777). * @param boolean Recursivly create directories. Default to false * @return string Path to the created directory. * * @task directory */ public static function createDirectory($path, $umask = 0777, $recursive = false) { $path = self::resolvePath($path); if (is_dir($path)) { Filesystem::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, "Failed to create directory `{$path}'."); } // Need to change premissions 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)."' Filesystem::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). * @return string Path to newly created temporary directory. * * @task directory */ public static function createTemporaryDirectory($prefix = '', $umask = 0700) { $prefix = preg_replace('/[^A-Z0-9._-]+/i', '', $prefix); $tmp = sys_get_temp_dir(); if (!$tmp) { throw new FilesystemException( $tmp, '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, "Failed to create a temporary directory: the disk is full."); } throw new FilesystemException( $dir, "Failed to create a temporary directory."); } 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, "Unable to list contents of directory `{$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 "/". Iterating over them walks * from the path to the root. * * @param string Path, absolute or relative to PWD. * @return list List of parent paths, including the provided path. * @task directory */ public static function walkToRoot($path) { $path = self::resolvePath($path); if (is_link($path)) { $path = realpath($path); } $walk = array(); $parts = explode(DIRECTORY_SEPARATOR, $path); foreach ($parts as $k => $part) { if (!strlen($part)) { unset($parts[$k]); } } do { if (phutil_is_windows()) { $walk[] = implode(DIRECTORY_SEPARATOR, $parts); } else { $walk[] = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts); } if (empty($parts)) { break; } array_pop($parts); } while (true); return $walk; } /* -( Paths )-------------------------------------------------------------- */ /** * 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) { if (phutil_is_windows()) { $is_absolute = preg_match('/^[A-Za-z]+:/', $path); } else { $is_absolute = !strncmp($path, DIRECTORY_SEPARATOR, 1); } 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) = exec_manual('where %s', $binary); + list($err, $stdout) = exec_manual('where %s', $binary); + $stdout = phutil_split_lines($stdout); + if (!$stdout) { + return null; + } + $stdout = trim($stdout[0]); } else { - list($err) = exec_manual('which %s', $binary); + list($err, $stdout) = exec_manual('which %s', $binary); } - - return !$err; + + return $err === 0 ? $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 = Filesystem::resolvePath($u); $v = Filesystem::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)) { throw new FilesystemException( $path, "Filesystem entity `{$path}' does not exist."); } } /** * 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, "Path `{$path}' already exists!"); } } /** * 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, "Requested path `{$path}' is not a file."); } } /** * 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, "Requested path `{$path}' is not a directory."); } } /** * 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, "Requested path `{$path}' is not writable."); } } /** * 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, "Path `{$path}' is not readable."); } } } diff --git a/src/filesystem/__tests__/FilesystemTestCase.php b/src/filesystem/__tests__/FilesystemTestCase.php index fa4b21c..24e5b51 100644 --- a/src/filesystem/__tests__/FilesystemTestCase.php +++ b/src/filesystem/__tests__/FilesystemTestCase.php @@ -1,30 +1,50 @@ <?php /** * @group testcase */ final class FilesystemTestCase extends PhutilTestCase { public function testBinaryExists() { // Test for the `which` binary on Linux, and the `where` binary on Windows, // because `which which` is cute. if (phutil_is_windows()) { $exists = 'where'; } else { $exists = 'which'; } $this->assertEqual( true, Filesystem::binaryExists($exists)); // We don't expect to find this binary on any system. $this->assertEqual( false, Filesystem::binaryExists('halting-problem-decider')); } + + public function testResolveBinary() { + + // Test to make sure resolveBinary() returns the full path to the `which` + // and `where` binaries. + + if (phutil_is_windows()) { + $binary = 'where'; + } else { + $binary = 'which'; + } + + $path = Filesystem::resolveBinary($binary); + $this->assertEqual(false, null === $path); + $this->assertEqual(true, file_exists($path)); + $this->assertEqual(false, is_dir($path)); + + $this->assertEqual(null, + Filesystem::resolveBinary('halting-problem-decider')); + } }