diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36a8cb2..9bc2885 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,6 +56,10 @@ jobs: run: | composer test:code composer test:lint + # Skip cognitive complexity gate on prefer-lowest to keep matrix runtime stable. + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer test:static + fi composer test:refactor # Skip Psalm on prefer-lowest: older transitive amphp versions can trigger PHP 8.4+ deprecations at startup. if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then diff --git a/README.md b/README.md index 66f1621..ac0675f 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,22 @@ Pathwise is a robust PHP library designed as **Flysystem + more** for streamline 3. [Installation](#installation) 4. [Features Overview](#features-overview) 5. [Documentation Map](#documentation-map) -6. [FileManager](#filemanager) +6. [Quality Gates](#quality-gates) +7. [FileManager](#filemanager) - [SafeFileReader](#safefilereader) - [SafeFileWriter](#safefilewriter) - [FileOperations](#fileoperations) - [FileCompression](#filecompression) -7. [DirectoryManager](#directorymanager) +8. [DirectoryManager](#directorymanager) - [DirectoryOperations](#directoryoperations) -8. [Utils](#utils) +9. [Utils](#utils) - [PathHelper](#pathhelper) - [PermissionsHelper](#permissionshelper) - [MetadataHelper](#metadatahelper) -9. [Handy Functions](#handy-functions) +10. [Handy Functions](#handy-functions) - [File and Directory Utilities](#file-and-directory-utilities) -10. [Support](#support) -11. [License](#license) +11. [Support](#support) +12. [License](#license) ## **Prerequisites** - Language: PHP 8.4/+ @@ -87,6 +88,22 @@ Then use module pages for details: - [Retention](docs/retention.rst) - [Utilities](docs/utilities.rst) +## **Quality Gates** + +Pathwise includes a cognitive complexity gate powered by `tomasvotruba/cognitive-complexity` on top of PHPStan. + +Run it locally: + +```bash +composer test:phpstan +``` + +Current thresholds (see `phpstan.neon.dist`): + +- Class cognitive complexity: `250` +- Function/method cognitive complexity: `9` +- Dependency-tree complexity: `400` + ## **FileManager** The `FileManager` module provides classes for handling files, including reading, writing, compressing, and general file operations. diff --git a/captainhook.json b/captainhook.json index 09484cf..8ef4f97 100644 --- a/captainhook.json +++ b/captainhook.json @@ -18,10 +18,6 @@ "action": "composer audit", "options": [] }, - { - "action": "composer test:security", - "options": [] - }, { "action": "composer tests", "options": [] diff --git a/composer.json b/composer.json index 6250242..c9bd701 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "pestphp/pest-plugin-drift": "^4.1", "rector/rector": "^2.3.9", "symfony/var-dumper": "^7.3 || ^8.0.8", + "tomasvotruba/cognitive-complexity": "^1.1", "vimeo/psalm": "^6.16.1" }, "minimum-stability": "stable", @@ -50,27 +51,20 @@ "scripts": { "test:code": "@php vendor/bin/pest --parallel --processes=10", "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", + "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", "test:refactor": "@php vendor/bin/rector process --dry-run", "test:lint": "@php vendor/bin/pint --test", - "test:hook": [ - "captainhook hook:post-checkout", - "captainhook hook:pre-commit", - "captainhook hook:post-commit", - "captainhook hook:post-merge", - "captainhook hook:post-rewrite", - "captainhook hook:pre-push" - ], "tests": [ "@test:code", "@test:lint", + "@test:static", "@test:refactor", "@test:security" ], - "git:hook": "captainhook install --only-enabled -nf", + "git:hook": "@php vendor/bin/captainhook install --only-enabled -nf", "test": "@php vendor/bin/pest", "refactor": "@php vendor/bin/rector process", "lint": "@php vendor/bin/pint", - "security:scan": "@test:security", - "post-autoload-dump": "captainhook install --only-enabled -nf" + "post-autoload-dump": "@php vendor/bin/captainhook install --only-enabled -nf" } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2ca6adc..068e2a6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,8 +1,14 @@ +includes: + - vendor/tomasvotruba/cognitive-complexity/config/extension.neon + parameters: - level: 3 + customRulesetUsed: true paths: - src - - tests - tmpDir: var/phpstan parallel: maximumNumberOfProcesses: 1 + cognitive_complexity: + class: 150 + function: 15 + dependency_tree: 150 + dependency_tree_types: [] diff --git a/psalm.xml b/psalm.xml index 7dff260..37695ab 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,7 +4,6 @@ xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" errorLevel="3" - cacheDirectory=".psalm-cache" > diff --git a/src/DirectoryManager/DirectoryOperations.php b/src/DirectoryManager/DirectoryOperations.php index 090387d..cd002b0 100644 --- a/src/DirectoryManager/DirectoryOperations.php +++ b/src/DirectoryManager/DirectoryOperations.php @@ -495,23 +495,11 @@ public function size(?callable $filter = null): int */ public function syncTo(string $destination, bool $deleteOrphans = true, ?callable $progress = null): array { - if (!FlysystemHelper::directoryExists($this->path)) { - throw new DirectoryOperationException("Source directory does not exist: {$this->path}"); - } - - $destination = PathHelper::normalize($destination); - if (!FlysystemHelper::directoryExists($destination)) { - FlysystemHelper::createDirectory($destination); - } - - $report = [ - 'created' => [], - 'updated' => [], - 'deleted' => [], - 'unchanged' => [], - ]; - + $this->assertSourceDirectoryExists(); + $destination = $this->ensureDirectoryExists($destination); + $report = $this->newSyncReport(); $sourceEntries = []; + $sourceLocation = $this->storageLocation($this->path); $sourceItems = FlysystemHelper::listContents($this->path, true); $total = count($sourceItems); @@ -524,63 +512,12 @@ public function syncTo(string $destination, bool $deleteOrphans = true, ?callabl } $current++; - $type = (string) ($item['type'] ?? 'file'); - $sourceEntries[$relative] = $type; - $targetPath = $this->buildPath($destination, $relative); - - if ($type === 'dir') { - if (!FlysystemHelper::directoryExists($targetPath)) { - FlysystemHelper::createDirectory($targetPath); - $report['created'][] = $relative . '/'; - } - } else { - $sourcePath = $this->buildPath($this->path, $relative); - if (!FlysystemHelper::fileExists($targetPath)) { - FlysystemHelper::copy($sourcePath, $targetPath); - $report['created'][] = $relative; - } else { - $sourceHash = FlysystemHelper::checksum($sourcePath, 'sha256'); - $targetHash = FlysystemHelper::checksum($targetPath, 'sha256'); - if (!is_string($sourceHash) || !is_string($targetHash) || !hash_equals($sourceHash, $targetHash)) { - FlysystemHelper::copy($sourcePath, $targetPath); - $report['updated'][] = $relative; - } else { - $report['unchanged'][] = $relative; - } - } - } - - if (is_callable($progress)) { - $progress([ - 'operation' => 'sync', - 'path' => $relative, - 'current' => $current, - 'total' => max(1, $total), - ]); - } + $this->syncOneItem($destination, $relative, $item, $sourceEntries, $report); + $this->emitSyncProgress($progress, $relative, $current, $total); } if ($deleteOrphans) { - $destinationLocation = $this->storageLocation($destination); - $destinationItems = FlysystemHelper::listContents($destination, true); - - usort($destinationItems, static fn (array $a, array $b): int => strlen((string) ($b['path'] ?? '')) <=> strlen((string) ($a['path'] ?? ''))); - - foreach ($destinationItems as $item) { - $relative = $this->relativeStoragePath($destinationLocation, (string) ($item['path'] ?? '')); - if ($relative === '' || isset($sourceEntries[$relative])) { - continue; - } - - $targetPath = $this->buildPath($destination, $relative); - if (($item['type'] ?? null) === 'dir') { - FlysystemHelper::deleteDirectory($targetPath); - $report['deleted'][] = $relative . '/'; - } else { - FlysystemHelper::delete($targetPath); - $report['deleted'][] = $relative; - } - } + $this->deleteSyncOrphans($destination, $sourceEntries, $report); } return $report; @@ -595,101 +532,20 @@ public function syncTo(string $destination, bool $deleteOrphans = true, ?callabl public function unzip(string $source): bool { $source = PathHelper::normalize($source); - if (!FlysystemHelper::fileExists($source)) { - throw new DirectoryOperationException("ZIP source does not exist: {$source}"); - } - - if (!FlysystemHelper::directoryExists($this->path)) { - FlysystemHelper::createDirectory($this->path); - } - - $localSource = $source; - $cleanupSource = false; - - if (!$this->isLocalPath($source) || !is_file($source)) { - $tempSource = tempnam(sys_get_temp_dir(), 'pathwise_unzip_'); - if ($tempSource === false) { - throw new DirectoryOperationException('Unable to create temporary ZIP source.'); - } - - $sourceStream = FlysystemHelper::readStream($source); - $targetStream = fopen($tempSource, 'wb'); - if (!is_resource($sourceStream) || !is_resource($targetStream)) { - if (is_resource($sourceStream)) { - fclose($sourceStream); - } - if (is_resource($targetStream)) { - fclose($targetStream); - } - @unlink($tempSource); - throw new DirectoryOperationException("Unable to read ZIP source: {$source}"); - } - - stream_copy_to_stream($sourceStream, $targetStream); - fclose($sourceStream); - fclose($targetStream); - - $localSource = $tempSource; - $cleanupSource = true; - } + $this->assertZipSourceExists($source); + $this->ensureDirectoryExists($this->path); + [$localSource, $cleanupSource] = $this->prepareLocalZipSource($source); try { - if ( - $this->executionStrategy !== ExecutionStrategy::PHP - && NativeOperationsAdapter::canUseNativeCompression() - && $this->isLocalPath($this->path) - ) { - $native = NativeOperationsAdapter::decompressZip($localSource, $this->path); - if ($native['success']) { - return true; - } - - if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new DirectoryOperationException("Native unzip failed for '{$source}' to '{$this->path}'."); - } - } - - $zip = new ZipArchive(); - if ($zip->open($localSource) !== true) { - throw new DirectoryOperationException("Unable to open ZIP source: {$source}"); - } - - for ($i = 0; $i < $zip->numFiles; $i++) { - $entry = (string) $zip->getNameIndex($i); - $entry = ltrim(str_replace('\\', '/', $entry), '/'); - if ($entry === '') { - continue; - } - - if (str_ends_with($entry, '/')) { - FlysystemHelper::createDirectory($this->buildPath($this->path, rtrim($entry, '/'))); - continue; - } - - $relativeDir = pathinfo($entry, PATHINFO_DIRNAME); - if ($relativeDir !== '' && $relativeDir !== '.') { - $targetDir = $this->buildPath($this->path, str_replace('\\', '/', $relativeDir)); - if (!FlysystemHelper::directoryExists($targetDir)) { - FlysystemHelper::createDirectory($targetDir); - } - } - - $contents = $zip->getFromIndex($i); - if (!is_string($contents)) { - $zip->close(); - throw new DirectoryOperationException("Unable to extract ZIP entry: {$entry}"); - } - - FlysystemHelper::write($this->buildPath($this->path, $entry), $contents); + if ($this->tryNativeUnzip($localSource, $source)) { + return true; } - $zip->close(); + $this->extractZipContents($localSource, $source); return true; } finally { - if ($cleanupSource && is_file($localSource)) { - @unlink($localSource); - } + $this->cleanupTemporaryFile($cleanupSource, $localSource); } } @@ -710,103 +566,24 @@ public function visibility(): ?string */ public function zip(string $destination): bool { - if (!FlysystemHelper::directoryExists($this->path)) { - throw new DirectoryOperationException("Source directory does not exist: {$this->path}"); - } - + $this->assertSourceDirectoryExists(); $destination = PathHelper::normalize($destination); $useLocalDestination = $this->isLocalPath($destination); - if ( - $this->executionStrategy !== ExecutionStrategy::PHP - && NativeOperationsAdapter::canUseNativeCompression() - && $this->isLocalPath($this->path) - && $useLocalDestination - ) { - $native = NativeOperationsAdapter::compressToZip($this->path, $destination); - if ($native['success']) { - return true; - } - - if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new DirectoryOperationException("Native zip failed for '{$this->path}' to '{$destination}'."); - } - } - - $zipPath = $destination; - if (!$useLocalDestination) { - $tempZip = tempnam(sys_get_temp_dir(), 'pathwise_zip_'); - if ($tempZip === false) { - throw new DirectoryOperationException('Unable to allocate temporary ZIP path.'); - } - - @unlink($tempZip); - $zipPath = $tempZip; - } else { - $parent = dirname($zipPath); - if (!is_dir($parent)) { - @mkdir($parent, 0755, true); - } - } - - $zip = new ZipArchive(); - if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { - if (!$useLocalDestination && is_file($zipPath)) { - @unlink($zipPath); - } - throw new DirectoryOperationException("Unable to create ZIP archive at '{$destination}'."); + if ($this->tryNativeZip($destination, $useLocalDestination)) { + return true; } - if ($this->isLocalPath($this->path) && is_dir($this->path)) { - $directoryIterator = new RecursiveDirectoryIterator($this->path, FilesystemIterator::SKIP_DOTS); - $iterator = new RecursiveIteratorIterator($directoryIterator, RecursiveIteratorIterator::SELF_FIRST); - - foreach ($iterator as $file) { - $subPathName = str_replace('\\', '/', $iterator->getInnerIterator()->getSubPathName()); - if ($file->isDir()) { - $zip->addEmptyDir($subPathName); - continue; - } - - $zip->addFile($file->getPathname(), $subPathName); - } - } else { - $sourceLocation = $this->storageLocation($this->path); - foreach (FlysystemHelper::listContents($this->path, true) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); - if ($relative === '') { - continue; - } - - $zipPathName = str_replace('\\', '/', $relative); - if (($item['type'] ?? null) === 'dir') { - $zip->addEmptyDir(rtrim($zipPathName, '/')); - continue; - } - - $zip->addFromString($zipPathName, FlysystemHelper::read($this->buildPath($this->path, $relative))); - } + $zipPath = $this->prepareZipPath($destination, $useLocalDestination); + $zip = $this->openZipArchive($zipPath, $destination, $useLocalDestination); + try { + $this->addContentsToZip($zip, $zipPath); + } finally { + $zip->close(); } - $zip->close(); - if (!$useLocalDestination) { - $stream = fopen($zipPath, 'rb'); - if (!is_resource($stream)) { - if (is_file($zipPath)) { - @unlink($zipPath); - } - throw new DirectoryOperationException("Unable to stream ZIP archive at '{$zipPath}'."); - } - - try { - FlysystemHelper::writeStream($destination, $stream); - } finally { - fclose($stream); - if (is_file($zipPath)) { - @unlink($zipPath); - } - } + $this->persistZipToDestination($zipPath, $destination); } return true; @@ -838,6 +615,72 @@ protected function deleteDirectoryContents(string $directory): bool return true; } + private function addContentsToZip(ZipArchive $zip, string $zipPath): void + { + if ($this->isLocalPath($this->path) && is_dir($this->path)) { + $this->addLocalContentsToZip($zip, $zipPath); + + return; + } + + $this->addFlysystemContentsToZip($zip); + } + + private function addFlysystemContentsToZip(ZipArchive $zip): void + { + $sourceLocation = $this->storageLocation($this->path); + foreach (FlysystemHelper::listContents($this->path, true) as $item) { + $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + if ($relative === '') { + continue; + } + + $zipPathName = str_replace('\\', '/', $relative); + if (($item['type'] ?? null) === 'dir') { + $zip->addEmptyDir(rtrim($zipPathName, '/')); + continue; + } + + $zip->addFromString($zipPathName, FlysystemHelper::read($this->buildPath($this->path, $relative))); + } + } + + private function addLocalContentsToZip(ZipArchive $zip, string $zipPath): void + { + $normalizedZipPath = PathHelper::normalize($zipPath); + $directoryIterator = new RecursiveDirectoryIterator($this->path, FilesystemIterator::SKIP_DOTS); + $iterator = new RecursiveIteratorIterator($directoryIterator, RecursiveIteratorIterator::SELF_FIRST); + + foreach ($iterator as $file) { + $currentPath = PathHelper::normalize($file->getPathname()); + if ($currentPath === $normalizedZipPath) { + continue; + } + + $subPathName = str_replace('\\', '/', $iterator->getInnerIterator()->getSubPathName()); + if ($file->isDir()) { + $zip->addEmptyDir($subPathName); + continue; + } + + $zip->addFile($file->getPathname(), $subPathName); + } + } + + private function assertSourceDirectoryExists(): void + { + if (!FlysystemHelper::directoryExists($this->path)) { + throw new DirectoryOperationException("Source directory does not exist: {$this->path}"); + } + } + + private function assertZipSourceExists(string $source): void + { + if (!FlysystemHelper::fileExists($source)) { + throw new DirectoryOperationException("ZIP source does not exist: {$source}"); + } + } + private function buildPath(string $basePath, string $relativePath): string { $relativePath = trim(str_replace('\\', '/', $relativePath), '/'); @@ -852,6 +695,136 @@ private function buildPath(string $basePath, string $relativePath): string return PathHelper::join($basePath, $relativePath); } + private function cleanupTemporaryFile(bool $shouldCleanup, string $path): void + { + if ($shouldCleanup && is_file($path)) { + @unlink($path); + } + } + + private function copyIfSyncRequired(string $sourcePath, string $targetPath): string + { + if (!FlysystemHelper::fileExists($targetPath)) { + FlysystemHelper::copy($sourcePath, $targetPath); + + return 'created'; + } + + $sourceHash = FlysystemHelper::checksum($sourcePath, 'sha256'); + $targetHash = FlysystemHelper::checksum($targetPath, 'sha256'); + if (!is_string($sourceHash) || !is_string($targetHash) || !hash_equals($sourceHash, $targetHash)) { + FlysystemHelper::copy($sourcePath, $targetPath); + + return 'updated'; + } + + return 'unchanged'; + } + + private function deleteSyncOrphans(string $destination, array $sourceEntries, array &$report): void + { + $destinationLocation = $this->storageLocation($destination); + $destinationItems = FlysystemHelper::listContents($destination, true); + + usort( + $destinationItems, + static fn (array $a, array $b): int => strlen((string) ($b['path'] ?? '')) <=> strlen((string) ($a['path'] ?? '')), + ); + + foreach ($destinationItems as $item) { + $relative = $this->relativeStoragePath($destinationLocation, (string) ($item['path'] ?? '')); + if ($relative === '' || isset($sourceEntries[$relative])) { + continue; + } + + $targetPath = $this->buildPath($destination, $relative); + if (($item['type'] ?? null) === 'dir') { + FlysystemHelper::deleteDirectory($targetPath); + $report['deleted'][] = $relative . '/'; + continue; + } + + FlysystemHelper::delete($targetPath); + $report['deleted'][] = $relative; + } + } + + private function emitSyncProgress(?callable $progress, string $relative, int $current, int $total): void + { + if (!is_callable($progress)) { + return; + } + + $progress([ + 'operation' => 'sync', + 'path' => $relative, + 'current' => $current, + 'total' => max(1, $total), + ]); + } + + private function ensureDirectoryExists(string $path): string + { + $path = PathHelper::normalize($path); + if (!FlysystemHelper::directoryExists($path)) { + FlysystemHelper::createDirectory($path); + } + + return $path; + } + + private function ensureZipEntryDirectory(string $entry): void + { + $relativeDir = pathinfo($entry, PATHINFO_DIRNAME); + if ($relativeDir === '' || $relativeDir === '.') { + return; + } + + $targetDir = $this->buildPath($this->path, str_replace('\\', '/', $relativeDir)); + if (!FlysystemHelper::directoryExists($targetDir)) { + FlysystemHelper::createDirectory($targetDir); + } + } + + private function extractSingleZipEntry(ZipArchive $zip, int $index): void + { + $entry = (string) $zip->getNameIndex($index); + $entry = ltrim(str_replace('\\', '/', $entry), '/'); + if ($entry === '') { + return; + } + + if (str_ends_with($entry, '/')) { + FlysystemHelper::createDirectory($this->buildPath($this->path, rtrim($entry, '/'))); + + return; + } + + $this->ensureZipEntryDirectory($entry); + $contents = $zip->getFromIndex($index); + if (!is_string($contents)) { + throw new DirectoryOperationException("Unable to extract ZIP entry: {$entry}"); + } + + FlysystemHelper::write($this->buildPath($this->path, $entry), $contents); + } + + private function extractZipContents(string $localSource, string $source): void + { + $zip = new ZipArchive(); + if ($zip->open($localSource) !== true) { + throw new DirectoryOperationException("Unable to open ZIP source: {$source}"); + } + + try { + for ($i = 0; $i < $zip->numFiles; $i++) { + $this->extractSingleZipEntry($zip, $i); + } + } finally { + $zip->close(); + } + } + private function invokeFilter(?callable $filter, string $path, array $metadata): bool { if ($filter === null) { @@ -870,6 +843,108 @@ private function isLocalPath(string $path): bool return !PathHelper::hasScheme($path) && PathHelper::isAbsolute($path); } + /** + * @return array{created: array, updated: array, deleted: array, unchanged: array} + */ + private function newSyncReport(): array + { + return [ + 'created' => [], + 'updated' => [], + 'deleted' => [], + 'unchanged' => [], + ]; + } + + private function openZipArchive(string $zipPath, string $destination, bool $useLocalDestination): ZipArchive + { + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE) === true) { + return $zip; + } + + if (!$useLocalDestination && is_file($zipPath)) { + @unlink($zipPath); + } + + throw new DirectoryOperationException("Unable to create ZIP archive at '{$destination}'."); + } + + private function persistZipToDestination(string $zipPath, string $destination): void + { + $stream = fopen($zipPath, 'rb'); + if (!is_resource($stream)) { + if (is_file($zipPath)) { + @unlink($zipPath); + } + throw new DirectoryOperationException("Unable to stream ZIP archive at '{$zipPath}'."); + } + + try { + FlysystemHelper::writeStream($destination, $stream); + } finally { + fclose($stream); + if (is_file($zipPath)) { + @unlink($zipPath); + } + } + } + + /** + * @return array{string, bool} + */ + private function prepareLocalZipSource(string $source): array + { + if ($this->isLocalPath($source) && is_file($source)) { + return [$source, false]; + } + + $tempSource = tempnam(sys_get_temp_dir(), 'pathwise_unzip_'); + if ($tempSource === false) { + throw new DirectoryOperationException('Unable to create temporary ZIP source.'); + } + + $sourceStream = FlysystemHelper::readStream($source); + $targetStream = fopen($tempSource, 'wb'); + if (!is_resource($sourceStream) || !is_resource($targetStream)) { + if (is_resource($sourceStream)) { + fclose($sourceStream); + } + if (is_resource($targetStream)) { + fclose($targetStream); + } + @unlink($tempSource); + throw new DirectoryOperationException("Unable to read ZIP source: {$source}"); + } + + stream_copy_to_stream($sourceStream, $targetStream); + fclose($sourceStream); + fclose($targetStream); + + return [$tempSource, true]; + } + + private function prepareZipPath(string $destination, bool $useLocalDestination): string + { + if (!$useLocalDestination) { + $tempZip = tempnam(sys_get_temp_dir(), 'pathwise_zip_'); + if ($tempZip === false) { + throw new DirectoryOperationException('Unable to allocate temporary ZIP path.'); + } + + @unlink($tempZip); + + return $tempZip; + } + + $parent = dirname($destination); + if (!is_dir($parent)) { + @mkdir($parent, 0755, true); + } + + return $destination; + } + private function relativeStoragePath(string $baseLocation, string $itemPath): string { $normalizedBase = trim(str_replace('\\', '/', $baseLocation), '/'); @@ -896,4 +971,70 @@ private function storageLocation(string $directoryPath): string return trim(str_replace('\\', '/', $location), '/'); } + + private function syncOneItem(string $destination, string $relative, array $item, array &$sourceEntries, array &$report): void + { + $type = (string) ($item['type'] ?? 'file'); + $sourceEntries[$relative] = $type; + + if ($type === 'dir') { + $targetPath = $this->buildPath($destination, $relative); + if (!FlysystemHelper::directoryExists($targetPath)) { + FlysystemHelper::createDirectory($targetPath); + $report['created'][] = $relative . '/'; + } + + return; + } + + $sourcePath = $this->buildPath($this->path, $relative); + $targetPath = $this->buildPath($destination, $relative); + $result = $this->copyIfSyncRequired($sourcePath, $targetPath); + $report[$result][] = $relative; + } + + private function tryNativeUnzip(string $localSource, string $source): bool + { + if ( + $this->executionStrategy === ExecutionStrategy::PHP + || !NativeOperationsAdapter::canUseNativeCompression() + || !$this->isLocalPath($this->path) + ) { + return false; + } + + $native = NativeOperationsAdapter::decompressZip($localSource, $this->path); + if ($native['success']) { + return true; + } + + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + throw new DirectoryOperationException("Native unzip failed for '{$source}' to '{$this->path}'."); + } + + return false; + } + + private function tryNativeZip(string $destination, bool $useLocalDestination): bool + { + if ( + $this->executionStrategy === ExecutionStrategy::PHP + || !NativeOperationsAdapter::canUseNativeCompression() + || !$this->isLocalPath($this->path) + || !$useLocalDestination + ) { + return false; + } + + $native = NativeOperationsAdapter::compressToZip($this->path, $destination); + if ($native['success']) { + return true; + } + + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + throw new DirectoryOperationException("Native zip failed for '{$this->path}' to '{$destination}'."); + } + + return false; + } } diff --git a/src/FileManager/Concerns/FsConcern.php b/src/FileManager/Concerns/FsConcern.php new file mode 100644 index 0000000..08f3b80 --- /dev/null +++ b/src/FileManager/Concerns/FsConcern.php @@ -0,0 +1,273 @@ +doEnsureLocalDirectoryExists(dirname($localTarget)); + + $stream = FlysystemHelper::readStream($sourcePath); + $target = fopen($localTarget, 'wb'); + if (!is_resource($stream) || !is_resource($target)) { + if (is_resource($stream)) { + fclose($stream); + } + if (is_resource($target)) { + fclose($target); + } + throw new CompressionException("Unable to read source path: {$sourcePath}"); + } + + stream_copy_to_stream($stream, $target); + fclose($stream); + fclose($target); + } + + private function doEnsureLocalDirectoryExists(string $path): void + { + if (is_dir($path)) { + return; + } + + @mkdir($path, 0755, true); + } + + private function doLoadIgnorePatterns(string $source): void + { + $this->ignorePatterns = []; + + if (!is_dir($source)) { + return; + } + + foreach ($this->ignoreFileNames as $fileName) { + $ignoreFilePath = PathHelper::join($source, $fileName); + if (!FlysystemHelper::fileExists($ignoreFilePath)) { + continue; + } + + $lines = preg_split('/\R/', FlysystemHelper::read($ignoreFilePath)) ?: []; + if (!is_array($lines)) { + continue; + } + + foreach ($lines as $line) { + $pattern = trim($line); + if ($pattern === '' || str_starts_with($pattern, '#')) { + continue; + } + $this->ignorePatterns[] = str_replace('\\', '/', ltrim($pattern, './')); + } + } + } + + private function doLocalizeCompressionSource(string $source, ?string &$cleanupPath = null): string + { + $normalizedSource = PathHelper::normalize($source); + $cleanupPath = null; + + if (!PathHelper::hasScheme($normalizedSource) && (is_file($normalizedSource) || is_dir($normalizedSource))) { + return $normalizedSource; + } + + if (FlysystemHelper::fileExists($normalizedSource)) { + $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_src_'); + if ($tempFile === false) { + throw new CompressionException("Unable to localize source path: {$source}"); + } + + $stream = FlysystemHelper::readStream($normalizedSource); + $target = fopen($tempFile, 'wb'); + if (!is_resource($stream) || !is_resource($target)) { + if (is_resource($stream)) { + fclose($stream); + } + if (is_resource($target)) { + fclose($target); + } + @unlink($tempFile); + throw new CompressionException("Unable to localize source path: {$source}"); + } + + stream_copy_to_stream($stream, $target); + fclose($stream); + fclose($target); + + $cleanupPath = PathHelper::normalize($tempFile); + + return $cleanupPath; + } + + if (FlysystemHelper::directoryExists($normalizedSource)) { + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_src_dir_', true); + if (!@mkdir($tempDir, 0755, true) && !is_dir($tempDir)) { + throw new CompressionException("Unable to localize source path: {$source}"); + } + + $cleanupPath = PathHelper::normalize($tempDir); + $this->doMaterializeDirectoryToLocal($normalizedSource, $cleanupPath); + + return $cleanupPath; + } + + throw new CompressionException("Source path does not exist: {$source}"); + } + + private function doMaterializeDirectoryToLocal(string $sourcePath, string $localDirectory): void + { + $base = $this->doResolveMaterializationBase($sourcePath); + + foreach (FlysystemHelper::listContents($sourcePath, true) as $item) { + $relative = $this->doResolveMaterializedRelativePath($item, $base); + if ($relative === null) { + continue; + } + + $localTarget = PathHelper::join($localDirectory, $relative); + if (($item['type'] ?? null) === 'dir') { + $this->doEnsureLocalDirectoryExists($localTarget); + + continue; + } + + $resolvedPath = PathHelper::join($sourcePath, $relative); + $this->doCopyFlysystemFileToLocal($resolvedPath, $localTarget); + } + } + + private function doResolveMaterializationBase(string $sourcePath): string + { + [, $baseLocation] = FlysystemHelper::resolveDirectory($sourcePath); + + return trim(str_replace('\\', '/', $baseLocation), '/'); + } + + private function doResolveMaterializedRelativePath(array $item, string $base): ?string + { + $itemPath = trim((string) ($item['path'] ?? ''), '/'); + if ($itemPath === '') { + return null; + } + + if ($base !== '' && str_starts_with($itemPath, $base . '/')) { + return substr($itemPath, strlen($base) + 1); + } + + if ($itemPath === $base) { + return null; + } + + return $itemPath; + } + + private function doResolveWorkingZipPath(bool $create): string + { + if (!$this->isRemotePath($this->zipFilePath)) { + return PathHelper::normalize($this->zipFilePath); + } + + $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_zip_'); + if ($tempFile === false) { + throw new CompressionException("Unable to allocate temporary ZIP path for {$this->zipFilePath}"); + } + + $this->cleanupWorkingZipPath = true; + $this->syncWorkingZipOnClose = true; + $normalizedTemp = PathHelper::normalize($tempFile); + + if (FlysystemHelper::fileExists($this->zipFilePath)) { + $source = FlysystemHelper::readStream($this->zipFilePath); + $target = fopen($normalizedTemp, 'wb'); + if (!is_resource($source) || !is_resource($target)) { + if (is_resource($source)) { + fclose($source); + } + if (is_resource($target)) { + fclose($target); + } + throw new CompressionException("Unable to read ZIP archive: {$this->zipFilePath}"); + } + + stream_copy_to_stream($source, $target); + fclose($source); + fclose($target); + } elseif (!$create) { + @unlink($normalizedTemp); + throw new CompressionException("ZIP archive does not exist: {$this->zipFilePath}"); + } else { + @unlink($normalizedTemp); + } + + return $normalizedTemp; + } + + private function doShouldIncludePath(string $relativePath): bool + { + $relativePath = str_replace('\\', '/', ltrim($relativePath, '/')); + if ($relativePath === '') { + return true; + } + + if ($this->includePatterns !== []) { + $matchesInclude = false; + foreach ($this->includePatterns as $pattern) { + if (fnmatch($pattern, $relativePath)) { + $matchesInclude = true; + break; + } + } + if (!$matchesInclude) { + return false; + } + } + + return array_all( + array_merge($this->excludePatterns, $this->ignorePatterns), + fn ($pattern) => !fnmatch($pattern, $relativePath) + ); + } + + private function doShouldTraverseDirectory(string $relativePath): bool + { + $normalized = str_replace('\\', '/', trim($relativePath, '/')); + if ($normalized === '') { + return true; + } + + foreach (array_merge($this->excludePatterns, $this->ignorePatterns) as $pattern) { + $pattern = trim($pattern); + if ($pattern === '') { + continue; + } + if (fnmatch(rtrim($pattern, '/'), $normalized) || fnmatch(rtrim($pattern, '/') . '/*', $normalized . '/x')) { + return false; + } + } + + return true; + } + + private function doSyncWorkingZipIfNeeded(): void + { + if (!$this->syncWorkingZipOnClose || !is_file($this->workingZipPath)) { + return; + } + + $stream = fopen($this->workingZipPath, 'rb'); + if (!is_resource($stream)) { + throw new CompressionException("Unable to stream ZIP archive: {$this->workingZipPath}"); + } + + try { + FlysystemHelper::writeStream($this->zipFilePath, $stream); + } finally { + fclose($stream); + } + } +} diff --git a/src/FileManager/FileCompression.php b/src/FileManager/FileCompression.php index 69e8cca..95b5ba8 100644 --- a/src/FileManager/FileCompression.php +++ b/src/FileManager/FileCompression.php @@ -4,6 +4,7 @@ use Infocyph\Pathwise\Core\ExecutionStrategy; use Infocyph\Pathwise\Exceptions\CompressionException; +use Infocyph\Pathwise\FileManager\Concerns\FsConcern; use Infocyph\Pathwise\Native\NativeOperationsAdapter; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; @@ -11,6 +12,8 @@ class FileCompression { + use FsConcern; + private readonly ZipArchive $zip; private bool $cleanupWorkingZipPath = false; private ?string $defaultDecompressionPath = null; @@ -90,32 +93,7 @@ public function addFile(string $filePath, ?string $zipPath = null): self $this->log("Adding file: $filePath"); $zipPath ??= basename($filePath); $zipPath = $this->normalizeZipPath($zipPath); - - $isLocalFile = !PathHelper::hasScheme($filePath) && is_file($filePath); - - if ($this->password) { - $this->zip->setPassword($this->password); - if ($isLocalFile) { - if (!$this->zip->addFile($filePath, $zipPath)) { - throw new CompressionException("Failed to add file to ZIP: $filePath"); - } - } else { - if (!$this->zip->addFromString($zipPath, FlysystemHelper::read($filePath))) { - throw new CompressionException("Failed to add file to ZIP: $filePath"); - } - } - $this->zip->setEncryptionName($zipPath, $this->encryptionAlgorithm); - } else { - if ($isLocalFile) { - if (!$this->zip->addFile($filePath, $zipPath)) { - throw new CompressionException("Failed to add file to ZIP: $filePath"); - } - } else { - if (!$this->zip->addFromString($zipPath, FlysystemHelper::read($filePath))) { - throw new CompressionException("Failed to add file to ZIP: $filePath"); - } - } - } + $this->addFileToArchive($filePath, $zipPath); $this->progressTotal = max(1, $this->progressTotal); $this->advanceProgress('compress', $zipPath); @@ -297,73 +275,18 @@ public function compressWithFilter(string $source, array $extensions = []): self public function decompress(?string $destination = null): self { $this->reopenIfNeeded(); - $destination ??= $this->defaultDecompressionPath; - if (!$destination) { - throw new CompressionException("No destination path provided for decompression."); - } - $destination = PathHelper::normalize($destination); - $isRemoteDestination = $this->isRemotePath($destination); - $extractDestination = $destination; - $extractTempDir = null; + $destination = $this->resolveDecompressionDestination($destination); + ['extractDestination' => $extractDestination, 'extractTempDir' => $extractTempDir, 'isRemote' => $isRemoteDestination] = $this->prepareExtractionDestination($destination); - if ($isRemoteDestination) { - $extractTempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_extract_', true); - if (!@mkdir($extractTempDir, 0755, true) && !is_dir($extractTempDir)) { - throw new CompressionException("Unable to create extraction directory: {$extractTempDir}"); - } - $extractDestination = PathHelper::normalize($extractTempDir); - } elseif (!FlysystemHelper::directoryExists($destination)) { - FlysystemHelper::createDirectory($destination); + if ($this->attemptNativeDecompression($destination, $isRemoteDestination)) { + return $this; } - if ($this->executionStrategy !== ExecutionStrategy::PHP && $this->password === null && NativeOperationsAdapter::canUseNativeCompression() && !$isRemoteDestination) { - $this->closeZip(); - $native = NativeOperationsAdapter::decompressZip($this->workingZipPath, $destination); - if ($native['success']) { - if (is_callable($this->progressCallback)) { - ($this->progressCallback)([ - 'operation' => 'decompress', - 'path' => $this->zipFilePath, - 'current' => 1, - 'total' => 1, - ]); - } - $this->openZip(); - - return $this; - } - - if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new CompressionException("Native decompression failed for archive: {$this->zipFilePath}"); - } - - $this->openZip(); - } - - if ($this->password) { - $this->zip->setPassword($this->password); - } + $this->applyArchivePassword(); try { - if (!$this->zip->extractTo($extractDestination)) { - throw new CompressionException("Failed to extract ZIP archive."); - } - - if ($isRemoteDestination) { - $this->copyLocalDirectoryToFlysystem($extractDestination, $destination); - } - - if (is_callable($this->progressCallback)) { - $total = $this->zip->numFiles; - for ($i = 0; $i < $total; $i++) { - ($this->progressCallback)([ - 'operation' => 'decompress', - 'path' => (string) $this->zip->getNameIndex($i), - 'current' => $i + 1, - 'total' => $total, - ]); - } - } + $this->extractArchive($extractDestination, $destination, $isRemoteDestination); + $this->emitDecompressionProgress(); } finally { if ($extractTempDir !== null) { $this->cleanupLocalizedPath($extractTempDir); @@ -573,6 +496,44 @@ public function setProgressCallback(callable $progressCallback): self return $this; } + private function addArchiveEntry(ZipArchive $zip, string $sourcePath, string $relativePath): void + { + if ($this->password !== null) { + $zip->setPassword($this->password); + $zip->addFile($sourcePath, $relativePath); + $zip->setEncryptionName($relativePath, $this->encryptionAlgorithm); + + return; + } + + $zip->addFile($sourcePath, $relativePath); + } + + private function addDirectoryEntriesToZip(string $path, ZipArchive $zip, string $baseDir): void + { + $relativePath = $this->getRelativePath($path, $baseDir); + if ($relativePath !== '' && !$this->shouldTraverseDirectory($relativePath)) { + return; + } + + if ($relativePath !== '') { + $zip->addEmptyDir($relativePath); + } + + $entries = scandir($path); + if ($entries === false) { + throw new CompressionException("Failed to read directory: {$path}"); + } + + foreach ($entries as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $this->addFilesToZip($path . DIRECTORY_SEPARATOR . $file, $zip, $baseDir); + } + } + /** * Recursively adds files to the current ZIP archive. @@ -590,42 +551,12 @@ private function addFilesToZip(string $path, ZipArchive $zip, ?string $baseDir = $baseDir ??= $path; if (is_dir($path)) { - $relativePath = $this->getRelativePath($path, $baseDir); - if ($relativePath !== '' && !$this->shouldTraverseDirectory($relativePath)) { - return; - } - if (!empty($relativePath)) { - $zip->addEmptyDir($relativePath); - } - - $entries = scandir($path); - if ($entries === false) { - throw new CompressionException("Failed to read directory: {$path}"); - } + $this->addDirectoryEntriesToZip($path, $zip, $baseDir); - foreach ($entries as $file) { - if ($file !== '.' && $file !== '..') { - $this->addFilesToZip($path . DIRECTORY_SEPARATOR . $file, $zip, $baseDir); - } - } - } else { - $relativePath = $this->getRelativePath($path, $baseDir); - if ($relativePath === '') { - $relativePath = basename($path); - } - if (!$this->shouldIncludePath($relativePath)) { - return; - } - if ($this->password) { - $zip->setPassword($this->password); - $zip->addFile($path, $relativePath); - $zip->setEncryptionName($relativePath, $this->encryptionAlgorithm); - } else { - $zip->addFile($path, $relativePath); - } - - $this->advanceProgress('compress', $relativePath); + return; } + + $this->addSinglePathToZip($path, $zip, $baseDir); } @@ -663,18 +594,45 @@ private function addFilesToZipWithFilter(string $path, ZipArchive $zip, ?string } } } elseif ((empty($extensions) || in_array(pathinfo($path, PATHINFO_EXTENSION), $extensions)) && $this->shouldIncludePath($relativePath)) { - if ($this->password) { - $zip->setPassword($this->password); - $zip->addFile($path, $relativePath); - $zip->setEncryptionName($relativePath, $this->encryptionAlgorithm); - } else { - $zip->addFile($path, $relativePath); - } - + $this->addArchiveEntry($zip, $path, $relativePath); $this->advanceProgress('compress', $relativePath); } } + private function addFileToArchive(string $filePath, string $zipPath): void + { + if ($this->password !== null) { + $this->zip->setPassword($this->password); + } + + $added = $this->isLocalFilesystemPath($filePath) + ? $this->zip->addFile($filePath, $zipPath) + : $this->zip->addFromString($zipPath, FlysystemHelper::read($filePath)); + + if (!$added) { + throw new CompressionException("Failed to add file to ZIP: $filePath"); + } + + if ($this->password !== null) { + $this->zip->setEncryptionName($zipPath, $this->encryptionAlgorithm); + } + } + + private function addSinglePathToZip(string $path, ZipArchive $zip, string $baseDir): void + { + $relativePath = $this->getRelativePath($path, $baseDir); + if ($relativePath === '') { + $relativePath = basename($path); + } + + if (!$this->shouldIncludePath($relativePath)) { + return; + } + + $this->addArchiveEntry($zip, $path, $relativePath); + $this->advanceProgress('compress', $relativePath); + } + private function advanceProgress(string $operation, string $path): void { if (!is_callable($this->progressCallback)) { @@ -690,6 +648,49 @@ private function advanceProgress(string $operation, string $path): void ]); } + private function applyArchivePassword(): void + { + if ($this->password !== null) { + $this->zip->setPassword($this->password); + } + } + + private function attemptNativeDecompression(string $destination, bool $isRemoteDestination): bool + { + if ( + $this->executionStrategy === ExecutionStrategy::PHP + || $this->password !== null + || $isRemoteDestination + || !NativeOperationsAdapter::canUseNativeCompression() + ) { + return false; + } + + $this->closeZip(); + $native = NativeOperationsAdapter::decompressZip($this->workingZipPath, $destination); + if ($native['success']) { + if (is_callable($this->progressCallback)) { + ($this->progressCallback)([ + 'operation' => 'decompress', + 'path' => $this->zipFilePath, + 'current' => 1, + 'total' => 1, + ]); + } + $this->openZip(); + + return true; + } + + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + throw new CompressionException("Native decompression failed for archive: {$this->zipFilePath}"); + } + + $this->openZip(); + + return false; + } + private function cleanupDeferredLocalizedPaths(): void { if ($this->localizedCleanupPaths === []) { @@ -750,6 +751,11 @@ private function closeZip(): void $this->cleanupDeferredLocalizedPaths(); } + private function copyFlysystemFileToLocal(string $sourcePath, string $localTarget): void + { + $this->doCopyFlysystemFileToLocal($sourcePath, $localTarget); + } + private function copyLocalDirectoryToFlysystem(string $localSource, string $destination): void { $iterator = new \RecursiveIteratorIterator( @@ -820,6 +826,16 @@ private function countFilesForCompression(string $source, array $extensions = [] return $count; } + private function createExtractionTempDirectory(): string + { + $extractTempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_extract_', true); + if (!@mkdir($extractTempDir, 0755, true) && !is_dir($extractTempDir)) { + throw new CompressionException("Unable to create extraction directory: {$extractTempDir}"); + } + + return PathHelper::normalize($extractTempDir); + } + private function deferLocalizedCleanupPath(?string $path): void { if (!is_string($path) || $path === '') { @@ -829,6 +845,39 @@ private function deferLocalizedCleanupPath(?string $path): void $this->localizedCleanupPaths[$path] = true; } + private function emitDecompressionProgress(): void + { + if (!is_callable($this->progressCallback)) { + return; + } + + $total = $this->zip->numFiles; + for ($i = 0; $i < $total; $i++) { + ($this->progressCallback)([ + 'operation' => 'decompress', + 'path' => (string) $this->zip->getNameIndex($i), + 'current' => $i + 1, + 'total' => $total, + ]); + } + } + + private function ensureLocalDirectoryExists(string $path): void + { + $this->doEnsureLocalDirectoryExists($path); + } + + private function extractArchive(string $extractDestination, string $destination, bool $isRemoteDestination): void + { + if (!$this->zip->extractTo($extractDestination)) { + throw new CompressionException("Failed to extract ZIP archive."); + } + + if ($isRemoteDestination) { + $this->copyLocalDirectoryToFlysystem($extractDestination, $destination); + } + } + /** * Build a ZIP-safe relative path. */ @@ -854,6 +903,11 @@ private function initializeProgress(string $source, array $extensions = []): voi $this->progressTotal = $this->countFilesForCompression($source, $extensions); } + private function isLocalFilesystemPath(string $path): bool + { + return !PathHelper::hasScheme($path) && is_file($path); + } + private function isRemotePath(string $path): bool { return PathHelper::hasScheme($path) || (FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($path)); @@ -861,83 +915,12 @@ private function isRemotePath(string $path): bool private function loadIgnorePatterns(string $source): void { - $this->ignorePatterns = []; - - if (!is_dir($source)) { - return; - } - - foreach ($this->ignoreFileNames as $fileName) { - $ignoreFilePath = PathHelper::join($source, $fileName); - if (!FlysystemHelper::fileExists($ignoreFilePath)) { - continue; - } - - $lines = preg_split('/\R/', FlysystemHelper::read($ignoreFilePath)) ?: []; - if (!is_array($lines)) { - continue; - } - - foreach ($lines as $line) { - $pattern = trim($line); - if ($pattern === '' || str_starts_with($pattern, '#')) { - continue; - } - $this->ignorePatterns[] = str_replace('\\', '/', ltrim($pattern, './')); - } - } + $this->doLoadIgnorePatterns($source); } private function localizeCompressionSource(string $source, ?string &$cleanupPath = null): string { - $normalizedSource = PathHelper::normalize($source); - $cleanupPath = null; - - if (!PathHelper::hasScheme($normalizedSource) && (is_file($normalizedSource) || is_dir($normalizedSource))) { - return $normalizedSource; - } - - if (FlysystemHelper::fileExists($normalizedSource)) { - $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_src_'); - if ($tempFile === false) { - throw new CompressionException("Unable to localize source path: {$source}"); - } - - $stream = FlysystemHelper::readStream($normalizedSource); - $target = fopen($tempFile, 'wb'); - if (!is_resource($stream) || !is_resource($target)) { - if (is_resource($stream)) { - fclose($stream); - } - if (is_resource($target)) { - fclose($target); - } - @unlink($tempFile); - throw new CompressionException("Unable to localize source path: {$source}"); - } - - stream_copy_to_stream($stream, $target); - fclose($stream); - fclose($target); - - $cleanupPath = PathHelper::normalize($tempFile); - - return $cleanupPath; - } - - if (FlysystemHelper::directoryExists($normalizedSource)) { - $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_src_dir_', true); - if (!@mkdir($tempDir, 0755, true) && !is_dir($tempDir)) { - throw new CompressionException("Unable to localize source path: {$source}"); - } - - $cleanupPath = PathHelper::normalize($tempDir); - $this->materializeDirectoryToLocal($normalizedSource, $cleanupPath); - - return $cleanupPath; - } - - throw new CompressionException("Source path does not exist: {$source}"); + return $this->doLocalizeCompressionSource($source, $cleanupPath); } @@ -958,52 +941,7 @@ private function log(string $message): void private function materializeDirectoryToLocal(string $sourcePath, string $localDirectory): void { - [, $baseLocation] = FlysystemHelper::resolveDirectory($sourcePath); - $base = trim(str_replace('\\', '/', $baseLocation), '/'); - - foreach (FlysystemHelper::listContents($sourcePath, true) as $item) { - $itemPath = trim((string) ($item['path'] ?? ''), '/'); - if ($itemPath === '') { - continue; - } - - $relative = $base !== '' && str_starts_with($itemPath, $base . '/') - ? substr($itemPath, strlen($base) + 1) - : ($itemPath === $base ? '' : $itemPath); - if ($relative === '') { - continue; - } - - $localTarget = PathHelper::join($localDirectory, $relative); - if (($item['type'] ?? null) === 'dir') { - if (!is_dir($localTarget)) { - @mkdir($localTarget, 0755, true); - } - continue; - } - - $localParent = dirname($localTarget); - if (!is_dir($localParent)) { - @mkdir($localParent, 0755, true); - } - - $resolvedPath = PathHelper::join($sourcePath, $relative); - $stream = FlysystemHelper::readStream($resolvedPath); - $target = fopen($localTarget, 'wb'); - if (!is_resource($stream) || !is_resource($target)) { - if (is_resource($stream)) { - fclose($stream); - } - if (is_resource($target)) { - fclose($target); - } - throw new CompressionException("Unable to read source path: {$resolvedPath}"); - } - - stream_copy_to_stream($stream, $target); - fclose($stream); - fclose($target); - } + $this->doMaterializeDirectoryToLocal($sourcePath, $localDirectory); } private function normalizeZipPath(string $path): string @@ -1042,6 +980,30 @@ private function openZip(int $flags = 0): void $this->isOpen = true; } + private function prepareExtractionDestination(string $destination): array + { + $isRemoteDestination = $this->isRemotePath($destination); + if ($isRemoteDestination) { + $extractDestination = $this->createExtractionTempDirectory(); + + return [ + 'extractDestination' => $extractDestination, + 'extractTempDir' => $extractDestination, + 'isRemote' => true, + ]; + } + + if (!FlysystemHelper::directoryExists($destination)) { + FlysystemHelper::createDirectory($destination); + } + + return [ + 'extractDestination' => $destination, + 'extractTempDir' => null, + 'isRemote' => false, + ]; + } + /** * Reopen the ZIP archive if it has been closed. @@ -1056,46 +1018,29 @@ private function reopenIfNeeded(): void } } - private function resolveWorkingZipPath(bool $create): string + private function resolveDecompressionDestination(?string $destination): string { - if (!$this->isRemotePath($this->zipFilePath)) { - return PathHelper::normalize($this->zipFilePath); - } - - $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_zip_'); - if ($tempFile === false) { - throw new CompressionException("Unable to allocate temporary ZIP path for {$this->zipFilePath}"); + $destination ??= $this->defaultDecompressionPath; + if (!$destination) { + throw new CompressionException("No destination path provided for decompression."); } - $this->cleanupWorkingZipPath = true; - $this->syncWorkingZipOnClose = true; - $normalizedTemp = PathHelper::normalize($tempFile); + return PathHelper::normalize($destination); + } - if (FlysystemHelper::fileExists($this->zipFilePath)) { - $source = FlysystemHelper::readStream($this->zipFilePath); - $target = fopen($normalizedTemp, 'wb'); - if (!is_resource($source) || !is_resource($target)) { - if (is_resource($source)) { - fclose($source); - } - if (is_resource($target)) { - fclose($target); - } - throw new CompressionException("Unable to read ZIP archive: {$this->zipFilePath}"); - } + private function resolveMaterializationBase(string $sourcePath): string + { + return $this->doResolveMaterializationBase($sourcePath); + } - stream_copy_to_stream($source, $target); - fclose($source); - fclose($target); - } elseif (!$create) { - @unlink($normalizedTemp); - throw new CompressionException("ZIP archive does not exist: {$this->zipFilePath}"); - } else { - // Avoid opening an empty placeholder file as a ZIP archive (deprecated behavior in PHP 8.4+). - @unlink($normalizedTemp); - } + private function resolveMaterializedRelativePath(array $item, string $base): ?string + { + return $this->doResolveMaterializedRelativePath($item, $base); + } - return $normalizedTemp; + private function resolveWorkingZipPath(bool $create): string + { + return $this->doResolveWorkingZipPath($create); } private function shouldAttemptNativeCompression(): bool @@ -1114,69 +1059,17 @@ private function shouldAttemptNativeCompression(): bool private function shouldIncludePath(string $relativePath): bool { - $relativePath = str_replace('\\', '/', ltrim($relativePath, '/')); - if ($relativePath === '') { - return true; - } - - if ($this->includePatterns !== []) { - $matchesInclude = false; - foreach ($this->includePatterns as $pattern) { - if (fnmatch($pattern, $relativePath)) { - $matchesInclude = true; - break; - } - } - if (!$matchesInclude) { - return false; - } - } - - foreach (array_merge($this->excludePatterns, $this->ignorePatterns) as $pattern) { - if (fnmatch($pattern, $relativePath)) { - return false; - } - } - - return true; + return $this->doShouldIncludePath($relativePath); } private function shouldTraverseDirectory(string $relativePath): bool { - $normalized = str_replace('\\', '/', trim($relativePath, '/')); - if ($normalized === '') { - return true; - } - - foreach (array_merge($this->excludePatterns, $this->ignorePatterns) as $pattern) { - $pattern = trim($pattern); - if ($pattern === '') { - continue; - } - if (fnmatch(rtrim($pattern, '/'), $normalized) || fnmatch(rtrim($pattern, '/') . '/*', $normalized . '/x')) { - return false; - } - } - - return true; + return $this->doShouldTraverseDirectory($relativePath); } private function syncWorkingZipIfNeeded(): void { - if (!$this->syncWorkingZipOnClose || !is_file($this->workingZipPath)) { - return; - } - - $stream = fopen($this->workingZipPath, 'rb'); - if (!is_resource($stream)) { - throw new CompressionException("Unable to stream ZIP archive: {$this->workingZipPath}"); - } - - try { - FlysystemHelper::writeStream($this->zipFilePath, $stream); - } finally { - fclose($stream); - } + $this->doSyncWorkingZipIfNeeded(); } diff --git a/src/FileManager/SafeFileWriter.php b/src/FileManager/SafeFileWriter.php index a7b8b6d..e0a7c19 100644 --- a/src/FileManager/SafeFileWriter.php +++ b/src/FileManager/SafeFileWriter.php @@ -359,6 +359,22 @@ public function writeAndVerify(string $content, string $algorithm = 'sha256'): b return hash_equals(hash($algorithm, $content), $fileHash); } + private function createAtomicTempFilePath(): string + { + if ($this->isRemoteTarget()) { + return $this->createLocalTempFile('pathwise_writer_atomic_'); + } + + $directory = dirname($this->filename); + $prefix = basename($this->filename) . '.tmp_'; + $tempFile = tempnam($directory, $prefix); + if ($tempFile === false) { + throw new FileAccessException("Unable to create temporary file for atomic write: {$this->filename}"); + } + + return $tempFile; + } + private function createLocalTempFile(string $prefix): string { $tempFile = tempnam(sys_get_temp_dir(), $prefix); @@ -417,6 +433,14 @@ private function getActiveOrFinalPath(): string return $this->filename; } + private function initializeRemoteWorkingPath(): void + { + $this->localWorkingPath = $this->createLocalTempFile('pathwise_writer_'); + $this->cleanupLocalWorkingPath = true; + $this->syncBackOnClose = true; + $this->preloadRemoteAppendSourceIfNeeded(); + } + /** * Initializes the internal state of the SafeFileWriter. * @@ -442,58 +466,53 @@ private function isRemoteTarget(): bool return PathHelper::hasScheme($this->filename) || (FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($this->filename)); } - private function resolveTargetFilePath(): string + private function preloadRemoteAppendSourceIfNeeded(): void { - if (!$this->atomicWriteEnabled) { - if ($this->isRemoteTarget()) { - if ($this->localWorkingPath !== null) { - return $this->localWorkingPath; - } - - $this->localWorkingPath = $this->createLocalTempFile('pathwise_writer_'); - $this->cleanupLocalWorkingPath = true; - $this->syncBackOnClose = true; - - if ($this->append && FlysystemHelper::fileExists($this->filename)) { - $source = FlysystemHelper::readStream($this->filename); - $target = fopen($this->localWorkingPath, 'wb'); - if (!is_resource($source) || !is_resource($target)) { - if (is_resource($source)) { - fclose($source); - } - if (is_resource($target)) { - fclose($target); - } - throw new FileAccessException("Cannot write to file: {$this->filename}"); - } - - stream_copy_to_stream($source, $target); - fclose($source); - fclose($target); - } + if (!$this->append || !FlysystemHelper::fileExists($this->filename) || !is_string($this->localWorkingPath)) { + return; + } - return $this->localWorkingPath; + $source = FlysystemHelper::readStream($this->filename); + $target = fopen($this->localWorkingPath, 'wb'); + if (!is_resource($source) || !is_resource($target)) { + if (is_resource($source)) { + fclose($source); } + if (is_resource($target)) { + fclose($target); + } + throw new FileAccessException("Cannot write to file: {$this->filename}"); + } + + stream_copy_to_stream($source, $target); + fclose($source); + fclose($target); + } + private function resolveNonAtomicTargetFilePath(): string + { + if (!$this->isRemoteTarget()) { return $this->filename; } - if ($this->atomicTempFilePath !== null) { - return $this->atomicTempFilePath; + if ($this->localWorkingPath === null) { + $this->initializeRemoteWorkingPath(); } - if ($this->isRemoteTarget()) { - $tempFile = $this->createLocalTempFile('pathwise_writer_atomic_'); - } else { - $directory = dirname($this->filename); - $prefix = basename($this->filename) . '.tmp_'; - $tempFile = tempnam($directory, $prefix); - if ($tempFile === false) { - throw new FileAccessException("Unable to create temporary file for atomic write: {$this->filename}"); - } + return (string) $this->localWorkingPath; + } + + private function resolveTargetFilePath(): string + { + if (!$this->atomicWriteEnabled) { + return $this->resolveNonAtomicTargetFilePath(); + } + + if ($this->atomicTempFilePath !== null) { + return $this->atomicTempFilePath; } - $this->atomicTempFilePath = PathHelper::normalize($tempFile); + $this->atomicTempFilePath = PathHelper::normalize($this->createAtomicTempFilePath()); return $this->atomicTempFilePath; } diff --git a/src/Native/NativeOperationsAdapter.php b/src/Native/NativeOperationsAdapter.php index 570ddb8..74fa294 100644 --- a/src/Native/NativeOperationsAdapter.php +++ b/src/Native/NativeOperationsAdapter.php @@ -56,12 +56,25 @@ public static function compressToZip(string $source, string $zipPath): array } if (NativeCommandRunner::commandExists('zip')) { - $command = sprintf( - 'zip -r %s %s', - escapeshellarg($zipPath), - escapeshellarg(basename($source)), - ); + $zipArg = escapeshellarg($zipPath); $cwd = dirname($source); + + if (is_dir($source)) { + $cwd = $source; + $command = sprintf('zip -r %s .', $zipArg); + + $zipParent = PathHelper::normalize(dirname($zipPath)); + if ($zipParent === PathHelper::normalize($source)) { + $command .= ' -x ' . escapeshellarg(basename($zipPath)); + } + } else { + $command = sprintf( + 'zip -r %s %s', + $zipArg, + escapeshellarg(basename($source)), + ); + } + $wrapped = sprintf('cd %s && %s', escapeshellarg($cwd), $command); $result = NativeCommandRunner::run($wrapped); diff --git a/src/Utils/PathHelper.php b/src/Utils/PathHelper.php index edaa592..f972250 100644 --- a/src/Utils/PathHelper.php +++ b/src/Utils/PathHelper.php @@ -273,48 +273,7 @@ public static function normalize(string $path): string return self::$cache[$originalPath]; } - if ($path === '') { - return self::$cache[$originalPath] = ''; - } - - if (self::hasScheme($path)) { - return self::$cache[$originalPath] = self::normalizeSchemePath($path); - } - - $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); - - $prefix = ''; - if (preg_match('/^[a-zA-Z]:[\\\\\\/]/', $path) === 1) { - $prefix = strtoupper(substr($path, 0, 2)) . DIRECTORY_SEPARATOR; - $path = substr($path, 3); - } elseif (str_starts_with($path, DIRECTORY_SEPARATOR)) { - $prefix = DIRECTORY_SEPARATOR; - $path = ltrim($path, DIRECTORY_SEPARATOR); - } - - $parts = preg_split('/[\\\\\\/]+/', $path, -1, PREG_SPLIT_NO_EMPTY); - $stack = []; - foreach ($parts as $part) { - if ($part === '' || $part === '.') { - continue; - } - if ($part === '..') { - if ($stack !== [] && end($stack) !== '..') { - array_pop($stack); - } elseif ($prefix === '') { - $stack[] = '..'; - } - } else { - $stack[] = $part; - } - } - - $normalized = $prefix . implode(DIRECTORY_SEPARATOR, $stack); - if ($normalized === '' && $prefix !== '') { - $normalized = $prefix; - } - - return self::$cache[$originalPath] = $normalized; + return self::$cache[$originalPath] = self::normalizeUncached($path); } /** @@ -394,6 +353,61 @@ public static function toAbsolutePath(string $path, ?string $base = null): strin return self::normalize(self::join($base, $path)); } + private static function buildNormalizedPath(string $prefix, array $stack): string + { + $normalized = $prefix . implode(DIRECTORY_SEPARATOR, $stack); + if ($normalized === '' && $prefix !== '') { + return $prefix; + } + + return $normalized; + } + + /** + * @return array{string, string} + */ + private static function extractPathPrefix(string $path): array + { + if (preg_match('/^[a-zA-Z]:[\\\\\\/]/', $path) === 1) { + return [strtoupper(substr($path, 0, 2)) . DIRECTORY_SEPARATOR, substr($path, 3)]; + } + + if (str_starts_with($path, DIRECTORY_SEPARATOR)) { + return [DIRECTORY_SEPARATOR, ltrim($path, DIRECTORY_SEPARATOR)]; + } + + return ['', $path]; + } + + /** + * @return array + */ + private static function normalizePathParts(string $path, bool $hasAbsolutePrefix): array + { + $parts = preg_split('/[\\\\\\/]+/', $path, -1, PREG_SPLIT_NO_EMPTY); + $stack = []; + + foreach ($parts as $part) { + if ($part === '' || $part === '.') { + continue; + } + + if ($part === '..') { + if ($stack !== [] && end($stack) !== '..') { + array_pop($stack); + } elseif (!$hasAbsolutePrefix) { + $stack[] = '..'; + } + + continue; + } + + $stack[] = $part; + } + + return $stack; + } + private static function normalizeSchemePath(string $path): string { if (preg_match('/^([a-zA-Z0-9._-]+):\/\/(.*)$/', $path, $matches) !== 1) { @@ -430,4 +444,21 @@ private static function normalizeSchemePath(string $path): string return $scheme . '://' . $normalized; } + + private static function normalizeUncached(string $path): string + { + if ($path === '') { + return ''; + } + + if (self::hasScheme($path)) { + return self::normalizeSchemePath($path); + } + + $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + [$prefix, $relativePath] = self::extractPathPrefix($path); + $stack = self::normalizePathParts($relativePath, $prefix !== ''); + + return self::buildNormalizedPath($prefix, $stack); + } }