diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b2abef..d7e3a67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,8 @@ jobs: - name: Upload to Codecov uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} phpstan: runs-on: ubuntu-latest @@ -115,6 +117,7 @@ jobs: with: php_version: ${{ matrix.php }} path: src/ + level: 7 phpmd: runs-on: ubuntu-latest diff --git a/composer.json b/composer.json index 39e689e..a6a602d 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "phpgt/promise": "^2.4", "phpgt/async": "^1.0", "phpgt/json": "^2.2", - "phpgt/curl": "^3.1", + "phpgt/curl": "^3.2", "phpgt/propfunc": "^1.0", "psr/http-message": "^2.0", "willdurand/negotiation": "3.1.0" @@ -38,7 +38,7 @@ "scripts": { "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", - "phpstan": "vendor/bin/phpstan analyse --level 6 src", + "phpstan": "vendor/bin/phpstan analyse --memory-limit=512M --level 7 src", "phpcs": "vendor/bin/phpcs src --standard=phpcs.xml", "phpmd": "vendor/bin/phpmd src/ text phpmd.xml", "test": [ diff --git a/composer.lock b/composer.lock index 985af6c..655db44 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "17e56c43a9af1f0e12accac30c263245", + "content-hash": "e2fac02bbd38a4f5a8b7f59482c569f6", "packages": [ { "name": "justinrainbow/json-schema", @@ -204,16 +204,16 @@ }, { "name": "phpgt/curl", - "version": "v3.2.0", + "version": "v3.2.1", "source": { "type": "git", "url": "https://github.com/phpgt/Curl.git", - "reference": "cbeb514a700253a94200b3e91107f1fb0bb32b03" + "reference": "125cc2d2eef8656913c13dd11fb451115dc5de9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpgt/Curl/zipball/cbeb514a700253a94200b3e91107f1fb0bb32b03", - "reference": "cbeb514a700253a94200b3e91107f1fb0bb32b03", + "url": "https://api.github.com/repos/phpgt/Curl/zipball/125cc2d2eef8656913c13dd11fb451115dc5de9f", + "reference": "125cc2d2eef8656913c13dd11fb451115dc5de9f", "shasum": "" }, "require": { @@ -223,14 +223,15 @@ "phpgt/json": "^2.2" }, "require-dev": { - "phpmd/phpmd": "^2.13", + "phpmd/phpmd": "^2.15", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "^3.7" + "phpunit/phpunit": "^12.5", + "squizlabs/php_codesniffer": "^4.0" }, "type": "library", "autoload": { "psr-4": { + "GT\\Curl\\": "./src", "Gt\\Curl\\": "./src" } }, @@ -253,7 +254,7 @@ ], "support": { "issues": "https://github.com/phpgt/Curl/issues", - "source": "https://github.com/phpgt/Curl/tree/v3.2.0" + "source": "https://github.com/phpgt/Curl/tree/v3.2.1" }, "funding": [ { @@ -261,7 +262,7 @@ "type": "github" } ], - "time": "2025-12-09T22:55:13+00:00" + "time": "2026-03-18T22:36:08+00:00" }, { "name": "phpgt/dataobject", diff --git a/src/Header/Parser.php b/src/Header/Parser.php index 824bbe5..7a259e5 100644 --- a/src/Header/Parser.php +++ b/src/Header/Parser.php @@ -41,13 +41,15 @@ public function getKeyValues():array { protected function pregMatchProtocol(string $matchName):string { $headerLine = strtok($this->rawHeaders, "\n") ?: ""; /** @noinspection RegExpRedundantEscape */ - preg_match( + $matched = preg_match( "/HTTP\/(?P[0-9\.]+)\s*(?P\d+)?/", $headerLine, $matches ); - /** @var array $matches */ + if($matched !== 1) { + return ""; + } - return $matches[$matchName]; + return (string)($matches[$matchName] ?? ""); } } diff --git a/src/Message.php b/src/Message.php index e302e5c..3acb405 100644 --- a/src/Message.php +++ b/src/Message.php @@ -22,7 +22,7 @@ public function getProtocolVersion():string { } /** @inheritDoc */ - public function withProtocolVersion(string $version):self { + public function withProtocolVersion(string $version):static { if(!is_numeric($version)) { throw new InvalidProtocolHttpException($version); } @@ -105,7 +105,7 @@ public function getHeaderLine(string $name):string { * * @param string|string[] $value Header value(s). */ - public function withHeader(string $name, $value):self { + public function withHeader(string $name, $value):static { if(!is_array($value)) { $value = [$value]; } @@ -120,7 +120,7 @@ public function withHeader(string $name, $value):self { * * @param string|string[] $value Header value(s). */ - public function withAddedHeader(string $name, $value):self { + public function withAddedHeader(string $name, $value):static { if(!is_array($value)) { $value = [$value]; } @@ -131,14 +131,14 @@ public function withAddedHeader(string $name, $value):self { } /** @inheritDoc */ - public function withoutHeader(string $name):self { + public function withoutHeader(string $name):static { $clone = clone $this; $clone->headers->remove($name); return $clone; } /** @param array> $headers */ - public function setHeaders(array $headers):self { + public function setHeaders(array $headers):static { $clone = clone $this; $clone->headers->fromArray($headers); return $clone; @@ -153,7 +153,7 @@ public function getBody():StreamInterface { } /** @inheritDoc */ - public function withBody(StreamInterface $body):self { + public function withBody(StreamInterface $body):static { $clone = clone $this; $clone->stream = $body; return $clone; diff --git a/src/Request.php b/src/Request.php index b113ba6..801ec10 100644 --- a/src/Request.php +++ b/src/Request.php @@ -53,7 +53,7 @@ public function getRequestTarget():string { } /** @inheritDoc */ - public function withRequestTarget($requestTarget):self { + public function withRequestTarget($requestTarget):static { $clone = clone $this; $clone->requestTarget = $requestTarget; return $clone; @@ -68,7 +68,7 @@ public function getMethod():string { * @inheritDoc * @SuppressWarnings("StaticAccess") */ - public function withMethod(string $method):self { + public function withMethod(string $method):static { $method = RequestMethod::filterMethodName($method); $clone = clone $this; $clone->method = $method; @@ -81,7 +81,7 @@ public function getUri():UriInterface { } /** @inheritDoc */ - public function withUri(UriInterface $uri, bool$preserveHost = false):self { + public function withUri(UriInterface $uri, bool$preserveHost = false):static { $clone = clone $this; $host = $uri->getHost(); @@ -97,7 +97,7 @@ public function withUri(UriInterface $uri, bool$preserveHost = false):self { } /** @inheritDoc */ - public function withBody(StreamInterface|FormData $body):self { + public function withBody(StreamInterface|FormData $body):static { if($body instanceof FormData) { $stream = new Stream(); $stream->write((string)$body); diff --git a/src/RequestFactory.php b/src/RequestFactory.php index 2d658b6..84886b5 100644 --- a/src/RequestFactory.php +++ b/src/RequestFactory.php @@ -29,7 +29,6 @@ public function createServerRequestFromGlobalState( $headers = $this->buildRequestHeaders($server); - /** @var ServerRequestInterface $serverRequest */ $serverRequest = $this->buildRequest( $method, $uri, @@ -58,7 +57,7 @@ public function buildRequest( array $get, array $post, string $inputPath - ):ServerRequest|Request { + ):ServerRequestInterface { $request = new ServerRequest( $method, $uri, diff --git a/src/Response.php b/src/Response.php index 32fe744..b68c22e 100644 --- a/src/Response.php +++ b/src/Response.php @@ -1,7 +1,7 @@ statusCode]; + return StatusCode::REASON_PHRASE[$this->statusCode ?? 0] ?? ""; } public function getResponseHeaders():ResponseHeaders { diff --git a/src/Stream.php b/src/Stream.php index 629a8d2..db5550f 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -38,12 +38,11 @@ public function __construct( $this->stream = $stream; - $streamInfo = stream_get_meta_data($this->stream); - $this->isSeekable = $streamInfo["seekable"]; - /** @phpstan-ignore-next-line */ - $this->uri = $streamInfo["uri"] ?? ""; - $this->isReadable = in_array($streamInfo["mode"], self::READABLE_MODES); - $this->isWritable = in_array($streamInfo["mode"], self::WRITABLE_MODES); + $streamInfo = stream_get_meta_data($this->stream); + $this->isSeekable = $streamInfo["seekable"]; + $this->uri = $streamInfo["uri"] ?? ""; + $this->isReadable = in_array($streamInfo["mode"], self::READABLE_MODES); + $this->isWritable = in_array($streamInfo["mode"], self::WRITABLE_MODES); } /** @inheritDoc */ @@ -63,7 +62,7 @@ public function close():void { public function detach() { /** @var resource|null $stream */ $stream = $this->stream; - unset($this->stream); + $this->stream = null; return $stream; } diff --git a/src/Uri.php b/src/Uri.php index 31a0719..6ed1b4b 100644 --- a/src/Uri.php +++ b/src/Uri.php @@ -42,6 +42,11 @@ protected function parseUri(string $uri):false|array { return $authorityStyleParts; } + $hostLikeParts = $this->parseHostLikeParts($uri, $parts); + if(!is_null($hostLikeParts)) { + return $hostLikeParts; + } + return $parts; } @@ -77,6 +82,34 @@ protected function parseAuthorityStyleParts( return $authorityParts; } + /** + * @param array $parts + * @return null|array + */ + protected function parseHostLikeParts( + string $uri, + array $parts + ):?array { + if(!$this->canBeHostLikeUri($uri, $parts)) { + return null; + } + + $hostLikeParts = parse_url("//" . $uri); + if($hostLikeParts === false || !isset($hostLikeParts["host"])) { + return null; + } + + if(!$this->isAuthorityPathValid($hostLikeParts)) { + return null; + } + + if(!$this->isAuthorityHostLike($hostLikeParts)) { + return null; + } + + return $hostLikeParts; + } + /** @param array $parts */ protected function canBeAuthorityStyleUri(string $uri, array $parts):bool { if(str_contains($uri, "://")) { @@ -91,6 +124,33 @@ protected function canBeAuthorityStyleUri(string $uri, array $parts):bool { return str_contains($path, "@"); } + /** @param array $parts */ + protected function canBeHostLikeUri(string $uri, array $parts):bool { + if(str_contains($uri, "://")) { + return false; + } + + if(isset($parts["host"])) { + return false; + } + + if(isset($parts["scheme"])) { + return $this->isHostLikeString((string)$parts["scheme"]); + } + + if(!isset($parts["path"])) { + return false; + } + + $path = (string)$parts["path"]; + if($path === "" || str_starts_with($path, "/")) { + return false; + } + + $firstSegment = explode("/", $path, 2)[0]; + return $this->isHostLikeString($firstSegment); + } + /** @param array $authorityParts */ protected function hasRequiredAuthorityParts(array $authorityParts):bool { return isset($authorityParts["user"], $authorityParts["pass"], $authorityParts["host"]); @@ -104,7 +164,16 @@ protected function isAuthorityPathValid(array $authorityParts):bool { /** @param array $authorityParts */ protected function isAuthorityHostLike(array $authorityParts):bool { - $host = (string)$authorityParts["host"]; + return $this->isHostLikeString((string)$authorityParts["host"]); + } + + protected function isHostLikeString(string $host):bool { + if($host === "." + || $host === ".." + || str_starts_with($host, ".")) { + return false; + } + if(filter_var($host, FILTER_VALIDATE_IP) !== false) { return true; } diff --git a/test/phpunit/UriTest.php b/test/phpunit/UriTest.php index a1088a3..3e58103 100644 --- a/test/phpunit/UriTest.php +++ b/test/phpunit/UriTest.php @@ -182,6 +182,41 @@ public function testBuildsAuthorityFromCredentialsWithoutScheme() { $this->assertSame('admin:admin@10.10.0.8', $uri->getAuthority()); } + public function testParsesAuthorityStyleUserWithoutPasswordAndWithoutScheme() { + $uri = new Uri('admin@10.10.0.8/status.xml'); + $this->assertSame('', $uri->getScheme()); + $this->assertSame('admin', $uri->getUserInfo()); + $this->assertSame('10.10.0.8', $uri->getHost()); + $this->assertSame('/status.xml', $uri->getPath()); + } + + public function testParsesLocalhostWithoutSchemeAsHost() { + $uri = new Uri('localhost/status'); + $this->assertSame('localhost', $uri->getHost()); + $this->assertSame('/status', $uri->getPath()); + } + + public function testKeepsSingleLabelPathWithoutSchemeAsRelativePath() { + $uri = new Uri('printer/status'); + $this->assertSame('', $uri->getHost()); + $this->assertSame('printer/status', $uri->getPath()); + } + + public function testParsesIpv6HostAndPortWithoutScheme() { + $uri = new Uri('[2001:db8::1]:8080/a'); + $this->assertSame('[2001:db8::1]', $uri->getHost()); + $this->assertSame(8080, $uri->getPort()); + $this->assertSame('/a', $uri->getPath()); + } + + public function testParsesHostPortAndQueryWithoutScheme() { + $uri = new Uri('10.0.0.1:4321?x=1'); + $this->assertSame('10.0.0.1', $uri->getHost()); + $this->assertSame(4321, $uri->getPort()); + $this->assertSame('', $uri->getPath()); + $this->assertSame('x=1', $uri->getQuery()); + } + public function testKeepsOriginalParseWhenAuthorityFallbackCannotParseDoubleSlash() { $uri = new Uri('admin:admin@?/x'); $this->assertSame('admin', $uri->getScheme()); @@ -638,4 +673,29 @@ public function testGetQueryValue() { self::assertSame("orange", $uri->getQueryValue("colour")); self::assertNull($uri->getQueryValue("age")); } + + public function testConstruct_noPathJustHost():void { + $uri = new Uri("10.0.0.1"); + self::assertSame("10.0.0.1", $uri->getHost()); + } + + public function testConstruct_pathAndHost():void { + $uri = new Uri("10.0.0.1/tagbatch"); + self::assertSame("10.0.0.1", $uri->getHost()); + self::assertSame("/tagbatch", $uri->getPath()); + } + + public function testConstruct_pathWithQueryAndHost():void { + $uri = new Uri("10.0.0.1/tagbatch?id=123"); + self::assertSame("10.0.0.1", $uri->getHost()); + self::assertSame("/tagbatch", $uri->getPath()); + self::assertSame("id=123", $uri->getQuery()); + self::assertSame("123", $uri->getQueryValue("id")); + } + + public function testConstruct_pathWithQueryAndHostAndPort():void { + $uri = new Uri("10.0.0.1:4321/tagbatch?id=123"); + self::assertSame("10.0.0.1", $uri->getHost()); + self::assertSame(4321, $uri->getPort()); + } }