diff --git a/lib/Url.php b/lib/Url.php index feb78a8..6bc937d 100644 --- a/lib/Url.php +++ b/lib/Url.php @@ -55,6 +55,13 @@ PCRE; protected const PORT_PATTERN = '/^\d*$/'; protected const FORBIDDEN_HOST_PATTERN = '/[\x{00}\t\n\r #%\/:\?@\[\]\\\]/'; protected const WHITESPACE_CHARS = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x20"; + protected const PERCENT_ENCODE_SETS = [ + 'C0' => "", + 'fragment' => " \"<>`", + 'path' => " \"<>`?#{}", + 'userinfo' => " \"<>`?#{}/:;=@[\]^|", + 'query' => " \"<>#", // single-quote as well if scheme is special + ]; protected const ESCAPE_CHARS = [ 'user' => [":", "@", "/", "?", "#"], 'pass' => [":", "@", "/", "?", "#"], @@ -300,11 +307,11 @@ PCRE; } protected function setUser(string $value): void { - $this->user = $this->normalizeEncoding($value, "user"); + $this->user = $this->percentEncode($value, "userinfo"); } protected function setPass(string $value): void { - $this->pass = $this->normalizeEncoding($value, "pass"); + $this->pass = $this->percentEncode($value, "userinfo"); } protected function setHost(?string $value): void { @@ -330,14 +337,14 @@ PCRE; if ($this->specialScheme) { $value = str_replace("\\", "/", $value); } - $this->path = $this->normalizeEncoding($value, "path"); + $this->path = $this->percentEncode($value, $this->isUrn() ? "C0" : "path"); } protected function setQuery(?string $value): void { if (is_null($value)) { $this->query = $value; } else { - $this->query = $this->normalizeEncoding($value, "query"); + $this->query = $this->percentEncode($value, "query"); } } @@ -345,10 +352,26 @@ PCRE; if (is_null($value)) { $this->fragment = $value; } else { - $this->fragment = $this->normalizeEncoding($value, "fragment"); + $this->fragment = $this->percentEncode($value, "fragment"); } } + protected function percentEncode(string $data, string $type): string { + assert(array_key_exists($type, self::PERCENT_ENCODE_SETS), "Invalid percent-encoding set"); + $out = ""; + $end = strlen($data); + for ($p = 0; $p < $end; $p++) { + $c = $data[$p]; + $o = ord($c); + if ($o > 0x1F && $o < 0x7F && !strspn($c, self::PERCENT_ENCODE_SETS[$type]) && !($this->specialScheme && $type === "query" && $c === "'")) { + $out .= $c; + } else { + $out .= strtoupper("%".str_pad(dechex($o), 2, "0", \STR_PAD_LEFT)); + } + } + return $out; + } + protected function resolve(self $base): void { if ($base->isUrn()) { throw new \InvalidArgumentException("URL base must not be a Uniform Resource Name"); diff --git a/tests/cases/Util/Url/Psr7TestCase.php b/tests/cases/Util/Url/Psr7TestCase.php index 8f98222..72eed41 100644 --- a/tests/cases/Util/Url/Psr7TestCase.php +++ b/tests/cases/Util/Url/Psr7TestCase.php @@ -215,7 +215,7 @@ abstract class Psr7TestCase extends TestCase { public function queryProvider() { return [ - 'normalized query' => ['foo.bar=%7evalue', 'foo.bar=~value'], + 'normalized query' => ['foo.bar=%7evalue', 'foo.bar=%7evalue'], 'empty query' => ['', ''], 'same param query' => ['foo.bar=1&foo.bar=1', 'foo.bar=1&foo.bar=1'], 'same param query' => ['?foo=1', '?foo=1'], @@ -286,7 +286,7 @@ abstract class Psr7TestCase extends TestCase { 'path' => '/%7ejohndoe/%a1/index.php', 'query' => 'foo.bar=%7evalue', 'fragment' => 'fragment', - 'uri' => 'https://iGoR:rAsMuZeN@master.example.com/~johndoe/%A1/index.php?foo.bar=~value#fragment', + 'uri' => 'https://iGoR:rAsMuZeN@master.example.com/%7ejohndoe/%a1/index.php?foo.bar=%7evalue#fragment', ], 'URL without scheme' => [ 'scheme' => '', diff --git a/tests/cases/Util/Url/UrlTest.php b/tests/cases/Util/Url/UrlTest.php index 9943bc3..93b11fe 100644 --- a/tests/cases/Util/Url/UrlTest.php +++ b/tests/cases/Util/Url/UrlTest.php @@ -11,9 +11,6 @@ use MensBeam\Lax\Url; /** @covers MensBeam\Lax\Url */ class UrlTest extends Psr7TestCase { private const INCOMPLETE_STD_INPUT = [ - "a:\t foo.com", - "lolscheme:x x#x x", - "http://&a:foo(b]c@d:2/", ]; protected function createUri($uri = '') {