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