Skip to content

Commit 2c1a1e6

Browse files
committed
Validate URI state instead of having workarounds in __toString according to PSR-7 errata
1 parent 5c6447c commit 2c1a1e6

File tree

3 files changed

+131
-41
lines changed

3 files changed

+131
-41
lines changed

composer.json

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
{
22
"name": "guzzlehttp/psr7",
33
"type": "library",
4-
"description": "PSR-7 message implementation",
5-
"keywords": ["message", "stream", "http", "uri"],
4+
"description": "PSR-7 message implementation that also provides common utility methods",
5+
"keywords": ["request", "response", "message", "stream", "http", "uri", "url"],
66
"license": "MIT",
77
"authors": [
88
{
99
"name": "Michael Dowling",
1010
"email": "[email protected]",
1111
"homepage": "https://github.com/mtdowling"
12+
},
13+
{
14+
"name": "Tobias Schultze",
15+
"homepage": "https://github.com/Tobion"
1216
}
1317
],
1418
"require": {

src/Uri.php

+48-21
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
*/
1313
class Uri implements UriInterface
1414
{
15+
/**
16+
* Absolute http and https URIs require a host per RFC 7230 Section 2.7
17+
* but in generic URIs the host can be empty. So for http(s) URIs
18+
* we apply this default host when no host is given yet to form a
19+
* valid URI.
20+
*/
21+
const HTTP_DEFAULT_HOST = 'localhost';
22+
1523
private static $schemes = [
1624
'http' => 80,
1725
'https' => 443,
@@ -241,16 +249,20 @@ public static function withQueryValue(UriInterface $uri, $key, $value)
241249
}
242250

243251
/**
244-
* Create a URI from a hash of parse_url parts.
252+
* Create a URI from a hash of parse_url components.
245253
*
246254
* @param array $parts
247255
*
248256
* @return self
257+
*
258+
* @throws \InvalidArgumentException If the components do not form a valid URI.
249259
*/
250260
public static function fromParts(array $parts)
251261
{
252262
$uri = new self();
253263
$uri->applyParts($parts);
264+
$uri->validateState();
265+
254266
return $uri;
255267
}
256268

@@ -261,10 +273,6 @@ public function getScheme()
261273

262274
public function getAuthority()
263275
{
264-
if ($this->host == '') {
265-
return '';
266-
}
267-
268276
$authority = $this->host;
269277
if ($this->userInfo != '') {
270278
$authority = $this->userInfo . '@' . $authority;
@@ -318,6 +326,8 @@ public function withScheme($scheme)
318326
$new = clone $this;
319327
$new->scheme = $scheme;
320328
$new->port = $new->filterPort($new->port);
329+
$new->validateState();
330+
321331
return $new;
322332
}
323333

@@ -334,6 +344,8 @@ public function withUserInfo($user, $password = null)
334344

335345
$new = clone $this;
336346
$new->userInfo = $info;
347+
$new->validateState();
348+
337349
return $new;
338350
}
339351

@@ -347,6 +359,8 @@ public function withHost($host)
347359

348360
$new = clone $this;
349361
$new->host = $host;
362+
$new->validateState();
363+
350364
return $new;
351365
}
352366

@@ -360,6 +374,8 @@ public function withPort($port)
360374

361375
$new = clone $this;
362376
$new->port = $port;
377+
$new->validateState();
378+
363379
return $new;
364380
}
365381

@@ -373,6 +389,8 @@ public function withPath($path)
373389

374390
$new = clone $this;
375391
$new->path = $path;
392+
$new->validateState();
393+
376394
return $new;
377395
}
378396

@@ -386,6 +404,7 @@ public function withQuery($query)
386404

387405
$new = clone $this;
388406
$new->query = $query;
407+
389408
return $new;
390409
}
391410

@@ -399,6 +418,7 @@ public function withFragment($fragment)
399418

400419
$new = clone $this;
401420
$new->fragment = $fragment;
421+
402422
return $new;
403423
}
404424

