├── SECURITY.md ├── src ├── Exception.php ├── Assertable.php ├── Phar │ ├── ReaderException.php │ ├── DeserializationException.php │ ├── Container.php │ ├── Stub.php │ ├── Manifest.php │ └── Reader.php ├── Resolvable.php ├── Collectable.php ├── Interceptor │ ├── PharExtensionInterceptor.php │ ├── PharMetaDataInterceptor.php │ └── ConjunctionInterceptor.php ├── Manager.php ├── Resolver │ ├── PharInvocation.php │ ├── PharInvocationCollection.php │ └── PharInvocationResolver.php ├── Behavior.php ├── Helper.php └── PharStreamWrapper.php ├── composer.json ├── LICENSE └── README.md /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 4.x | :white_check_mark: | 8 | | 3.x | :white_check_mark: | 9 | | 2.x | :x: | 10 | | < 2.0 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Please report vulnerabilities to [security@typo3.org](mailto:security@typo3.org). 15 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | stub = $stub; 30 | $this->manifest = $manifest; 31 | } 32 | 33 | public function getStub(): Stub 34 | { 35 | return $this->stub; 36 | } 37 | 38 | public function getManifest(): Manifest 39 | { 40 | return $this->manifest; 41 | } 42 | 43 | public function getAlias(): string 44 | { 45 | return $this->manifest->getAlias() ?: $this->stub->getMappedAlias(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Phar/Stub.php: -------------------------------------------------------------------------------- 1 | content = $content; 24 | 25 | if ( 26 | stripos($content, 'Phar::mapPhar(') !== false 27 | && preg_match('#Phar\:\:mapPhar\(([^)]+)\)#', $content, $matches) 28 | ) { 29 | // remove spaces, single & double quotes 30 | // @todo `'my' . 'alias' . '.phar'` is not evaluated here 31 | $target->mappedAlias = trim($matches[1], ' \'"'); 32 | } 33 | 34 | return $target; 35 | } 36 | 37 | /** 38 | * @var null|string 39 | */ 40 | private $content = null; 41 | 42 | /** 43 | * @var string 44 | */ 45 | private $mappedAlias = ''; 46 | 47 | public function getContent(): ?string 48 | { 49 | return $this->content; 50 | } 51 | 52 | public function getMappedAlias(): string 53 | { 54 | return $this->mappedAlias; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Interceptor/PharExtensionInterceptor.php: -------------------------------------------------------------------------------- 1 | baseFileContainsPharExtension($path)) { 29 | return true; 30 | } 31 | throw new Exception( 32 | sprintf( 33 | 'Unexpected file extension in "%s"', 34 | $path 35 | ), 36 | 1535198703 37 | ); 38 | } 39 | 40 | private function baseFileContainsPharExtension(string $path): bool 41 | { 42 | $invocation = Manager::instance()->resolve($path); 43 | if ($invocation === null) { 44 | return false; 45 | } 46 | $fileExtension = pathinfo($invocation->getBaseName(), PATHINFO_EXTENSION); 47 | return strtolower($fileExtension) === 'phar'; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Interceptor/PharMetaDataInterceptor.php: -------------------------------------------------------------------------------- 1 | baseFileDoesNotHaveMetaDataIssues($path)) { 36 | return true; 37 | } 38 | throw new Exception( 39 | sprintf( 40 | 'Problematic meta-data in "%s"', 41 | $path 42 | ), 43 | 1539632368 44 | ); 45 | } 46 | 47 | private function baseFileDoesNotHaveMetaDataIssues(string $path): bool 48 | { 49 | $invocation = Manager::instance()->resolve($path); 50 | if ($invocation === null) { 51 | return false; 52 | } 53 | // directly return in case invocation was checked before 54 | if ($invocation->getVariable(self::class) === true) { 55 | return true; 56 | } 57 | // otherwise analyze meta-data 58 | try { 59 | $reader = new Reader($invocation->getBaseName()); 60 | $reader->resolveContainer()->getManifest()->deserializeMetaData(); 61 | $invocation->setVariable(self::class, true); 62 | } catch (DeserializationException $exception) { 63 | return false; 64 | } 65 | return true; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Interceptor/ConjunctionInterceptor.php: -------------------------------------------------------------------------------- 1 | assertAssertions($assertions); 31 | $this->assertions = $assertions; 32 | } 33 | 34 | /** 35 | * Executes assertions based on all contained assertions. 36 | * 37 | * @throws Exception 38 | */ 39 | public function assert(string $path, string $command): bool 40 | { 41 | if ($this->invokeAssertions($path, $command)) { 42 | return true; 43 | } 44 | throw new Exception( 45 | sprintf( 46 | 'Assertion failed in "%s"', 47 | $path 48 | ), 49 | 1539625084 50 | ); 51 | } 52 | 53 | /** 54 | * @param Assertable[] $assertions 55 | */ 56 | private function assertAssertions(array $assertions): void 57 | { 58 | foreach ($assertions as $assertion) { 59 | if (!$assertion instanceof Assertable) { 60 | throw new \InvalidArgumentException( 61 | sprintf( 62 | 'Instance %s must implement Assertable', 63 | get_class($assertion) 64 | ), 65 | 1539624719 66 | ); 67 | } 68 | } 69 | } 70 | 71 | private function invokeAssertions(string $path, string $command): bool 72 | { 73 | try { 74 | foreach ($this->assertions as $assertion) { 75 | if (!$assertion->assert($path, $command)) { 76 | return false; 77 | } 78 | } 79 | } catch (Exception $exception) { 80 | return false; 81 | } 82 | return true; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Manager.php: -------------------------------------------------------------------------------- 1 | collection = $collection ?? new PharInvocationCollection(); 82 | $this->resolver = $resolver ?? new PharInvocationResolver(); 83 | $this->behavior = $behaviour; 84 | } 85 | 86 | public function assert(string $path, string $command): bool 87 | { 88 | return $this->behavior->assert($path, $command); 89 | } 90 | 91 | public function resolve(string $path, ?int $flags = null): ?PharInvocation 92 | { 93 | return $this->resolver->resolve($path, $flags); 94 | } 95 | 96 | public function getCollection(): Collectable 97 | { 98 | return $this->collection; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Resolver/PharInvocation.php: -------------------------------------------------------------------------------- 1 | baseName = $baseName; 56 | $this->alias = $alias; 57 | } 58 | 59 | /** 60 | * @return string 61 | */ 62 | public function __toString(): string 63 | { 64 | return $this->baseName; 65 | } 66 | 67 | /** 68 | * @return string 69 | */ 70 | public function getBaseName(): string 71 | { 72 | return $this->baseName; 73 | } 74 | 75 | /** 76 | * @return null|string 77 | */ 78 | public function getAlias(): string 79 | { 80 | return $this->alias; 81 | } 82 | 83 | /** 84 | * @return bool 85 | */ 86 | public function isConfirmed(): bool 87 | { 88 | return $this->confirmed; 89 | } 90 | 91 | public function confirm(): void 92 | { 93 | $this->confirmed = true; 94 | } 95 | 96 | /** 97 | * @return mixed|null 98 | */ 99 | public function getVariable(string $name) 100 | { 101 | return $this->variables[$name] ?? null; 102 | } 103 | 104 | /** 105 | * @param mixed $value 106 | */ 107 | public function setVariable(string $name, $value): void 108 | { 109 | $this->variables[$name] = $value; 110 | } 111 | 112 | /** 113 | * @param PharInvocation $other 114 | * @return bool 115 | */ 116 | public function equals(PharInvocation $other): bool 117 | { 118 | return $other->baseName === $this->baseName 119 | && $other->alias === $this->alias; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Behavior.php: -------------------------------------------------------------------------------- 1 | assertCommands($commands); 48 | $commands = $commands ?: $this->availableCommands; 49 | 50 | $target = clone $this; 51 | foreach ($commands as $command) { 52 | $target->assertions[$command] = $assertable; 53 | } 54 | return $target; 55 | } 56 | 57 | public function assert(string $path, string $command): bool 58 | { 59 | $this->assertCommand($command); 60 | $this->assertAssertionCompleteness(); 61 | 62 | return $this->assertions[$command]->assert($path, $command); 63 | } 64 | 65 | private function assertCommands(array $commands): void 66 | { 67 | $unknownCommands = array_diff($commands, $this->availableCommands); 68 | if ($unknownCommands === []) { 69 | return; 70 | } 71 | throw new \LogicException( 72 | sprintf( 73 | 'Unknown commands: %s', 74 | implode(', ', $unknownCommands) 75 | ), 76 | 1535189881 77 | ); 78 | } 79 | 80 | private function assertCommand(string $command): void 81 | { 82 | if (in_array($command, $this->availableCommands, true)) { 83 | return; 84 | } 85 | throw new \LogicException( 86 | sprintf( 87 | 'Unknown command "%s"', 88 | $command 89 | ), 90 | 1535189882 91 | ); 92 | } 93 | 94 | private function assertAssertionCompleteness(): void 95 | { 96 | $undefinedAssertions = array_diff( 97 | $this->availableCommands, 98 | array_keys($this->assertions) 99 | ); 100 | if ($undefinedAssertions === []) { 101 | return; 102 | } 103 | throw new \LogicException( 104 | sprintf( 105 | 'Missing assertions for commands: %s', 106 | implode(', ', $undefinedAssertions) 107 | ), 108 | 1535189883 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Phar/Manifest.php: -------------------------------------------------------------------------------- 1 | manifestLength = Reader::resolveFourByteLittleEndian($content, 0); 24 | $target->amountOfFiles = Reader::resolveFourByteLittleEndian($content, 4); 25 | $target->flags = Reader::resolveFourByteLittleEndian($content, 10); 26 | $target->aliasLength = Reader::resolveFourByteLittleEndian($content, 14); 27 | $target->alias = substr($content, 18, $target->aliasLength); 28 | $target->metaDataLength = Reader::resolveFourByteLittleEndian($content, 18 + $target->aliasLength); 29 | $target->metaData = substr($content, 22 + $target->aliasLength, $target->metaDataLength); 30 | 31 | $apiVersionNibbles = Reader::resolveTwoByteBigEndian($content, 8); 32 | $target->apiVersion = implode('.', [ 33 | ($apiVersionNibbles & 0xf000) >> 12, 34 | ($apiVersionNibbles & 0x0f00) >> 8, 35 | ($apiVersionNibbles & 0x00f0) >> 4, 36 | ]); 37 | 38 | return $target; 39 | } 40 | 41 | /** 42 | * @var int 43 | */ 44 | private $manifestLength; 45 | 46 | /** 47 | * @var int 48 | */ 49 | private $amountOfFiles; 50 | 51 | /** 52 | * @var string 53 | */ 54 | private $apiVersion; 55 | 56 | /** 57 | * @var int 58 | */ 59 | private $flags; 60 | 61 | /** 62 | * @var int 63 | */ 64 | private $aliasLength; 65 | 66 | /** 67 | * @var string 68 | */ 69 | private $alias; 70 | 71 | /** 72 | * @var int 73 | */ 74 | private $metaDataLength; 75 | 76 | /** 77 | * @var string 78 | */ 79 | private $metaData; 80 | 81 | /** 82 | * Avoid direct instantiation. 83 | */ 84 | private function __construct() 85 | { 86 | } 87 | 88 | public function getManifestLength(): int 89 | { 90 | return $this->manifestLength; 91 | } 92 | 93 | public function getAmountOfFiles(): int 94 | { 95 | return $this->amountOfFiles; 96 | } 97 | 98 | public function getApiVersion(): string 99 | { 100 | return $this->apiVersion; 101 | } 102 | 103 | public function getFlags(): int 104 | { 105 | return $this->flags; 106 | } 107 | 108 | public function getAliasLength(): int 109 | { 110 | return $this->aliasLength; 111 | } 112 | 113 | public function getAlias(): string 114 | { 115 | return $this->alias; 116 | } 117 | 118 | public function getMetaDataLength(): int 119 | { 120 | return $this->metaDataLength; 121 | } 122 | 123 | public function getMetaData(): string 124 | { 125 | return $this->metaData; 126 | } 127 | 128 | /** 129 | * @return mixed|null 130 | */ 131 | public function deserializeMetaData() 132 | { 133 | if (empty($this->metaData)) { 134 | return null; 135 | } 136 | 137 | $result = unserialize($this->metaData, ['allowed_classes' => false]); 138 | 139 | $serialized = json_encode($result); 140 | if (strpos($serialized, '__PHP_Incomplete_Class_Name') !== false) { 141 | throw new DeserializationException( 142 | 'Meta-data contains serialized object', 143 | 1539623382 144 | ); 145 | } 146 | 147 | return $result; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Resolver/PharInvocationCollection.php: -------------------------------------------------------------------------------- 1 | invocations, true); 35 | } 36 | 37 | /** 38 | * @param PharInvocation $invocation 39 | * @param null|int $flags 40 | * @return bool 41 | */ 42 | public function collect(PharInvocation $invocation, ?int $flags = null): bool 43 | { 44 | if ($flags === null) { 45 | $flags = static::UNIQUE_INVOCATION | static::DUPLICATE_ALIAS_WARNING; 46 | } 47 | if ($invocation->getBaseName() === '' 48 | || $invocation->getAlias() === '' 49 | || !$this->assertUniqueBaseName($invocation, $flags) 50 | || !$this->assertUniqueInvocation($invocation, $flags) 51 | ) { 52 | return false; 53 | } 54 | if ($flags & static::DUPLICATE_ALIAS_WARNING) { 55 | $this->triggerDuplicateAliasWarning($invocation); 56 | } 57 | 58 | $this->invocations[] = $invocation; 59 | return true; 60 | } 61 | 62 | public function findByCallback(callable $callback, bool $reverse = false): ?PharInvocation 63 | { 64 | foreach ($this->getInvocations($reverse) as $invocation) { 65 | if (call_user_func($callback, $invocation) === true) { 66 | return $invocation; 67 | } 68 | } 69 | return null; 70 | } 71 | 72 | /** 73 | * Asserts that base-name is unique. This disallows having multiple invocations for 74 | * same base-name but having different alias names. 75 | * 76 | * @param PharInvocation $invocation 77 | * @param int $flags 78 | * @return bool 79 | */ 80 | private function assertUniqueBaseName(PharInvocation $invocation, int $flags): bool 81 | { 82 | if (!($flags & static::UNIQUE_BASE_NAME)) { 83 | return true; 84 | } 85 | return $this->findByCallback( 86 | function (PharInvocation $candidate) use ($invocation) { 87 | return $candidate->getBaseName() === $invocation->getBaseName(); 88 | } 89 | ) === null; 90 | } 91 | 92 | /** 93 | * Asserts that combination of base-name and alias is unique. This allows having multiple 94 | * invocations for same base-name but having different alias names (for whatever reason). 95 | * 96 | * @param PharInvocation $invocation 97 | * @param int $flags 98 | * @return bool 99 | */ 100 | private function assertUniqueInvocation(PharInvocation $invocation, int $flags): bool 101 | { 102 | if (!($flags & static::UNIQUE_INVOCATION)) { 103 | return true; 104 | } 105 | return $this->findByCallback( 106 | function (PharInvocation $candidate) use ($invocation) { 107 | return $candidate->equals($invocation); 108 | } 109 | ) === null; 110 | } 111 | 112 | /** 113 | * Triggers warning for invocations with same alias and same confirmation state. 114 | * 115 | * @see \TYPO3\PharStreamWrapper\PharStreamWrapper::collectInvocation() 116 | */ 117 | private function triggerDuplicateAliasWarning(PharInvocation $invocation): void 118 | { 119 | $sameAliasInvocation = $this->findByCallback( 120 | function (PharInvocation $candidate) use ($invocation) { 121 | return $candidate->isConfirmed() === $invocation->isConfirmed() 122 | && $candidate->getAlias() === $invocation->getAlias(); 123 | }, 124 | true 125 | ); 126 | if ($sameAliasInvocation === null) { 127 | return; 128 | } 129 | trigger_error( 130 | sprintf( 131 | 'Alias %s cannot be used by %s, already used by %s', 132 | $invocation->getAlias(), 133 | $invocation->getBaseName(), 134 | $sameAliasInvocation->getBaseName() 135 | ), 136 | E_USER_WARNING 137 | ); 138 | } 139 | 140 | /** 141 | * @param bool $reverse 142 | * @return PharInvocation[] 143 | */ 144 | private function getInvocations(bool $reverse = false): array 145 | { 146 | if ($reverse) { 147 | return array_reverse($this->invocations); 148 | } 149 | return $this->invocations; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Helper.php: -------------------------------------------------------------------------------- 1 | = 1) { 138 | // Rremove this and previous element 139 | array_splice($pathParts, $partCount - 1, 2); 140 | $partCount -= 2; 141 | $pathPartsLength -= 2; 142 | } elseif ($absolutePathPrefix) { 143 | // can't go higher than root dir 144 | // simply remove this part and continue 145 | array_splice($pathParts, $partCount, 1); 146 | $partCount--; 147 | $pathPartsLength--; 148 | } 149 | } 150 | } 151 | 152 | return $absolutePathPrefix . implode('/', $pathParts); 153 | } 154 | 155 | /** 156 | * Checks if the $path is absolute or relative (detecting either '/' or 157 | * 'x:/' as first part of string) and returns TRUE if so. 158 | */ 159 | private static function isAbsolutePath(string $path): bool 160 | { 161 | // Path starting with a / is always absolute, on every system 162 | // On Windows also a path starting with a drive letter is absolute: X:/ 163 | return ($path[0] ?? null) === '/' 164 | || static::isWindows() && ( 165 | strpos($path, ':/') === 1 166 | || strpos($path, ':\\') === 1 167 | ); 168 | } 169 | 170 | /** 171 | * @return bool 172 | */ 173 | private static function isWindows(): bool 174 | { 175 | return stripos(PHP_OS, 'WIN') === 0; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Resolver/PharInvocationResolver.php: -------------------------------------------------------------------------------- 1 | findByAlias($path); 63 | if ($invocation !== null) { 64 | return $invocation; 65 | } 66 | } 67 | 68 | $baseName = $this->resolveBaseName($path, $flags); 69 | if ($baseName === null) { 70 | return null; 71 | } 72 | 73 | if ($flags & static::RESOLVE_REALPATH) { 74 | $baseName = $this->baseNames[$baseName]; 75 | } 76 | 77 | return $this->retrieveInvocation($baseName, $flags); 78 | } 79 | 80 | /** 81 | * Retrieves PharInvocation, either existing in collection or created on demand 82 | * with resolving a potential alias name used in the according Phar archive. 83 | * 84 | * @param string $baseName 85 | * @param int $flags 86 | * @return PharInvocation 87 | */ 88 | private function retrieveInvocation(string $baseName, int $flags): PharInvocation 89 | { 90 | $invocation = $this->findByBaseName($baseName); 91 | if ($invocation !== null) { 92 | return $invocation; 93 | } 94 | 95 | if ($flags & static::RESOLVE_ALIAS) { 96 | $alias = (new Reader($baseName))->resolveContainer()->getAlias(); 97 | } else { 98 | $alias = ''; 99 | } 100 | // add unconfirmed(!) new invocation to collection 101 | $invocation = new PharInvocation($baseName, $alias); 102 | Manager::instance()->getCollection()->collect($invocation); 103 | return $invocation; 104 | } 105 | 106 | private function resolveBaseName(string $path, int $flags): ?string 107 | { 108 | $baseName = $this->findInBaseNames($path); 109 | if ($baseName !== null) { 110 | return $baseName; 111 | } 112 | 113 | $baseName = Helper::determineBaseFile($path); 114 | if ($baseName !== null) { 115 | $this->addBaseName($baseName); 116 | return $baseName; 117 | } 118 | 119 | $possibleAlias = $this->resolvePossibleAlias($path); 120 | if (!($flags & static::RESOLVE_ALIAS) || $possibleAlias === null) { 121 | return null; 122 | } 123 | 124 | $trace = debug_backtrace(); 125 | foreach ($trace as $item) { 126 | if (!isset($item['function']) || !isset($item['args'][0]) 127 | || !in_array($item['function'], $this->invocationFunctionNames, true)) { 128 | continue; 129 | } 130 | $currentPath = $item['args'][0]; 131 | if (Helper::hasPharPrefix($currentPath)) { 132 | continue; 133 | } 134 | $currentBaseName = Helper::determineBaseFile($currentPath); 135 | if ($currentBaseName === null) { 136 | continue; 137 | } 138 | // ensure the possible alias name (how we have been called initially) matches 139 | // the resolved alias name that was retrieved by the current possible base name 140 | try { 141 | $currentAlias = (new Reader($currentBaseName))->resolveContainer()->getAlias(); 142 | } catch (ReaderException $exception) { 143 | // most probably that was not a Phar file 144 | continue; 145 | } 146 | if (empty($currentAlias) || $currentAlias !== $possibleAlias) { 147 | continue; 148 | } 149 | $this->addBaseName($currentBaseName); 150 | return $currentBaseName; 151 | } 152 | 153 | return null; 154 | } 155 | 156 | private function resolvePossibleAlias(string $path): ?string 157 | { 158 | $normalizedPath = Helper::normalizePath($path); 159 | return strstr($normalizedPath, '/', true) ?: null; 160 | } 161 | 162 | private function findByBaseName(string $baseName): ?PharInvocation 163 | { 164 | return Manager::instance()->getCollection()->findByCallback( 165 | function (PharInvocation $candidate) use ($baseName) { 166 | return $candidate->getBaseName() === $baseName; 167 | }, 168 | true 169 | ); 170 | } 171 | 172 | private function findInBaseNames(string $path): ?string 173 | { 174 | // return directly if the resolved base name was submitted 175 | if (in_array($path, $this->baseNames, true)) { 176 | return $path; 177 | } 178 | 179 | $parts = explode('/', Helper::normalizePath($path)); 180 | 181 | while (count($parts)) { 182 | $currentPath = implode('/', $parts); 183 | if (isset($this->baseNames[$currentPath])) { 184 | return $currentPath; 185 | } 186 | array_pop($parts); 187 | } 188 | 189 | return null; 190 | } 191 | 192 | private function addBaseName(string $baseName): void 193 | { 194 | if (isset($this->baseNames[$baseName])) { 195 | return; 196 | } 197 | $this->baseNames[$baseName] = Helper::normalizeWindowsPath( 198 | realpath($baseName) 199 | ); 200 | } 201 | 202 | /** 203 | * Finds confirmed(!) invocations by alias. 204 | * 205 | * @see \TYPO3\PharStreamWrapper\PharStreamWrapper::collectInvocation() 206 | */ 207 | private function findByAlias(string $path): ?PharInvocation 208 | { 209 | $possibleAlias = $this->resolvePossibleAlias($path); 210 | if ($possibleAlias === null) { 211 | return null; 212 | } 213 | return Manager::instance()->getCollection()->findByCallback( 214 | function (PharInvocation $candidate) use ($possibleAlias) { 215 | return $candidate->isConfirmed() && $candidate->getAlias() === $possibleAlias; 216 | }, 217 | true 218 | ); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/Phar/Reader.php: -------------------------------------------------------------------------------- 1 | fileName = $fileName; 42 | $this->fileType = $this->determineFileType(); 43 | } 44 | 45 | public function resolveContainer(): Container 46 | { 47 | $data = $this->extractData($this->resolveStream() . $this->fileName); 48 | 49 | if ($data['stubContent'] === null) { 50 | throw new ReaderException( 51 | 'Cannot resolve stub', 52 | 1547807881 53 | ); 54 | } 55 | if ($data['manifestContent'] === null || $data['manifestLength'] === null) { 56 | throw new ReaderException( 57 | 'Cannot resolve manifest', 58 | 1547807882 59 | ); 60 | } 61 | if (strlen($data['manifestContent']) < $data['manifestLength']) { 62 | throw new ReaderException( 63 | sprintf( 64 | 'Exected manifest length %d, got %d', 65 | strlen($data['manifestContent']), 66 | $data['manifestLength'] 67 | ), 68 | 1547807883 69 | ); 70 | } 71 | 72 | return new Container( 73 | Stub::fromContent($data['stubContent']), 74 | Manifest::fromContent($data['manifestContent']) 75 | ); 76 | } 77 | 78 | /** 79 | * @param string $fileName e.g. '/path/file.phar' or 'compress.zlib:///path/file.phar' 80 | */ 81 | private function extractData(string $fileName): array 82 | { 83 | $stubContent = null; 84 | $manifestContent = null; 85 | $manifestLength = null; 86 | 87 | $resource = fopen($fileName, 'r'); 88 | if (!is_resource($resource)) { 89 | throw new ReaderException( 90 | sprintf('Resource %s could not be opened', $fileName), 91 | 1547902055 92 | ); 93 | } 94 | 95 | while (!feof($resource)) { 96 | $line = fgets($resource); 97 | // stop processing in case the system fails to read from a stream 98 | if ($line === false) { 99 | break; 100 | } 101 | // stop reading file when manifest can be extracted 102 | if ($manifestLength !== null && $manifestContent !== null && strlen($manifestContent) >= $manifestLength) { 103 | break; 104 | } 105 | 106 | $manifestPosition = strpos($line, '__HALT_COMPILER();'); 107 | 108 | // first line contains start of manifest 109 | if ($stubContent === null && $manifestContent === null && $manifestPosition !== false) { 110 | $stubContent = substr($line, 0, $manifestPosition - 1); 111 | $manifestContent = preg_replace('#^.*__HALT_COMPILER\(\);(?>[ \n]\?>(?>\r\n|\n)?)?#', '', $line); 112 | $manifestLength = $this->resolveManifestLength($manifestContent); 113 | // line contains start of stub 114 | } elseif ($stubContent === null) { 115 | $stubContent = $line; 116 | // line contains start of manifest 117 | } elseif ($manifestContent === null && $manifestPosition !== false) { 118 | $manifestContent = preg_replace('#^.*__HALT_COMPILER\(\);(?>[ \n]\?>(?>\r\n|\n)?)?#', '', $line); 119 | $manifestLength = $this->resolveManifestLength($manifestContent); 120 | // manifest has been started (thus is cannot be stub anymore), add content 121 | } elseif ($manifestContent !== null) { 122 | $manifestContent .= $line; 123 | $manifestLength = $this->resolveManifestLength($manifestContent); 124 | // stub has been started (thus cannot be manifest here, yet), add content 125 | } elseif ($stubContent !== null) { 126 | $stubContent .= $line; 127 | } 128 | } 129 | fclose($resource); 130 | 131 | return [ 132 | 'stubContent' => $stubContent, 133 | 'manifestContent' => $manifestContent, 134 | 'manifestLength' => $manifestLength, 135 | ]; 136 | } 137 | 138 | /** 139 | * Resolves the stream to handle compressed Phar archives. 140 | */ 141 | private function resolveStream(): string 142 | { 143 | if ($this->fileType === 'application/x-gzip' || $this->fileType === 'application/gzip') { 144 | return 'compress.zlib://'; 145 | } 146 | if ($this->fileType === 'application/x-bzip2') { 147 | return 'compress.bzip2://'; 148 | } 149 | return ''; 150 | } 151 | 152 | private function determineFileType(): string 153 | { 154 | if (class_exists('\\finfo')) { 155 | $fileInfo = new \finfo(); 156 | return (string)$fileInfo->file($this->fileName, FILEINFO_MIME_TYPE); 157 | } 158 | return $this->determineFileTypeByHeader(); 159 | } 160 | 161 | /** 162 | * In case ext-fileinfo is not present only the relevant types 163 | * 'application/x-gzip' and 'application/x-bzip2' are resolved. 164 | */ 165 | private function determineFileTypeByHeader(): string 166 | { 167 | $resource = fopen($this->fileName, 'r'); 168 | if (!is_resource($resource)) { 169 | throw new ReaderException( 170 | sprintf('Resource %s could not be opened', $this->fileName), 171 | 1557753055 172 | ); 173 | } 174 | $header = fgets($resource, 4); 175 | fclose($resource); 176 | if (strpos($header, "\x42\x5a\x68") === 0) { 177 | return 'application/x-bzip2'; 178 | } 179 | if (strpos($header, "\x1f\x8b") === 0) { 180 | return 'application/x-gzip'; 181 | } 182 | return ''; 183 | } 184 | 185 | private function resolveManifestLength(string $content): ?int 186 | { 187 | if (strlen($content) < 4) { 188 | return null; 189 | } 190 | return static::resolveFourByteLittleEndian($content, 0); 191 | } 192 | 193 | public static function resolveFourByteLittleEndian(string $content, int $start): int 194 | { 195 | $payload = substr($content, $start, 4); 196 | if (!is_string($payload)) { 197 | throw new ReaderException( 198 | sprintf('Cannot resolve value at offset %d', $start), 199 | 1539614260 200 | ); 201 | } 202 | 203 | $value = unpack('V', $payload); 204 | if (!isset($value[1])) { 205 | throw new ReaderException( 206 | sprintf('Cannot resolve value at offset %d', $start), 207 | 1539614261 208 | ); 209 | } 210 | return $value[1]; 211 | } 212 | 213 | public static function resolveTwoByteBigEndian(string $content, int $start): int 214 | { 215 | $payload = substr($content, $start, 2); 216 | if (!is_string($payload)) { 217 | throw new ReaderException( 218 | sprintf('Cannot resolve value at offset %d', $start), 219 | 1539614263 220 | ); 221 | } 222 | 223 | $value = unpack('n', $payload); 224 | if (!isset($value[1])) { 225 | throw new ReaderException( 226 | sprintf('Cannot resolve value at offset %d', $start), 227 | 1539614264 228 | ); 229 | } 230 | return $value[1]; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/TYPO3/phar-stream-wrapper/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/TYPO3/phar-stream-wrapper/?branch=master) 2 | [![GitHub Build Status Ubuntu](https://github.com/typo3/phar-stream-wrapper/actions/workflows/tests-ubuntu.yml/badge.svg)](https://github.com/typo3/phar-stream-wrapper/actions/workflows/tests-ubuntu.yml) 3 | [![GitHub Build Status Windows](https://github.com/typo3/phar-stream-wrapper/actions/workflows/tests-windows.yml/badge.svg)](https://github.com/typo3/phar-stream-wrapper/actions/workflows/tests-windows.yml) 4 | [![Downloads](https://poser.pugx.org/typo3/phar-stream-wrapper/downloads.svg)](https://packagist.org/packages/typo3/phar-stream-wrapper) 5 | 6 | # PHP Phar Stream Wrapper 7 | 8 | > [!NOTE] 9 | > With PHP 8.0.0 the default behavior changed, and meta-data is not deserialized automatically anymore: 10 | > 11 | > * see [`typo3/phar-stream-wrapper` issue #64](https://github.com/TYPO3/phar-stream-wrapper/issues/64) 12 | > * see https://php.watch/versions/8.0/phar-stream-wrapper-unserialize 13 | 14 | ## Abstract & History 15 | 16 | Based on Sam Thomas' findings concerning 17 | [insecure deserialization in combination with obfuscation strategies](https://www.secarma.com/labs/near-phar-dangerous-unserialization-wherever-you-are.html) 18 | allowing to hide Phar files inside valid image resources, the TYPO3 project 19 | decided back then to introduce a `PharStreamWrapper` to intercept invocations 20 | of the `phar://` stream in PHP and only allow usage for defined locations in 21 | the file system. 22 | 23 | Since the TYPO3 mission statement is **inspiring people to share**, we thought 24 | it would be helpful for others to release our `PharStreamWrapper` as standalone 25 | package to the PHP community. 26 | 27 | The mentioned security issue was reported to TYPO3 on 10th June 2018 by Sam Thomas 28 | and has been addressed concerning the specific attack vector and for this generic 29 | `PharStreamWrapper` in TYPO3 versions 7.6.30 LTS, 8.7.17 LTS and 9.3.1 on 12th 30 | July 2018. 31 | 32 | * https://blog.secarma.co.uk/labs/near-phar-dangerous-unserialization-wherever-you-are 33 | * https://youtu.be/GePBmsNJw6Y 34 | * https://typo3.org/security/advisory/typo3-psa-2018-001/ 35 | * https://typo3.org/security/advisory/typo3-psa-2019-007/ 36 | * https://typo3.org/security/advisory/typo3-psa-2019-008/ 37 | 38 | ## License 39 | 40 | In general the TYPO3 core is released under the GNU General Public License version 41 | 2 or any later version (`GPL-2.0-or-later`). In order to avoid licensing issues and 42 | incompatibilities this `PharStreamWrapper` is licenced under the MIT License. In case 43 | you duplicate or modify source code, credits are not required but really appreciated. 44 | 45 | ## Credits 46 | 47 | Thanks to [Alex Pott](https://github.com/alexpott), Drupal for creating 48 | back-ports of all sources in order to provide compatibility with PHP v5.3. 49 | 50 | ## Installation 51 | 52 | The `PharStreamWrapper` is provided as composer package `typo3/phar-stream-wrapper` 53 | and has minimum requirements of PHP v5.3 ([`v2`](https://github.com/TYPO3/phar-stream-wrapper/tree/v2) branch), 54 | PHP v7.0 - v8.3 ([`v3`](https://github.com/TYPO3/phar-stream-wrapper/tree/v3) branch) and PHP v7.1 - v8.4+ ([`v3`](https://github.com/TYPO3/phar-stream-wrapper) branch). 55 | 56 | ### Installation for PHP v7.1 - v8.4 57 | 58 | ``` 59 | composer require typo3/phar-stream-wrapper ^4.0 60 | ``` 61 | 62 | ### Installation for PHP v7.0 - v8.3 63 | 64 | ``` 65 | composer require typo3/phar-stream-wrapper ^3.0 66 | ``` 67 | 68 | ### Installation for PHP v5.3 69 | 70 | ``` 71 | composer require typo3/phar-stream-wrapper ^2.0 72 | ``` 73 | 74 | ## Example 75 | 76 | The following example is bundled within this package, the shown 77 | `PharExtensionInterceptor` denies all stream wrapper invocations files 78 | not having the `.phar` suffix. Interceptor logic has to be individual and 79 | adjusted to according requirements. 80 | 81 | ``` 82 | \TYPO3\PharStreamWrapper\Manager::initialize( 83 | (new \TYPO3\PharStreamWrapper\Behavior()) 84 | ->withAssertion(new \TYPO3\PharStreamWrapper\Interceptor\PharExtensionInterceptor()) 85 | ); 86 | 87 | if (in_array('phar', stream_get_wrappers())) { 88 | stream_wrapper_unregister('phar'); 89 | stream_wrapper_register('phar', \TYPO3\PharStreamWrapper\PharStreamWrapper::class); 90 | } 91 | ``` 92 | 93 | * `PharStreamWrapper` defined as class reference will be instantiated each time 94 | `phar://` streams shall be processed. 95 | * `Manager` as singleton pattern being called by `PharStreamWrapper` instances 96 | in order to retrieve individual behavior and settings. 97 | * `Behavior` holds reference to interceptor(s) that shall assert correct/allowed 98 | invocation of a given `$path` for a given `$command`. Interceptors implement 99 | the interface `Assertable`. Interceptors can act individually on the following 100 | commands or handle all of them in case they were not defined specifically: 101 | + `COMMAND_DIR_OPENDIR` 102 | + `COMMAND_MKDIR` 103 | + `COMMAND_RENAME` 104 | + `COMMAND_RMDIR` 105 | + `COMMAND_STEAM_METADATA` 106 | + `COMMAND_STREAM_OPEN` 107 | + `COMMAND_UNLINK` 108 | + `COMMAND_URL_STAT` 109 | 110 | ## Interceptors 111 | 112 | The following interceptor is shipped with the package and ready to use in order 113 | to block any Phar invocation of files not having a `.phar` suffix. Besides that 114 | individual interceptors are possible of course. 115 | 116 | ``` 117 | class PharExtensionInterceptor implements Assertable 118 | { 119 | /** 120 | * Determines whether the base file name has a ".phar" suffix. 121 | * 122 | * @param string $path 123 | * @param string $command 124 | * @return bool 125 | * @throws Exception 126 | */ 127 | public function assert(string $path, string $command): bool 128 | { 129 | if ($this->baseFileContainsPharExtension($path)) { 130 | return true; 131 | } 132 | throw new Exception( 133 | sprintf( 134 | 'Unexpected file extension in "%s"', 135 | $path 136 | ), 137 | 1535198703 138 | ); 139 | } 140 | 141 | /** 142 | * @param string $path 143 | * @return bool 144 | */ 145 | private function baseFileContainsPharExtension(string $path): bool 146 | { 147 | $baseFile = Helper::determineBaseFile($path); 148 | if ($baseFile === null) { 149 | return false; 150 | } 151 | $fileExtension = pathinfo($baseFile, PATHINFO_EXTENSION); 152 | return strtolower($fileExtension) === 'phar'; 153 | } 154 | } 155 | ``` 156 | 157 | ### ConjunctionInterceptor 158 | 159 | This interceptor combines multiple interceptors implementing `Assertable`. 160 | It succeeds when all nested interceptors succeed as well (logical `AND`). 161 | 162 | ``` 163 | \TYPO3\PharStreamWrapper\Manager::initialize( 164 | (new \TYPO3\PharStreamWrapper\Behavior()) 165 | ->withAssertion(new ConjunctionInterceptor([ 166 | new PharExtensionInterceptor(), 167 | new PharMetaDataInterceptor(), 168 | ])) 169 | ); 170 | ``` 171 | 172 | ### PharExtensionInterceptor 173 | 174 | This (basic) interceptor just checks whether the invoked Phar archive has 175 | an according `.phar` file extension. Resolving symbolic links as well as 176 | Phar internal alias resolving are considered as well. 177 | 178 | ``` 179 | \TYPO3\PharStreamWrapper\Manager::initialize( 180 | (new \TYPO3\PharStreamWrapper\Behavior()) 181 | ->withAssertion(new PharExtensionInterceptor()) 182 | ); 183 | ``` 184 | 185 | ### PharMetaDataInterceptor 186 | 187 | This interceptor is actually checking serialized Phar meta-data against 188 | PHP objects and would consider a Phar archive malicious in case not only 189 | scalar values are found. A custom low-level `Phar\Reader` is used in order to 190 | avoid using PHP's `Phar` object which would trigger the initial vulnerability. 191 | 192 | ``` 193 | \TYPO3\PharStreamWrapper\Manager::initialize( 194 | (new \TYPO3\PharStreamWrapper\Behavior()) 195 | ->withAssertion(new PharMetaDataInterceptor()) 196 | ); 197 | ``` 198 | 199 | ## Reader 200 | 201 | * `Phar\Reader::__construct(string $fileName)`: Creates low-level reader for Phar archive 202 | * `Phar\Reader::resolveContainer(): Phar\Container`: Resolves model representing Phar archive 203 | * `Phar\Container::getStub(): Phar\Stub`: Resolves (plain PHP) stub section of Phar archive 204 | * `Phar\Container::getManifest(): Phar\Manifest`: Resolves parsed Phar archive manifest as 205 | documented at http://php.net/manual/en/phar.fileformat.manifestfile.php 206 | * `Phar\Stub::getMappedAlias(): string`: Resolves internal Phar archive alias defined in stub 207 | using `Phar::mapPhar('alias.phar')` - actually the plain PHP source is analyzed here 208 | * `Phar\Manifest::getAlias(): string` - Resolves internal Phar archive alias defined in manifest 209 | using `Phar::setAlias('alias.phar')` 210 | * `Phar\Manifest::getMetaData(): string`: Resolves serialized Phar archive meta-data 211 | * `Phar\Manifest::deserializeMetaData(): mixed`: Resolves deserialized Phar archive meta-data 212 | containing only scalar values - in case an object is determined, an according 213 | `Phar\DeserializationException` will be thrown 214 | 215 | ``` 216 | $reader = new Phar\Reader('example.phar'); 217 | var_dump($reader->resolveContainer()->getManifest()->deserializeMetaData()); 218 | ``` 219 | 220 | ## Helper 221 | 222 | * `Helper::determineBaseFile(string $path): string`: Determines base file that can be 223 | accessed using the regular file system. For instance the following path 224 | `phar:///home/user/bundle.phar/content.txt` would be resolved to 225 | `/home/user/bundle.phar`. 226 | * `Helper::resetOpCache()`: Resets PHP's OPcache if enabled as work-around for 227 | issues in `include()` or `require()` calls and OPcache delivering wrong 228 | results. More details can be found in PHP's bug tracker, for instance like 229 | https://bugs.php.net/bug.php?id=66569 230 | 231 | ## Security Contact 232 | 233 | In case of finding additional security issues in the TYPO3 project or in this 234 | `PharStreamWrapper` package in particular, please get in touch with the 235 | [TYPO3 Security Team](mailto:security@typo3.org). 236 | -------------------------------------------------------------------------------- /src/PharStreamWrapper.php: -------------------------------------------------------------------------------- 1 | internalResource)) { 43 | return false; 44 | } 45 | 46 | $this->invokeInternalStreamWrapper( 47 | 'closedir', 48 | $this->internalResource 49 | ); 50 | return !is_resource($this->internalResource); 51 | } 52 | 53 | public function dir_opendir(string $path, int $options): bool 54 | { 55 | $this->assert($path, Behavior::COMMAND_DIR_OPENDIR); 56 | $this->internalResource = $this->invokeInternalStreamWrapper( 57 | 'opendir', 58 | $path, 59 | $this->context 60 | ); 61 | return is_resource($this->internalResource); 62 | } 63 | 64 | /** 65 | * @return string|false 66 | */ 67 | public function dir_readdir() 68 | { 69 | return $this->invokeInternalStreamWrapper( 70 | 'readdir', 71 | $this->internalResource 72 | ); 73 | } 74 | 75 | public function dir_rewinddir(): bool 76 | { 77 | if (!is_resource($this->internalResource)) { 78 | return false; 79 | } 80 | 81 | $this->invokeInternalStreamWrapper( 82 | 'rewinddir', 83 | $this->internalResource 84 | ); 85 | return is_resource($this->internalResource); 86 | } 87 | 88 | public function mkdir(string $path, int $mode, int $options): bool 89 | { 90 | $this->assert($path, Behavior::COMMAND_MKDIR); 91 | return $this->invokeInternalStreamWrapper( 92 | 'mkdir', 93 | $path, 94 | $mode, 95 | (bool) ($options & STREAM_MKDIR_RECURSIVE), 96 | $this->context 97 | ); 98 | } 99 | 100 | public function rename(string $path_from, string $path_to): bool 101 | { 102 | $this->assert($path_from, Behavior::COMMAND_RENAME); 103 | $this->assert($path_to, Behavior::COMMAND_RENAME); 104 | return $this->invokeInternalStreamWrapper( 105 | 'rename', 106 | $path_from, 107 | $path_to, 108 | $this->context 109 | ); 110 | } 111 | 112 | public function rmdir(string $path, int $options): bool 113 | { 114 | $this->assert($path, Behavior::COMMAND_RMDIR); 115 | return $this->invokeInternalStreamWrapper( 116 | 'rmdir', 117 | $path, 118 | $this->context 119 | ); 120 | } 121 | 122 | public function stream_cast(int $cast_as): void 123 | { 124 | throw new Exception( 125 | 'Method stream_select() cannot be used', 126 | 1530103999 127 | ); 128 | } 129 | 130 | public function stream_close(): void 131 | { 132 | $this->invokeInternalStreamWrapper( 133 | 'fclose', 134 | $this->internalResource 135 | ); 136 | } 137 | 138 | public function stream_eof(): bool 139 | { 140 | return $this->invokeInternalStreamWrapper( 141 | 'feof', 142 | $this->internalResource 143 | ); 144 | } 145 | 146 | public function stream_flush(): bool 147 | { 148 | return $this->invokeInternalStreamWrapper( 149 | 'fflush', 150 | $this->internalResource 151 | ); 152 | } 153 | 154 | public function stream_lock(int $operation): bool 155 | { 156 | return $this->invokeInternalStreamWrapper( 157 | 'flock', 158 | $this->internalResource, 159 | $operation 160 | ); 161 | } 162 | 163 | /** 164 | * @param string|int $value 165 | */ 166 | public function stream_metadata(string $path, int $option, $value): bool 167 | { 168 | $this->assert($path, Behavior::COMMAND_STEAM_METADATA); 169 | if ($option === STREAM_META_TOUCH) { 170 | return $this->invokeInternalStreamWrapper( 171 | 'touch', 172 | $path, 173 | ...$value 174 | ); 175 | } 176 | if ($option === STREAM_META_OWNER_NAME || $option === STREAM_META_OWNER) { 177 | return $this->invokeInternalStreamWrapper( 178 | 'chown', 179 | $path, 180 | $value 181 | ); 182 | } 183 | if ($option === STREAM_META_GROUP_NAME || $option === STREAM_META_GROUP) { 184 | return $this->invokeInternalStreamWrapper( 185 | 'chgrp', 186 | $path, 187 | $value 188 | ); 189 | } 190 | if ($option === STREAM_META_ACCESS) { 191 | return $this->invokeInternalStreamWrapper( 192 | 'chmod', 193 | $path, 194 | $value 195 | ); 196 | } 197 | return false; 198 | } 199 | 200 | /** 201 | * @param string|null $opened_path 202 | */ 203 | public function stream_open( 204 | string $path, 205 | string $mode, 206 | int $options, 207 | ?string &$opened_path = null 208 | ): bool { 209 | $this->assert($path, Behavior::COMMAND_STREAM_OPEN); 210 | $arguments = [$path, $mode, (bool) ($options & STREAM_USE_PATH)]; 211 | // only add stream context for non include/require calls 212 | if (!($options & static::STREAM_OPEN_FOR_INCLUDE)) { 213 | $arguments[] = $this->context; 214 | // work around https://bugs.php.net/bug.php?id=66569 215 | // for including files from Phar stream with OPcache enabled 216 | } else { 217 | Helper::resetOpCache(); 218 | } 219 | $this->internalResource = $this->invokeInternalStreamWrapper( 220 | 'fopen', 221 | ...$arguments 222 | ); 223 | if (!is_resource($this->internalResource)) { 224 | return false; 225 | } 226 | if ($opened_path !== null) { 227 | $metaData = stream_get_meta_data($this->internalResource); 228 | $opened_path = $metaData['uri']; 229 | } 230 | return true; 231 | } 232 | 233 | public function stream_read(int $count): string 234 | { 235 | return $this->invokeInternalStreamWrapper( 236 | 'fread', 237 | $this->internalResource, 238 | $count 239 | ); 240 | } 241 | 242 | public function stream_seek(int $offset, int $whence = SEEK_SET): bool 243 | { 244 | return $this->invokeInternalStreamWrapper( 245 | 'fseek', 246 | $this->internalResource, 247 | $offset, 248 | $whence 249 | ) !== -1; 250 | } 251 | 252 | public function stream_set_option(int $option, int $arg1, int $arg2): bool 253 | { 254 | if ($option === STREAM_OPTION_BLOCKING) { 255 | return $this->invokeInternalStreamWrapper( 256 | 'stream_set_blocking', 257 | $this->internalResource, 258 | $arg1 259 | ); 260 | } 261 | if ($option === STREAM_OPTION_READ_TIMEOUT) { 262 | return $this->invokeInternalStreamWrapper( 263 | 'stream_set_timeout', 264 | $this->internalResource, 265 | $arg1, 266 | $arg2 267 | ); 268 | } 269 | if ($option === STREAM_OPTION_WRITE_BUFFER) { 270 | return $this->invokeInternalStreamWrapper( 271 | 'stream_set_write_buffer', 272 | $this->internalResource, 273 | $arg2 274 | ) === 0; 275 | } 276 | return false; 277 | } 278 | 279 | public function stream_stat(): array 280 | { 281 | return $this->invokeInternalStreamWrapper( 282 | 'fstat', 283 | $this->internalResource 284 | ); 285 | } 286 | 287 | public function stream_tell(): int 288 | { 289 | return $this->invokeInternalStreamWrapper( 290 | 'ftell', 291 | $this->internalResource 292 | ); 293 | } 294 | 295 | public function stream_truncate(int $new_size): bool 296 | { 297 | return $this->invokeInternalStreamWrapper( 298 | 'ftruncate', 299 | $this->internalResource, 300 | $new_size 301 | ); 302 | } 303 | 304 | public function stream_write(string $data): int 305 | { 306 | return $this->invokeInternalStreamWrapper( 307 | 'fwrite', 308 | $this->internalResource, 309 | $data 310 | ); 311 | } 312 | 313 | public function unlink(string $path): bool 314 | { 315 | $this->assert($path, Behavior::COMMAND_UNLINK); 316 | return $this->invokeInternalStreamWrapper( 317 | 'unlink', 318 | $path, 319 | $this->context 320 | ); 321 | } 322 | 323 | /** 324 | * @return array|false 325 | */ 326 | public function url_stat(string $path, int $flags) 327 | { 328 | $this->assert($path, Behavior::COMMAND_URL_STAT); 329 | $functionName = $flags & STREAM_URL_STAT_QUIET ? '@stat' : 'stat'; 330 | return $this->invokeInternalStreamWrapper($functionName, $path); 331 | } 332 | 333 | protected function assert(string $path, string $command): void 334 | { 335 | if (Manager::instance()->assert($path, $command) === true) { 336 | $this->collectInvocation($path); 337 | return; 338 | } 339 | 340 | throw new Exception( 341 | sprintf( 342 | 'Denied invocation of "%s" for command "%s"', 343 | $path, 344 | $command 345 | ), 346 | 1535189880 347 | ); 348 | } 349 | 350 | protected function collectInvocation(string $path): void 351 | { 352 | if (isset($this->invocation)) { 353 | return; 354 | } 355 | 356 | $manager = Manager::instance(); 357 | $this->invocation = $manager->resolve($path); 358 | if ($this->invocation === null) { 359 | throw new Exception( 360 | 'Expected invocation could not be resolved', 361 | 1556389591 362 | ); 363 | } 364 | // confirm, previous interceptor(s) validated invocation 365 | $this->invocation->confirm(); 366 | $collection = $manager->getCollection(); 367 | if (!$collection->has($this->invocation)) { 368 | $collection->collect($this->invocation); 369 | } 370 | } 371 | 372 | /** 373 | * @return Manager|Assertable 374 | * @deprecated Use Manager::instance() directly 375 | */ 376 | protected function resolveAssertable(): Assertable 377 | { 378 | return Manager::instance(); 379 | } 380 | 381 | /** 382 | * Invokes commands on the native PHP Phar stream wrapper. 383 | * 384 | * @param mixed ...$arguments 385 | * @return mixed 386 | */ 387 | private function invokeInternalStreamWrapper(string $functionName, ...$arguments) 388 | { 389 | $silentExecution = $functionName[0] === '@'; 390 | $functionName = ltrim($functionName, '@'); 391 | $this->restoreInternalSteamWrapper(); 392 | 393 | try { 394 | if ($silentExecution) { 395 | $result = @call_user_func_array($functionName, $arguments); 396 | } else { 397 | $result = call_user_func_array($functionName, $arguments); 398 | } 399 | } finally { 400 | $this->registerStreamWrapper(); 401 | } 402 | 403 | return $result; 404 | } 405 | 406 | private function restoreInternalSteamWrapper(): void 407 | { 408 | if (PHP_VERSION_ID < 70324 409 | || PHP_VERSION_ID >= 70400 && PHP_VERSION_ID < 70412) { 410 | stream_wrapper_restore('phar'); 411 | } else { 412 | // with https://github.com/php/php-src/pull/6183 (PHP #76943) the 413 | // behavior of `stream_wrapper_restore()` did change for 414 | // PHP 8.0-RC1, 7.4.12 and 7.3.24 415 | @stream_wrapper_restore('phar'); 416 | } 417 | } 418 | 419 | private function registerStreamWrapper(): void 420 | { 421 | stream_wrapper_unregister('phar'); 422 | stream_wrapper_register('phar', static::class); 423 | } 424 | } 425 | --------------------------------------------------------------------------------