feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console
This commit is contained in:
1 parent
93d991703c
commit
9d277d9e72
48 files changed
+5147
-4
No files matched your search
@@ -0,0 +1,200 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user