diff --git a/src/filesystem/FileFinder.php b/src/filesystem/FileFinder.php index e78927e..0bdfbdc 100644 --- a/src/filesystem/FileFinder.php +++ b/src/filesystem/FileFinder.php @@ -1,318 +1,338 @@ withType('f') * ->withSuffix('php') * ->find(); * * @task create Creating a File Query * @task config Configuring File Queries * @task exec Executing the File Query * @task internal Internal */ final class FileFinder extends Phobject { private $root; private $exclude = array(); private $paths = array(); private $name = array(); private $suffix = array(); private $type; private $generateChecksums = false; private $followSymlinks; private $forceMode; /** * Create a new FileFinder. * * @param string Root directory to find files beneath. * @return this * @task create */ public function __construct($root) { $this->root = rtrim($root, '/'); } /** * @task config */ public function excludePath($path) { $this->exclude[] = $path; return $this; } /** * @task config */ public function withName($name) { $this->name[] = $name; return $this; } /** * @task config */ public function withSuffix($suffix) { - $this->suffix[] = '*.'.$suffix; + $this->suffix[] = $suffix; return $this; } /** * @task config */ public function withPath($path) { $this->paths[] = $path; return $this; } /** * @task config */ public function withType($type) { $this->type = $type; return $this; } /** * @task config */ public function withFollowSymlinks($follow) { $this->followSymlinks = $follow; return $this; } /** * @task config */ public function setGenerateChecksums($generate) { $this->generateChecksums = $generate; return $this; } public function getGenerateChecksums() { return $this->generateChecksums; } /** * @task config * @param string Either "php", "shell", or the empty string. */ public function setForceMode($mode) { $this->forceMode = $mode; return $this; } /** * @task internal */ public function validateFile($file) { if ($this->name) { $matches = false; foreach ($this->name as $curr_name) { if (basename($file) === $curr_name) { $matches = true; break; } } if (!$matches) { return false; } } if ($this->suffix) { $matches = false; - foreach ($this->suffix as $curr_suffix) { - if (fnmatch($curr_suffix, $file)) { + foreach ($this->suffix as $suffix) { + $suffix = addcslashes($suffix, '\\?*'); + $suffix = '*.'.$suffix; + if (fnmatch($suffix, $file)) { $matches = true; break; } } if (!$matches) { return false; } } if ($this->paths) { $matches = false; foreach ($this->paths as $path) { if (fnmatch($path, $this->root.'/'.$file)) { $matches = true; break; } } if (!$matches) { return false; } } $fullpath = $this->root.'/'.ltrim($file, '/'); if (($this->type == 'f' && is_dir($fullpath)) || ($this->type == 'd' && !is_dir($fullpath))) { return false; } return true; } /** * @task internal */ private function getFiles($dir) { $found = Filesystem::listDirectory($this->root.'/'.$dir, true); $files = array(); if (strlen($dir) > 0) { $dir = rtrim($dir, '/').'/'; } foreach ($found as $filename) { // Only exclude files whose names match relative to the root. if ($dir == '') { $matches = true; foreach ($this->exclude as $exclude_path) { if (fnmatch(ltrim($exclude_path, './'), $dir.$filename)) { $matches = false; break; } } if (!$matches) { continue; } } if ($this->validateFile($dir.$filename)) { $files[] = $dir.$filename; } if (is_dir($this->root.'/'.$dir.$filename)) { foreach ($this->getFiles($dir.$filename) as $file) { $files[] = $file; } } } return $files; } /** * @task exec */ public function find() { $files = array(); if (!is_dir($this->root) || !is_readable($this->root)) { throw new Exception( pht( "Invalid %s root directory specified ('%s'). Root directory ". "must be a directory, be readable, and be specified with an ". "absolute path.", __CLASS__, $this->root)); } if ($this->forceMode == 'shell') { $php_mode = false; } else if ($this->forceMode == 'php') { $php_mode = true; } else { $php_mode = (phutil_is_windows() || !Filesystem::binaryExists('find')); } if ($php_mode) { $files = $this->getFiles(''); } else { $args = array(); $command = array(); $command[] = 'find'; if ($this->followSymlinks) { $command[] = '-L'; } $command[] = '.'; if ($this->exclude) { $command[] = $this->generateList('path', $this->exclude).' -prune'; $command[] = '-o'; } if ($this->type) { $command[] = '-type %s'; $args[] = $this->type; } if ($this->name) { - $command[] = $this->generateList('name', $this->name); + $command[] = $this->generateList('name', $this->name, 'name'); } if ($this->suffix) { - $command[] = $this->generateList('name', $this->suffix); + $command[] = $this->generateList('name', $this->suffix, 'suffix'); } if ($this->paths) { $command[] = $this->generateList('path', $this->paths); } $command[] = '-print0'; array_unshift($args, implode(' ', $command)); list($stdout) = newv('ExecFuture', $args) ->setCWD($this->root) ->resolvex(); $stdout = trim($stdout); if (!strlen($stdout)) { return array(); } $files = explode("\0", $stdout); // On OSX/BSD, find prepends a './' to each file. foreach ($files as $key => $file) { // When matching directories, we can get "." back in the result set, // but this isn't an interesting result. if ($file == '.') { unset($files[$key]); continue; } if (substr($files[$key], 0, 2) == './') { $files[$key] = substr($files[$key], 2); } } } if (!$this->generateChecksums) { return $files; } else { $map = array(); foreach ($files as $line) { $fullpath = $this->root.'/'.ltrim($line, '/'); if (is_dir($fullpath)) { $map[$line] = null; } else { $map[$line] = md5_file($fullpath); } } return $map; } } /** * @task internal */ - private function generateList($flag, array $items) { - $items = array_map('escapeshellarg', $items); + private function generateList( + $flag, + array $items, + $mode = 'path') { + foreach ($items as $key => $item) { - $items[$key] = '-'.$flag.' '.$item; + // If the mode is not "path" mode, we're going to escape glob characters + // in the pattern. Otherwise, we escape only backslashes. + if ($mode === 'path') { + $item = addcslashes($item, '\\'); + } else { + $item = addcslashes($item, '\\*?'); + } + + if ($mode === 'suffix') { + $item = '*.'.$item; + } + + $item = (string)csprintf('%s %s', '-'.$flag, $item); + + $items[$key] = $item; } + $items = implode(' -o ', $items); return '"(" '.$items.' ")"'; } } diff --git a/src/filesystem/__tests__/FileFinderTestCase.php b/src/filesystem/__tests__/FileFinderTestCase.php index 731b38b..1ddbfad 100644 --- a/src/filesystem/__tests__/FileFinderTestCase.php +++ b/src/filesystem/__tests__/FileFinderTestCase.php @@ -1,146 +1,212 @@ excludePath('./exclude') ->excludePath('subdir.txt'); } public function testFinderWithChecksums() { $this->assertFinder( pht('Basic Checksums'), $this->newFinder() ->setGenerateChecksums(true) ->withType('f') ->withPath('*') ->withSuffix('txt'), array( '.hidden.txt' => 'b6cfc9ce9afe12b258ee1c19c235aa27', 'file.txt' => '725130ba6441eadb4e5d807898e0beae', 'include_dir.txt/anotherfile.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'include_dir.txt/subdir.txt/alsoinclude.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'test.txt' => 'aea46212fa8b8d0e0e6aa34a15c9e2f5', )); } public function testFinderWithoutChecksums() { $this->assertFinder( pht('Basic No Checksums'), $this->newFinder() ->withType('f') ->withPath('*') ->withSuffix('txt'), array( '.hidden.txt', 'file.txt', 'include_dir.txt/anotherfile.txt', 'include_dir.txt/subdir.txt/alsoinclude.txt', 'test.txt', )); } public function testFinderWithFilesAndDirectories() { $this->assertFinder( pht('With Files And Directories'), $this->newFinder() ->setGenerateChecksums(true) ->withPath('*') ->withSuffix('txt'), array( '.hidden.txt' => 'b6cfc9ce9afe12b258ee1c19c235aa27', 'file.txt' => '725130ba6441eadb4e5d807898e0beae', 'include_dir.txt' => null, 'include_dir.txt/anotherfile.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'include_dir.txt/subdir.txt' => null, 'include_dir.txt/subdir.txt/alsoinclude.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'test.txt' => 'aea46212fa8b8d0e0e6aa34a15c9e2f5', )); } public function testFinderWithDirectories() { $this->assertFinder( pht('Just Directories'), $this->newFinder() ->withType('d'), array( 'include_dir.txt', 'include_dir.txt/subdir.txt', )); } public function testFinderWithPath() { $this->assertFinder( pht('With Path'), $this->newFinder() ->setGenerateChecksums(true) ->withType('f') ->withPath('*/include_dir.txt/subdir.txt/alsoinclude.txt') ->withSuffix('txt'), array( 'include_dir.txt/subdir.txt/alsoinclude.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', )); } public function testFinderWithNames() { $this->assertFinder( pht('With Names'), $this->newFinder() ->withType('f') ->withPath('*') ->withName('test'), array( 'include_dir.txt/subdir.txt/test', 'include_dir.txt/test', 'test', )); } public function testFinderWithNameAndSuffix() { $this->assertFinder( pht('With Name and Suffix'), $this->newFinder() ->withType('f') ->withName('alsoinclude.txt') ->withSuffix('txt'), array( 'include_dir.txt/subdir.txt/alsoinclude.txt', )); } + public function testFinderWithGlobMagic() { + // Fill a temporary directory with all this magic garbage so we don't have + // to check a bunch of files with backslashes in their names into version + // control. + $tmp_dir = Filesystem::createTemporaryDirectory(); + + $crazy_magic = array( + 'backslash\\.\\*', + 'star-*.*', + 'star-*.txt', + 'star.t*t', + 'star.tesseract', + ); + + foreach ($crazy_magic as $sketchy_path) { + Filesystem::writeFile($tmp_dir.'/'.$sketchy_path, '.'); + } + + $this->assertFinder( + pht('Glob Magic, Literal .t*t'), + $this->newFinder($tmp_dir) + ->withType('f') + ->withSuffix('t*t'), + array( + 'star.t*t', + )); + + $this->assertFinder( + pht('Glob Magic, .tesseract'), + $this->newFinder($tmp_dir) + ->withType('f') + ->withSuffix('tesseract'), + array( + 'star.tesseract', + )); + + $this->assertFinder( + pht('Glob Magic, Name'), + $this->newFinder($tmp_dir) + ->withType('f') + ->withName('star-*'), + array()); + + $this->assertFinder( + pht('Glob Magic, Name + Suffix'), + $this->newFinder($tmp_dir) + ->withType('f') + ->withName('star-*.*'), + array( + 'star-*.*', + )); + + $this->assertFinder( + pht('Glob Magic, Backslash Suffix'), + $this->newFinder($tmp_dir) + ->withType('f') + ->withSuffix('\\*'), + array( + 'backslash\\.\\*', + )); + } + private function assertFinder($label, FileFinder $finder, $expect) { $modes = array( 'php', 'shell', ); foreach ($modes as $mode) { $actual = id(clone $finder) ->setForceMode($mode) ->find(); if ($finder->getGenerateChecksums()) { ksort($actual); } else { sort($actual); } $this->assertEqual( $expect, $actual, pht('Test Case "%s" in Mode "%s"', $label, $mode)); } } }