201 lines
8.5 KiB
PHP
201 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MeshVault\Tests\Unit\Indexer;
|
|
|
|
use MeshVault\Exception\PathSafetyException;
|
|
use MeshVault\Indexer\Filesystem\PathSafety;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class PathSafetyTest extends TestCase
|
|
{
|
|
private string $base;
|
|
private string $root;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->base = dirname(__DIR__, 3) . '/.phpunit.cache/paths-' . bin2hex(random_bytes(6));
|
|
mkdir($this->base, 0777, true);
|
|
$this->base = realpath($this->base);
|
|
$this->root = $this->base . '/library';
|
|
mkdir($this->root);
|
|
mkdir($this->base . '/library-sibling');
|
|
file_put_contents($this->base . '/source.txt', 'source');
|
|
file_put_contents($this->root . '/existing.txt', 'existing');
|
|
self::assertTrue(class_exists(PathSafety::class), 'PathSafety must exist');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$remove = static function (string $path) use (&$remove): void {
|
|
if (is_link($path) || !is_dir($path)) {
|
|
unlink($path);
|
|
return;
|
|
}
|
|
foreach (scandir($path) as $name) {
|
|
if ($name !== '.' && $name !== '..') {
|
|
$remove($path . '/' . $name);
|
|
}
|
|
}
|
|
rmdir($path);
|
|
};
|
|
$remove($this->base);
|
|
}
|
|
|
|
public function testCanonicalSourcesAndExistingAndNewDestinations(): void
|
|
{
|
|
$safety = new PathSafety($this->root);
|
|
self::assertSame(realpath($this->base . '/source.txt'), $safety->ensureSourceSafe($this->base . '/source.txt'));
|
|
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($this->root));
|
|
self::assertSame(realpath($this->root), $safety->ensureDestSafe($this->root));
|
|
self::assertSame(realpath($this->root . '/existing.txt'), $safety->ensureDestSafe('existing.txt'));
|
|
self::assertSame(
|
|
realpath($this->root . '/existing.txt'),
|
|
$safety->ensureDestSafe($this->root . '/existing.txt')
|
|
);
|
|
$expected = realpath($this->root) . DIRECTORY_SEPARATOR . 'new' . DIRECTORY_SEPARATOR . 'model.stl';
|
|
self::assertSame($expected, $safety->ensureDestSafe('new/model.stl'));
|
|
self::assertSame($expected, $safety->ensureDestSafe($this->root . '/new/model.stl'));
|
|
self::assertDirectoryDoesNotExist($this->root . '/new');
|
|
self::assertSame('models/model.stl', $safety->ensureRelative('models/model.stl'));
|
|
}
|
|
|
|
public function testSourceAndConstructorBackslashPolicy(): void
|
|
{
|
|
$safety = new PathSafety($this->root);
|
|
$path = $this->base . '\\library';
|
|
if (PHP_OS_FAMILY === 'Windows') {
|
|
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($path));
|
|
self::assertSame(realpath($this->root), (new PathSafety($path))->ensureSourceSafe($path));
|
|
} else {
|
|
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
|
|
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
|
|
}
|
|
}
|
|
|
|
public static function unsafeRelativePaths(): iterable
|
|
{
|
|
foreach (['/tmp/a', '\\rooted', 'C:/file', 'C:relative', '\\\\server\\share', '//server/share'] as $path) {
|
|
yield [$path, 'ABSOLUTE_PATH'];
|
|
}
|
|
foreach (['../file', 'a/../file', 'a\\..\\file', '..'] as $path) {
|
|
yield [$path, 'PATH_TRAVERSAL'];
|
|
}
|
|
yield ["a\0b", 'INVALID_PATH'];
|
|
yield ['', 'INVALID_PATH'];
|
|
yield ['php://memory', 'INVALID_PATH'];
|
|
}
|
|
|
|
#[DataProvider('unsafeRelativePaths')]
|
|
public function testUnsafeRelativePaths(string $path, string $reason): void
|
|
{
|
|
$this->rejects(fn () => (new PathSafety($this->root))->ensureRelative($path), $reason);
|
|
}
|
|
|
|
public function testMissingTraversalEscapeAndNonDirectoryAncestor(): void
|
|
{
|
|
$safety = new PathSafety($this->root);
|
|
$this->rejects(fn () => $safety->ensureSourceSafe($this->base . '/absent'), 'PATH_NOT_FOUND');
|
|
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
|
|
$this->rejects(fn () => $safety->$method($this->root . '/../source.txt'), 'PATH_TRAVERSAL');
|
|
$reason = $method === 'ensureSourceSafe' && PHP_OS_FAMILY !== 'Windows'
|
|
? 'INVALID_PATH'
|
|
: 'PATH_TRAVERSAL';
|
|
$this->rejects(fn () => $safety->$method($this->root . '/a\\..\\source.txt'), $reason);
|
|
$this->rejects(fn () => $safety->$method("bad\0path"), 'INVALID_PATH');
|
|
}
|
|
foreach ([$this->base . '/source.txt', $this->base . '/library-sibling/new/file'] as $path) {
|
|
$this->rejects(fn () => $safety->ensureDestSafe($path), 'OUTSIDE_LIBRARY_ROOT');
|
|
}
|
|
$this->rejects(fn () => $safety->ensureDestSafe('existing.txt/child'), 'NOT_DIRECTORY');
|
|
$this->rejects(fn () => new PathSafety($this->base . '/absent'), 'PATH_NOT_FOUND');
|
|
$this->rejects(fn () => new PathSafety($this->base . '/source.txt'), 'NOT_DIRECTORY');
|
|
}
|
|
|
|
public static function driveRelativeOperations(): iterable
|
|
{
|
|
yield 'source uppercase drive' => [false, false];
|
|
yield 'source lowercase drive' => [false, true];
|
|
yield 'root uppercase drive' => [true, false];
|
|
yield 'root lowercase drive' => [true, true];
|
|
}
|
|
|
|
#[DataProvider('driveRelativeOperations')]
|
|
public function testExistingDriveRelativePathsRejected(bool $constructor, bool $lowercase): void
|
|
{
|
|
if (PHP_OS_FAMILY !== 'Windows') {
|
|
self::markTestSkipped('Drive-relative resolution requires Windows.');
|
|
}
|
|
$safety = new PathSafety($this->root);
|
|
$drive = substr($this->base, 0, 2);
|
|
$drive = $lowercase ? strtolower($drive) : strtoupper($drive);
|
|
$name = $constructor ? 'library' : 'source.txt';
|
|
$relativeBase = substr($this->base, 3);
|
|
$path = $drive . $relativeBase . '/' . $name;
|
|
self::assertSame(realpath($this->base . '/' . $name), realpath($path));
|
|
$this->rejects(
|
|
fn () => $constructor ? new PathSafety($path) : $safety->ensureSourceSafe($path),
|
|
'INVALID_PATH'
|
|
);
|
|
}
|
|
|
|
public function testEveryDriveLetterRejectedForSourcesAndRoots(): void
|
|
{
|
|
$safety = new PathSafety($this->root);
|
|
foreach (array_merge(range('A', 'Z'), range('a', 'z')) as $drive) {
|
|
foreach (['', 'file', './file', '.\\file'] as $suffix) {
|
|
$path = $drive . ':' . $suffix;
|
|
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
|
|
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testWindowsAmbiguousNamesCannotBypassLexicalChecks(): void
|
|
{
|
|
if (PHP_OS_FAMILY !== 'Windows') {
|
|
self::markTestSkipped('Windows path aliases do not apply on this platform.');
|
|
}
|
|
$safety = new PathSafety($this->root);
|
|
foreach (['.. /source.txt', 'existing.txt:stream', 'new./child', 'new /child'] as $path) {
|
|
foreach (['ensureSourceSafe', 'ensureDestSafe', 'ensureRelative'] as $method) {
|
|
$this->rejects(fn () => $safety->$method($path), 'INVALID_PATH');
|
|
}
|
|
}
|
|
}
|
|
|
|
public static function links(): iterable
|
|
{
|
|
yield 'file' => ['source.txt', 'link', ''];
|
|
yield 'directory ancestor' => ['library', 'link', '/existing.txt'];
|
|
yield 'missing child of ancestor' => ['library', 'link', '/new/child'];
|
|
yield 'dangling' => ['absent', 'link', ''];
|
|
}
|
|
|
|
#[DataProvider('links')]
|
|
public function testLinksRejectedWithoutFollowingThem(string $target, string $name, string $suffix): void
|
|
{
|
|
$link = $this->root . '/' . $name;
|
|
if (!function_exists('symlink') || !@symlink($this->base . '/' . $target, $link)) {
|
|
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
|
|
}
|
|
$safety = new PathSafety($this->root);
|
|
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
|
|
$this->rejects(fn () => $safety->$method($link . $suffix), 'SYMLINK_ESCAPE');
|
|
}
|
|
$this->rejects(fn () => new PathSafety($link . $suffix), 'SYMLINK_ESCAPE');
|
|
}
|
|
|
|
private function rejects(callable $operation, string $reason): void
|
|
{
|
|
try {
|
|
$operation();
|
|
self::fail('Expected path rejection: ' . $reason);
|
|
} catch (PathSafetyException $exception) {
|
|
self::assertSame($reason, $exception->reason);
|
|
}
|
|
}
|
|
}
|