Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions interfaces/UriString.php
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ public static function removeDotSegments(Stringable|string $path): string
$newPath .= '/';
}

// A ".." overshooting the root must not turn an absolute path into a
// rootless one; RFC3986 §5.2.4 keeps the leading slash (as BaseUri does).
if (str_starts_with($path, '/') && !str_starts_with($newPath, '/')) {
return '/'.$newPath;
}

return $newPath;
}

Expand Down
40 changes: 40 additions & 0 deletions interfaces/UriStringTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1190,4 +1190,44 @@ public function test_it_resolve_correctly_uri_issue_184(): void
self::assertSame('g:h', UriString::resolve('g:h', $baseUri));
self::assertSame('foo:g', UriString::resolve('foo:g', $baseUri));
}

#[DataProvider('removeDotSegmentsProvider')]
public function test_it_keeps_the_leading_slash_of_an_absolute_path(string $path, string $expected): void
{
self::assertSame($expected, UriString::removeDotSegments($path));
}

/**
* @return iterable<string, array{path: string, expected: string}>
*/
public static function removeDotSegmentsProvider(): iterable
{
// A ".." popping past the root used to drop the leading slash, turning an
// absolute path into a rootless one (RFC 3986 §5.2.4 keeps the root).
yield 'overshoot single' => ['path' => '/../a', 'expected' => '/a'];
yield 'overshoot deep' => ['path' => '/a/b/../../../c', 'expected' => '/c'];
yield 'overshoot to root' => ['path' => '/../../', 'expected' => '/'];
yield 'overshoot trailing' => ['path' => '/../', 'expected' => '/'];
yield 'overshoot then dir' => ['path' => '/../a/b', 'expected' => '/a/b'];
yield 'overshoot then dot' => ['path' => '/.././g', 'expected' => '/g'];
yield 'bare dotdot absolute' => ['path' => '/..', 'expected' => '/'];

// Absolute paths that never overshoot must be unchanged.
yield 'absolute plain' => ['path' => '/a/b/../c', 'expected' => '/a/c'];
yield 'absolute mixed' => ['path' => '/a/b/c/./../../g', 'expected' => '/a/g'];
yield 'absolute trailing dot' => ['path' => '/a/.', 'expected' => '/a/'];

// Relative paths must stay relative: the guard only restores a dropped
// root, it must not promote a rootless path to an absolute one.
yield 'relative plain' => ['path' => 'a/b/../c', 'expected' => 'a/c'];
yield 'relative overshoot' => ['path' => 'a/../b', 'expected' => 'b'];
yield 'relative leading dot' => ['path' => './a', 'expected' => 'a'];
yield 'relative complex' => ['path' => 'mid/content=5/../6', 'expected' => 'mid/6'];
}

public function test_it_keeps_the_root_when_normalizing_an_authority_less_uri(): void
{
self::assertSame('foo:/a', UriString::normalize('foo:/../a'));
self::assertSame('file:/etc/passwd', UriString::normalize('file:/../etc/passwd'));
}
}
Loading