@@ -455,22 +475,7 @@ private static function createUriString($scheme, $authority, $path, $query, $fra
455475
$uri .= '//' . $authority;
456476
}
457477

458-
if ($path != '') {
459-
if ($path[0] !== '/') {
460-
if ($authority != '') {
461-
// If the path is rootless and an authority is present, the path MUST be prefixed by "/"
462-
$path = '/' . $path;
463-
}
464-
} elseif (isset($path[1]) && $path[1] === '/') {
465-
if ($authority == '') {
466-
// If the path is starting with more than one "/" and no authority is present, the
467-
// starting slashes MUST be reduced to one.
468-
$path = '/' . ltrim($path, '/');
469-
}
470-
}
471-
472-
$uri .= $path;
473-
}
478+
$uri .= $path;
474479

475480
if ($query != '') {
476481
$uri .= '?' . $query;
@@ -599,4 +604,26 @@ private function rawurlencodeMatchZero(array $match)
599604
{
600605
return rawurlencode($match[0]);
601606
}
607+
608+
private function validateState()
609+
{
610+
if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) {
611+
$this->host = self::HTTP_DEFAULT_HOST;
612+
}
613+
614+
if ($this->getAuthority() === '') {
615+
if (0 === strpos($this->path, '//')) {
616+
throw new \InvalidArgumentException('The path of a URI without an authority must not start with two slashes "//"');
617+
}
618+
} elseif (isset($this->path[0]) && $this->path[0] !== '/') {
619+
throw new \InvalidArgumentException('The path of a URI with an authority must start with a slash "/" or be empty');
620+
}
621+
622+
if ($this->scheme === ''
623+
&& false !== ($colonPos = strpos($this->path, ':'))
624+
&& ($colonPos < ($slashPos = strpos($this->path, '/')) || false === $slashPos)
625+
) {
626+
throw new \InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon');
627+
}
628+
}
602629
}

tests/UriTest.php

+77-18
Original file line numberDiff line numberDiff line change
@@ -434,12 +434,42 @@ public function testPortCanBeRemoved()
434434
$this->assertSame('http://example.com', (string) $uri);
435435
}
436436

437-
public function testAuthorityWithUserInfoButWithoutHost()
437+
/**
438+
* In RFC 8986 the host is optional and the authority can only
439+
* consist of the user info and port.
440+
*/
441+
public function testAuthorityWithUserInfoOrPortButWithoutHost()
438442
{
439443
$uri = (new Uri())->withUserInfo('user', 'pass');
440444

441445
$this->assertSame('user:pass', $uri->getUserInfo());
442-
$this->assertSame('', $uri->getAuthority());
446+
$this->assertSame('user:pass@', $uri->getAuthority());
447+
448+
$uri = $uri->withPort(8080);
449+
$this->assertSame(8080, $uri->getPort());
450+
$this->assertSame('user:pass@:8080', $uri->getAuthority());
451+
$this->assertSame('//user:pass@:8080', (string) $uri);
452+
453+
$uri = $uri->withUserInfo('');
454+
$this->assertSame(':8080', $uri->getAuthority());
455+
}
456+
457+
public function testHostInHttpUriDefaultsToLocalhost()
458+
{
459+
$uri = (new Uri())->withScheme('http');
460+
461+
$this->assertSame('localhost', $uri->getHost());
462+
$this->assertSame('localhost', $uri->getAuthority());
463+
$this->assertSame('http://localhost', (string) $uri);
464+
}
465+
466+
public function testHostInHttpsUriDefaultsToLocalhost()
467+
{
468+
$uri = (new Uri())->withScheme('https');
469+
470+
$this->assertSame('localhost', $uri->getHost());
471+
$this->assertSame('localhost', $uri->getAuthority());
472+
$this->assertSame('https://localhost', (string) $uri);
443473
}
444474

