diff --git a/lib/REST/Target.php b/lib/REST/Target.php new file mode 100644 index 0000000..bde4c6c --- /dev/null +++ b/lib/REST/Target.php @@ -0,0 +1,131 @@ +parseFragment($target); + $target = $this->parseQuery($target); + $this->path = $this->parsePath($target); + } + + public function __toString(): string { + $out = ""; + $path = []; + foreach ($this->path as $segment) { + if (is_null($segment)) { + if (!$path) { + $path[] = ".."; + } else { + continue; + } + } elseif ($segment==".") { + $path[] = "%2E"; + } elseif ($segment=="..") { + $path[] = "%2E%2E"; + } else { + $path[] = rawurlencode(ValueInfo::normalize($segment, ValueInfo::T_STRING)); + } + } + $path = implode("/", $path); + if (!$this->relative) { + $out .= "/"; + } + $out .= $path; + if ($this->index && strlen($path)) { + $out .= "/"; + } + if (strlen($this->query)) { + $out .= "?".$this->query; + } + if (strlen($this->fragment)) { + $out .= "#".rawurlencode($this->fragment); + } + return $out; + } + + public static function normalize(string $target): string { + return (string) new self($target); + } + + protected function parseFragment(string $target): string { + // store and strip off any fragment identifier and return the target without a fragment + $pos = strpos($target,"#"); + if ($pos !== false) { + $this->fragment = rawurldecode(substr($target, $pos + 1)); + $target = substr($target, 0, $pos); + } + return $target; + } + + protected function parseQuery(string $target): string { + // store and strip off any query string and return the target without a query + // note that the function assumes any fragment identifier has already been stripped off + // unlike the other parts the query string is currently neither parsed nor normalized + $pos = strpos($target,"?"); + if ($pos !== false) { + $this->query = substr($target, $pos + 1); + $target = substr($target, 0, $pos); + } + return $target; + } + + protected function parsePath(string $target): array { + // note that the function assumes any fragment identifier or query has already been stripped off + // syntax-based normalization is applied to the path segments (see RFC 3986 sec. 6.2.2) + // duplicate slashes are NOT collapsed + if (substr($target, 0, 1)=="/") { + // if the path starts with a slash, strip it off + $target = substr($target, 1); + } else { + // otherwise this is a relative target + $this->relative = true; + } + if (!strlen($target)) { + // if the target is an empty string, this is an index target + $this->index = true; + } elseif (substr($target, -1, 1)=="/") { + // if the path ends in a slash, this is an index target and the slash should be stripped off + $this->index = true; + $target = substr($target, 0, strlen($target) -1); + } + // after stripping, explode the path parts + if (strlen($target)) { + $target = explode("/", $target); + $out = []; + // resolve relative path segments and decode each retained segment + foreach($target as $index => $segment) { + if ($segment==".") { + // self-referential segments can be ignored + continue; + } elseif ($segment=="..") { + if ($index==0) { + // if the first path segment refers to its parent (which we don't know about) we cannot output a correct path, so we do the best we can + $out[] = null; + } else { + // for any other segments after the first we pop off the last stored segment + array_pop($out); + } + } else { + // any other segment is decoded and retained + $out[] = rawurldecode($segment); + } + } + return $out; + } else { + return []; + } + } +} \ No newline at end of file diff --git a/tests/cases/REST/TestTarget.php b/tests/cases/REST/TestTarget.php new file mode 100644 index 0000000..08555d8 --- /dev/null +++ b/tests/cases/REST/TestTarget.php @@ -0,0 +1,66 @@ + */ +class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest { + + /** @dataProvider provideTargetUrls */ + public function testParseTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) { + $test = new Target($target); + $this->assertEquals($path, $test->path, "Path does not match"); + $this->assertSame($path, $test->path, "Path does not match exactly"); + $this->assertSame($relative, $test->relative, "Relative flag does not match"); + $this->assertSame($index, $test->index, "Index flag does not match"); + $this->assertSame($query, $test->query, "Query does not match"); + $this->assertSame($fragment, $test->fragment, "Fragment does not match"); + } + + /** @dataProvider provideTargetUrls */ + public function testNormalizeTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) { + $test = new Target(""); + $test->path = $path; + $test->relative = $relative; + $test->index = $index; + $test->query = $query; + $test->fragment = $fragment; + $this->assertSame($normalized, (string) $test); + $this->assertSame($normalized, Target::normalize($target)); + } + + public function provideTargetUrls() { + return [ + ["/", [], false, true, "", "", "/"], + ["", [], true, true, "", "", ""], + ["/index.php", ["index.php"], false, false, "", "", "/index.php"], + ["index.php", ["index.php"], true, false, "", "", "index.php"], + ["/ook/", ["ook"], false, true, "", "", "/ook/"], + ["ook/", ["ook"], true, true, "", "", "ook/"], + ["/eek/../ook/", ["ook"], false, true, "", "", "/ook/"], + ["eek/../ook/", ["ook"], true, true, "", "", "ook/"], + ["/./ook/", ["ook"], false, true, "", "", "/ook/"], + ["./ook/", ["ook"], true, true, "", "", "ook/"], + ["/../ook/", [null,"ook"], false, true, "", "", "/../ook/"], + ["../ook/", [null,"ook"], true, true, "", "", "../ook/"], + ["0", ["0"], true, false, "", "", "0"], + ["%6f%6F%6b", ["ook"], true, false, "", "", "ook"], + ["%2e%2E%2f%2E%2Fook%2f", [".././ook/"], true, false, "", "", "..%2F.%2Fook%2F"], + ["%2e%2E/%2E/ook%2f", ["..",".","ook/"], true, false, "", "", "%2E%2E/%2E/ook%2F"], + ["...", ["..."], true, false, "", "", "..."], + ["%2e%2e%2e", ["..."], true, false, "", "", "..."], + ["/?", [], false, true, "", "", "/"], + ["/#", [], false, true, "", "", "/"], + ["/?#", [], false, true, "", "", "/"], + ["#%2e", [], true, true, "", ".", "#."], + ["?%2e", [], true, true, "%2e", "", "?%2e"], + ["?%2e#%2f", [], true, true, "%2e", "/", "?%2e#%2F"], + ["#%2e?%2f", [], true, true, "", ".?/", "#.%3F%2F"], + ]; + } +} \ No newline at end of file diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 8ffa2b6..167ab87 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -65,15 +65,16 @@ cases/Db/SQLite3/Database/TestLabel.php cases/Db/SQLite3/Database/TestCleanup.php - - - cases/REST/NextCloudNews/TestVersions.php - cases/REST/NextCloudNews/TestV1_2.php - - - cases/REST/TinyTinyRSS/TestAPI.php - cases/REST/TinyTinyRSS/TestIcon.php - + + cases/REST/TestTarget.php + + + cases/REST/NextCloudNews/TestVersions.php + cases/REST/NextCloudNews/TestV1_2.php + + + cases/REST/TinyTinyRSS/TestAPI.php + cases/REST/TinyTinyRSS/TestIcon.php cases/Service/TestService.php