diff --git a/src/AbsolutePathInterface.php b/src/AbsolutePathInterface.php index 35285cd..a8e16e0 100644 --- a/src/AbsolutePathInterface.php +++ b/src/AbsolutePathInterface.php @@ -9,5 +9,5 @@ interface AbsolutePathInterface extends PathInterface /** * @param static $targetPath */ - public function makeRelative(self $targetPath): RelativePathInterface; + public function makeRelative(self $targetPath, ?\Closure $equals = null): RelativePathInterface; } diff --git a/src/AbstractAbsolutePath.php b/src/AbstractAbsolutePath.php index 4405074..5448bf0 100644 --- a/src/AbstractAbsolutePath.php +++ b/src/AbstractAbsolutePath.php @@ -14,7 +14,7 @@ abstract class AbstractAbsolutePath extends AbstractPath implements AbsolutePath /** * @param static $targetPath */ - public function makeRelative(AbsolutePathInterface $targetPath): RelativePathInterface + public function makeRelative(AbsolutePathInterface $targetPath, ?\Closure $equals = null): RelativePathInterface { if (\get_class($this) !== \get_class($targetPath) || $this->prefix !== $targetPath->prefix) { throw new \InvalidArgumentException( @@ -28,9 +28,10 @@ abstract class AbstractAbsolutePath extends AbstractPath implements AbsolutePath } $length = min($this->components->count(), $targetPath->components->count()); + $equals ??= fn($a, $b) => $a === $b; for ($i = 0; $i < $length; $i++) { - if ($this->components[$i] !== $targetPath->components[$i]) { + if (!$equals($this->components[$i], $targetPath->components[$i])) { break; } } diff --git a/src/WindowsPath.php b/src/WindowsPath.php new file mode 100644 index 0000000..36c066b --- /dev/null +++ b/src/WindowsPath.php @@ -0,0 +1,104 @@ +parseDOS($prefix, $restOfPath, $strict); + } elseif (preg_match('@^\\\\\\\\[.?]\\\\([^\\\\/]+)\\\\@', $path, $matches)) { + // UNC local volume + $prefix = $matches[0]; + + // if the volume is a drive letter, uppercase it + if (preg_match('/^[a-zA-Z]:$/', $matches[1])) { + // \\?\C:\ + $prefix[4] = strtoupper($prefix[4]); + } + + $restOfPath = substr($path, \strlen($prefix)); + + $this->parseUNC($prefix, $restOfPath); + } elseif (preg_match('@^\\\\\\\\[^.?\\\\/][^\\\\/]*\\\\|\\\\\\\\[.?][^\\\\/][^\\\\/]+\\\\@', $path, $matches)) { + $prefix = $matches[0]; + $restOfPath = substr($path, \strlen($prefix)); + + $this->parseUNC($prefix, $restOfPath); + } else { + throw new \InvalidArgumentException('Unrecognized Windows path'); + } + } + + private function parseDOS(string $prefix, string $restOfPath, bool $strict): void + { + // forward slash is also a valid path separator in DOS paths + // just also parse backslashes + $components = explode('/', $restOfPath); + $components = array_merge( + ...array_map(fn ($a) => explode('\\', $a), $components) + ); + + $parsedComponents = $this->normalize($components); + + if ($parsedComponents[0] === '..') { + if ($strict) { + throw new \InvalidArgumentException('Path went beyond root'); + } + + do { + $parsedComponents->shift(); + } while ($parsedComponents[0] === '..'); + } + + // normalize prefix: use backslash + $this->prefix = strtr($prefix, ['/' => '\\']); + $this->components = $parsedComponents; + } + + // no $strict param, UNC is always strict + private function parseUNC(string $prefix, string $restOfPath): void + { + if (str_contains($restOfPath, '/')) { + throw new \InvalidArgumentException('Slashes are not allowed in UNC paths'); + } + + $components = explode('\\', $restOfPath); + + foreach ($components as $component) { + if ($component === '.' || $component === '..') { + throw new \InvalidArgumentException('. and .. are not allowed in UNC paths'); + } + } + + $this->prefix = $prefix; + $this->components = DataTypeHelper::iterableToNewListInstance($components); + } + + public function toString(): string + { + return $this->prefix . \iter\join('\\', $this->components); + } + + protected function buildRelative(\SplDoublyLinkedList $components): RelativePathInterface + { + $path = new RelativePath('.', true); + $path->components = $components; + + return $path; + } +} diff --git a/tests/WindowsPathTest.php b/tests/WindowsPathTest.php new file mode 100644 index 0000000..721de7f --- /dev/null +++ b/tests/WindowsPathTest.php @@ -0,0 +1,293 @@ +toString()); + self::assertEquals('C:\\', $path->getPrefix()); + + // dos path to be normalized + $path = WindowsPath::parse('z:/home/arokettu/wine/./path\\Games\\Something/../../Tools/file.exe'); + self::assertEquals('Z:\\home\\arokettu\\wine\\path\\Tools\\file.exe', $path->toString()); + self::assertEquals('Z:\\', $path->getPrefix()); + + // non strictly valid dos path + $path = WindowsPath::parse('C:\\..\\..\\I\\Am\\Windows\\Path'); + self::assertEquals('C:\\I\\Am\\Windows\\Path', $path->toString()); + self::assertEquals('C:\\', $path->getPrefix()); + + // local unc path (drive letter) + $path = WindowsPath::parse('\\\\.\\c:\\windows\\win.ini'); + self::assertEquals('\\\\.\\C:\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\.\\C:\\', $path->getPrefix()); + + // local unc path (guid) + $path = WindowsPath::parse('\\\\?\\Volume{D4AF2203-A75B-4CB1-9B93-AE78EB9A50A5}\\windows\\win.ini'); + self::assertEquals('\\\\?\\Volume{D4AF2203-A75B-4CB1-9B93-AE78EB9A50A5}\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\?\\Volume{D4AF2203-A75B-4CB1-9B93-AE78EB9A50A5}\\', $path->getPrefix()); + + // remote share + $path = WindowsPath::parse('\\\\MYPC\\c$\\windows\\win.ini'); + self::assertEquals('\\\\MYPC\\c$\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\MYPC\\', $path->getPrefix()); + + // remote share single char + $path = WindowsPath::parse('\\\\M\\c$\\windows\\win.ini'); + self::assertEquals('\\\\M\\c$\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\M\\', $path->getPrefix()); + + // remote share starts with dot + $path = WindowsPath::parse('\\\\.MYPC\\c$\\windows\\win.ini'); + self::assertEquals('\\\\.MYPC\\c$\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\.MYPC\\', $path->getPrefix()); + } + + public function testCreateInvalidNotAWinPath(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unrecognized Windows path'); + + WindowsPath::parse('/home/arokettu'); + } + + public function testCreateInvalidRelativeWithALetter(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unrecognized Windows path'); + + // technically valid but usually useless + WindowsPath::parse('c:windows\win.ini'); + } + + public function testCreateInvalidUNCWithSlash(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Slashes are not allowed in UNC paths'); + + // technically valid but usually useless + WindowsPath::parse('\\\\MYPC\\c$/Windows'); + } + + public function testCreateInvalidUNCWithDots(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('. and .. are not allowed in UNC paths'); + + // technically valid but usually useless + WindowsPath::parse('\\\\MYPC\\c$\\Windows\\..\\Users\\'); + } + + public function testCreateStrict(): void + { + // simple dos path + $path = WindowsPath::parse('C:\\I\\Am\\Windows\\Path', true); + self::assertEquals('C:\\I\\Am\\Windows\\Path', $path->toString()); + self::assertEquals('C:\\', $path->getPrefix()); + + // dos path to be normalized + $path = WindowsPath::parse('z:/home/arokettu/wine/./path\\Games\\Something/../../Tools/file.exe', true); + self::assertEquals('Z:\\home\\arokettu\\wine\\path\\Tools\\file.exe', $path->toString()); + self::assertEquals('Z:\\', $path->getPrefix()); + + // local unc path (drive letter) + $path = WindowsPath::parse('\\\\.\\c:\\windows\\win.ini', true); + self::assertEquals('\\\\.\\C:\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\.\\C:\\', $path->getPrefix()); + + // local unc path (guid) + $path = WindowsPath::parse('\\\\?\\Volume{D4AF2203-A75B-4CB1-9B93-AE78EB9A50A5}\\windows\\win.ini', true); + self::assertEquals('\\\\?\\Volume{D4AF2203-A75B-4CB1-9B93-AE78EB9A50A5}\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\?\\Volume{D4AF2203-A75B-4CB1-9B93-AE78EB9A50A5}\\', $path->getPrefix()); + + // remote share + $path = WindowsPath::parse('\\\\MYPC\\c$\\windows\\win.ini', true); + self::assertEquals('\\\\MYPC\\c$\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\MYPC\\', $path->getPrefix()); + + // remote share single char + $path = WindowsPath::parse('\\\\M\\c$\\windows\\win.ini', true); + self::assertEquals('\\\\M\\c$\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\M\\', $path->getPrefix()); + + // remote share starts with dot + $path = WindowsPath::parse('\\\\.MYPC\\c$\\windows\\win.ini', true); + self::assertEquals('\\\\.MYPC\\c$\\windows\\win.ini', $path->toString()); + self::assertEquals('\\\\.MYPC\\', $path->getPrefix()); + } + + public function testCreateStrictInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Path went beyond root'); + + $path = WindowsPath::parse('C:\\..\\..\\I\\Am\\Windows\\Path', true); + self::assertEquals('C:\\I\\Am\\Windows\\Path', $path->toString()); + self::assertEquals('C:\\', $path->getPrefix()); + } + + public function testResolveRelative(): void + { + $path = WindowsPath::parse('c:\\i\\am\\test\\windows\\path'); + + $rp = RelativePath::windows('\\i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp)->toString() + ); + + $rp = RelativePath::windows('i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\windows\\path\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp)->toString() + ); + + $rp = RelativePath::windows('..\\..\\i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp)->toString() + ); + + $rp = RelativePath::windows('..\\..\\..\\..\\..\\..\\..\\..\\i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp)->toString() + ); + + $rp = RelativePath::windows('..'); + self::assertEquals( + 'C:\\i\\am\\test\\windows', + $path->resolveRelative($rp)->toString() + ); + + $rp = RelativePath::windows('.'); + self::assertEquals( + 'C:\\i\\am\\test\\windows\\path', + $path->resolveRelative($rp)->toString() + ); + } + + public function testResolveRelativeStrict(): void + { + $path = WindowsPath::parse('c:\\i\\am\\test\\windows\\path'); + + $rp = RelativePath::windows('\\i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp, true)->toString() + ); + + $rp = RelativePath::windows('i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\windows\\path\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp, true)->toString() + ); + + $rp = RelativePath::windows('..\\..\\i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp, true)->toString() + ); + + $rp = RelativePath::windows('..'); + self::assertEquals( + 'C:\\i\\am\\test\\windows', + $path->resolveRelative($rp, true)->toString() + ); + + $rp = RelativePath::windows('.'); + self::assertEquals( + 'C:\\i\\am\\test\\windows\\path', + $path->resolveRelative($rp, true)->toString() + ); + } + + public function testResolveRelativeStrictInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Relative path went beyond root'); + + $path = WindowsPath::parse('c:\\i\\am\\test\\windows\\path'); + + $rp = RelativePath::windows('..\\..\\..\\..\\..\\..\\..\\..\\i\\am\\test\\relative\\path'); + self::assertEquals( + 'C:\\i\\am\\test\\relative\\path', + $path->resolveRelative($rp, true)->toString() + ); + } + + public function testMakeRelative(): void + { + $paths = [ + WindowsPath::parse('C:\\i\\am\\test\\windows\\path'), + WindowsPath::parse('C:\\i\\aM\\anOther\\Windows\\TEST\\path'), + WindowsPath::parse('C:\\i\\Am'), + WindowsPath::parse('C:\\I\\AM\\TEST\\WINDOWS\\PATH'), // different case, same path + ]; + + // simple latin case insensitive + $equalFunction = fn ($a, $b) => strtoupper($a) === strtoupper($b); + + $matrix = [ + [ + '.', + '..\\..\\..\\anOther\\Windows\\TEST\\path', + '..\\..\\..', + '.', + ], + [ + '..\\..\\..\\..\\test\\windows\\path', + '.', + '..\\..\\..\\..', + '..\\..\\..\\..\\TEST\\WINDOWS\\PATH', + ], + [ + 'test\\windows\\path', + 'anOther\\Windows\\TEST\\path', + '.', + 'TEST\\WINDOWS\\PATH', + ], + [ + '.', + '..\\..\\..\\anOther\\Windows\\TEST\\path', + '..\\..\\..', + '.', + ], + ]; + + foreach ($paths as $bpi => $bp) { + foreach ($paths as $tpi => $tp) { + $result = $matrix[$bpi][$tpi]; + + self::assertEquals($result, $bp->makeRelative($tp, $equalFunction)->toString()); + } + } + } + + public function testMakeRelativeWrongType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('You can only make relative path from paths of same type and same prefix'); + + WindowsPath::parse('C:\\Windows')->makeRelative(UnixPath::parse('/i/am/test/unix/path')); + } + + public function testMakeRelativeWrongPrefix(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('You can only make relative path from paths of same type and same prefix'); + + WindowsPath::parse('C:\\Windows')->makeRelative(WindowsPath::parse('D:\\Windows')); + } +}