445475
public function uriComponentsEncodingProvider()
@@ -509,24 +539,53 @@ public function testAllowsForRelativeUri()
509539
$this->assertSame('foo', (string) $uri);
510540
}
511541

512-
public function testAddsSlashForRelativeUriStringWithHost()
542+
/**
543+
* @expectedException \InvalidArgumentException
544+
* @expectedExceptionMessage The path of a URI with an authority must start with a slash "/" or be empty
545+
*/
546+
public function testRelativePathAndAuhorityIsInvalid()
513547
{
514-
// If the path is rootless and an authority is present, the path MUST
515-
// be prefixed by "/".
516-
$uri = (new Uri)->withPath('foo')->withHost('example.com');
517-
$this->assertSame('foo', $uri->getPath());
518548
// concatenating a relative path with a host doesn't work: "//example.comfoo" would be wrong
519-
$this->assertSame('//example.com/foo', (string) $uri);
549+
(new Uri)->withPath('foo')->withHost('example.com');
520550
}
521551

522-
public function testRemoveExtraSlashesWihoutHost()
552+
/**
553+
* @expectedException \InvalidArgumentException
554+
* @expectedExceptionMessage The path of a URI without an authority must not start with two slashes "//"
555+
*/
556+
public function testPathStartingWithTwoSlashesAndNoAuthorityIsInvalid()
523557
{
524-
// If the path is starting with more than one "/" and no authority is
525-
// present, the starting slashes MUST be reduced to one.
526-
$uri = (new Uri)->withPath('//foo');
527-
$this->assertSame('//foo', $uri->getPath());
528558
// URI "//foo" would be interpreted as network reference and thus change the original path to the host
529-
$this->assertSame('/foo', (string) $uri);
559+
(new Uri)->withPath('//foo');
560+
}
561+
562+
public function testPathStartingWithTwoSlashes()
563+
{
564+
$uri = new Uri('http://example.org//path-not-host.com');
565+
$this->assertSame('//path-not-host.com', $uri->getPath());
566+
567+
$uri = $uri->withScheme('');
568+
$this->assertSame('//example.org//path-not-host.com', (string) $uri); // This is still valid
569+
$this->setExpectedException('\InvalidArgumentException');
570+
$uri->withHost(''); // Now it becomes invalid
571+
}
572+
573+
/**
574+
* @expectedException \InvalidArgumentException
575+
* @expectedExceptionMessage A relative URI must not have a path beginning with a segment containing a colon
576+
*/
577+
public function testRelativeUriWithPathBeginngWithColonSegmentIsInvalid()
578+
{
579+
(new Uri)->withPath('mailto:foo');
580+
}
581+
582+
public function testRelativeUriWithPathHavingColonSegment()
583+
{
584+
$uri = (new Uri('urn:/mailto:foo'))->withScheme('');
585+
$this->assertSame('/mailto:foo', $uri->getPath());
586+
587+
$this->setExpectedException('\InvalidArgumentException');
588+
(new Uri('urn:mailto:foo'))->withScheme('');
530589
}
531590

532591
public function testDefaultReturnValuesOfGetters()
@@ -559,15 +618,15 @@ public function testImmutability()
559618
public function testExtendingClassesInstantiates()
560619
{
561620
// The non-standard port triggers a cascade of private methods which
562-
// should not use late static binding to access private static members.
621+
// should not use late static binding to access private static members.
563622
// If they do, this will fatal.
564623
$this->assertInstanceOf(
565-
'\GuzzleHttp\Tests\Psr7\ExtendingClassTest',
566-
new ExtendingClassTest('http://h:9/')
624+
'GuzzleHttp\Tests\Psr7\ExtendedUriTest',
625+
new ExtendedUriTest('http://h:9/')
567626
);
568627
}
569628
}
570629

571-
class ExtendingClassTest extends \GuzzleHttp\Psr7\Uri
630+
class ExtendedUriTest extends Uri
572631
{
573632
}

0 commit comments

Comments
 (0)