Files
MeshVault/tests/Unit/Indexer/PathSafetyTest.php
T

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);
}
}
}