From 673800bd56f3fa561e000f41f85a7f8401690016 Mon Sep 17 00:00:00 2001 From: AnrDaemon Date: Fri, 7 Sep 2018 03:01:28 +0300 Subject: [PATCH] Added Utility\Path wrapper class. Utility\Path::info($path) - enhanced pathinfo() function. - always returns all components; - correctly report absence of extension for dot-files; - adds missing dot to exceptions. Utility\Path::normalize() - provides path normalization. - handles both *NIX and Windows (X:...) style paths; - handles leaking relative paths if explicitly allowed; - tripping an exception if relative path is trying to escape itself; - allows customizable output directory separator. --- CHANGES.txt | 59 +----------------- src/Utility/Path.php | 126 ++++++++++++++++++++++++++++++++++++++ test/Utility/PathTest.php | 74 ++++++++++++++++++++++ 3 files changed, 202 insertions(+), 57 deletions(-) create mode 100644 src/Utility/Path.php create mode 100644 test/Utility/PathTest.php diff --git a/CHANGES.txt b/CHANGES.txt index 01f753e..aad4396 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,61 +1,6 @@ ------------------------------------------------------------------------ -r900 | anrdaemon | 2018-09-04 19:03:27 +0300 (Tue, 04 Sep 2018) | 3 lines +r904 | anrdaemon | 2018-09-07 02:58:13 +0300 (Fri, 07 Sep 2018) | 2 lines -* Net\Browser: Improved docblocks for method callers. -= Sync'd .todo. - ------------------------------------------------------------------------- -r880 | anrdaemon | 2018-09-01 06:56:56 +0300 (Sat, 01 Sep 2018) | 2 lines - -+ Net\Browser: Added __clone and __destruct handlers. - ------------------------------------------------------------------------- -r879 | anrdaemon | 2018-09-01 06:35:50 +0300 (Sat, 01 Sep 2018) | 2 lines - -+ HTTP PUT and custom request method implementations. - ------------------------------------------------------------------------- -r878 | anrdaemon | 2018-09-01 06:07:17 +0300 (Sat, 01 Sep 2018) | 2 lines - -* Moved common request steps into a helper method. - ------------------------------------------------------------------------- -r877 | anrdaemon | 2018-09-01 05:52:07 +0300 (Sat, 01 Sep 2018) | 3 lines - -* Fixed Net\Browser::setOpt to behave and report. -+ Added CurlOptions helper to better report errors. - ------------------------------------------------------------------------- -r876 | anrdaemon | 2018-09-01 01:27:16 +0300 (Sat, 01 Sep 2018) | 2 lines - -+ Implement ability to return basic request status as a single array. - ------------------------------------------------------------------------- -r875 | anrdaemon | 2018-09-01 01:23:26 +0300 (Sat, 01 Sep 2018) | 4 lines - -+ Added a (b)lo(a)t of docblocks to Net\Browser. -* Prepared put and custom requests for existence. - Sad but I can't make it any better than that. - ------------------------------------------------------------------------- -r874 | anrdaemon | 2018-08-31 20:02:00 +0300 (Fri, 31 Aug 2018) | 2 lines - -* Repaired initial docblock, removing legacy behavior references. - ------------------------------------------------------------------------- -r873 | anrdaemon | 2018-08-31 18:44:46 +0300 (Fri, 31 Aug 2018) | 2 lines - -* Used assertEquals where applicable for better failure representation. - ------------------------------------------------------------------------- -r872 | anrdaemon | 2018-08-31 18:42:04 +0300 (Fri, 31 Aug 2018) | 3 lines - -* Reordered methods. -* Added PHPUnit v7 compatible exception trap. - ------------------------------------------------------------------------- -r871 | anrdaemon | 2018-08-31 16:20:04 +0300 (Fri, 31 Aug 2018) | 2 lines - -* Use static $url in tests. ++ Added Utility\Path class to fix/enhance standard functions. ------------------------------------------------------------------------ diff --git a/src/Utility/Path.php b/src/Utility/Path.php new file mode 100644 index 0000000..eafd0de --- /dev/null +++ b/src/Utility/Path.php @@ -0,0 +1,126 @@ + ""]; + } + + /** Path normalizer part examinator. + * @internal + * @throws \UnexpectedValueException if relative path is trying to escape above current directory, unless explicitly allowed. + */ + protected static function examine($part, array &$array, $path_relative, $allow_escape = false) + { + if($part === '.') + { + return; + } + + if($part !== '..') + { + $array[] = $part; + return; + } + + // $part == '..', handle escaping. + $last = end($array); + if($last === '..') + { // Escaping is allowed and we're already on the run. + $array[] = $part; + return; + } + + if($last !== false) + { // $last element exists - move up the stack. + array_pop($array); + return; + } + + if(!$path_relative) + { // Path is not relative - skip updir. + return; + } + + if(!$allow_escape) + throw new \UnexpectedValueException('Attempt to traverse outside the root directory.'); + + $array[] = $part; + } + + /** Normalize path string, removing '.'/'..'/empty components. + * + * Warning: This function is NOT intended to handle URL's ("//host/path")! + * Please use {@see \parse_url() parse_url()} first. + * + * @param string $path The path to normalize. + * @param bool $allow_escape Is the path relative? Defaults to autodetect. Paths declared explicitly relative get slightly different treatment. + * @param string $directory_separator Output directory separator. Defaults to DIRECTORY_SEPARATOR. + * @return string The normalized string. + * @throws \UnexpectedValueException if relative path is trying to escape above current directory, unless explicitly allowed. + */ + public static function normalize($path, $allow_escape = false, $directory_separator = DIRECTORY_SEPARATOR) + { + $path = (string)$path; + if($path === '') + return $path; + + $disk = null; + $path_relative = false; + + // If path is not explicitly relative, test if it's an absolute and possibly Windows path + // Convert first byte to uppercase. + $char = ord($path[0]) & 0xdf; + if($char & 0x80) + { // Multibyte character - path is relative + $path_relative = true; + } + // Windows disk prefix "{A..Z}:" + elseif(strlen($path) > 1 && $char > 0x40 && $char < 0x5b && $path[1] === ':') + { + if(strlen($path) === 2) + return $path; + + $disk = substr($path, 0, 2); + $path = substr($path, 2); + } + + if($path[0] !== "/" && $path[0] !== "\\") + { // First byte is not a slash + $path_relative = true; + } + + $ta = []; + $part = strtok($path, "/\\"); + while(false !== $part) + { + static::examine($part, $ta, $path_relative, $allow_escape); + $part = strtok("/\\"); + } + + return $disk . ($path_relative ? '' : $directory_separator) . join($directory_separator, $ta); + } +} diff --git a/test/Utility/PathTest.php b/test/Utility/PathTest.php new file mode 100644 index 0000000..3f017a3 --- /dev/null +++ b/test/Utility/PathTest.php @@ -0,0 +1,74 @@ + array("", ""), + "root" => array('/', '/'), + "relative" => array("foo", "foo"), + "dot dir" => array('/foo/bar', '/foo/./bar'), + "absolute path inescapable" => array('/', '/Foo/Bar/../../../..'), + "multi slash #1" => array('/', '//'), + "multi slash #2" => array('/', '///'), + "multi slash #3" => array('/Foo', '///Foo'), + "otherdir" => array('/bar', '/foo/../bar/'), + "windows disk only" => array("D:", "D:"), + "windows disk root" => array('c:/', 'c:\\'), + "windows disk relative" => array("e:g", "e:g"), + "windows multi slash #1" => array('c:/foo/bar', 'c:/foo//bar'), + "windows multi slash #2" => array('C:/foo/bar', 'C://foo//bar'), + "windows multi slash #3" => array('C:/foo/bar', 'C:///foo//bar'), + "windows otherdir" => array('C:/bar', 'C:/foo/../bar'), + ]; + + return $data; + } + + public function escapablePairsProvider() + { + $data = [ + "simple" => array('../foo', '../foo'), + "simple otherdir" => array('../bar', '../foo/../bar'), + "chained escape" => array("../../bar", "a/../../b/../../bar"), + "double collapse" => array('../src', 'Foo/Bar/../../../src'), + "windows otherdir" => array('c:../b', 'c:.\\..\\a\\..\\b'), + ]; + + return $data; + } + + /** Test normalization of standard pairs + * + * @dataProvider defaultPairsProvider + */ + public function testNormalizeStandardPair($target, $path) + { + $this->assertTrue($target === Path::normalize($path, null, "/")); + } + + /** Test normalization of escapable pairs + * + * @dataProvider escapablePairsProvider + */ + public function testNormalizeEscapingPair($target, $path) + { + $this->assertTrue($target === Path::normalize($path, true, "/")); + } + + /** Test exception on escape attempt + * + * @expectedException \UnexpectedValueException + */ + public function testExceptionOnEscapeAttempt() + { + $path = Path::normalize("..", false); + } +}