diff --git a/.gitignore b/.gitignore index 961285c..6991d72 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .phpunit.cache .vscode .windsurf +.codex *~ *.patch *.txt @@ -16,3 +17,4 @@ patch.php test.php var vendor +d2utmp* diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 8111fe2..a807878 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -15,4 +15,3 @@ sphinx: formats: - pdf - epub - diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..62d44a8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,126 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +infocyph@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, +available at: +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html + +Community Impact Guidelines were inspired by Mozilla's code of conduct +enforcement ladder: +https://github.com/mozilla/diversity + +For answers to common questions about this code of conduct, see: +https://www.contributor-covenant.org/faq diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d6d177d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +## Supported Versions + +Pathwise follows a rolling support model. + +| Version | Security Support | +| --- | --- | +| Latest stable release | Yes | +| `main` branch | Yes | +| Older releases | No | + +## Reporting a Vulnerability + +Please do not report security issues in public GitHub issues. + +Use one of the following private channels: + +1. GitHub private advisories: https://github.com/infocyph/Pathwise/security/advisories/new +2. Email: infocyph@gmail.com + +When reporting, include: + +- Affected Pathwise version +- Reproduction steps or proof of concept +- Expected impact and possible attack scenario +- Any mitigation you have already tested + +## Response Process + +- Initial acknowledgment target: within 3 business days +- Triage update target: within 7 business days +- Fix and disclosure timeline depends on severity and patch complexity + +We will coordinate responsible disclosure and credit reporters when appropriate. diff --git a/composer.json b/composer.json index 2de240e..a60af16 100644 --- a/composer.json +++ b/composer.json @@ -45,11 +45,11 @@ "require-dev": { "captainhook/captainhook": "^5.29.2", "laravel/pint": "^1.29", - "pestphp/pest": "^4.5", + "pestphp/pest": "^4.6.2", "pestphp/pest-plugin-drift": "^4.1", - "phpbench/phpbench": "^1.6", - "phpstan/phpstan": "^2.1", - "rector/rector": "^2.4.1", + "phpbench/phpbench": "^1.6.1", + "phpstan/phpstan": "^2.1.50", + "rector/rector": "^2.4.2", "squizlabs/php_codesniffer": "^4.0.1", "symfony/var-dumper": "^7.3 || ^8.0.8", "tomasvotruba/cognitive-complexity": "^1.1", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 666c061..1cf0c6c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -18,25 +18,39 @@ */.git/* */.idea/* + + + + + + + + + + + + + + - - - - + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0adc1df..650fbdf 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,12 +3,14 @@ includes: parameters: customRulesetUsed: true + level: max paths: - src parallel: - maximumNumberOfProcesses: 1 + maximumNumberOfProcesses: 2 cognitive_complexity: - class: 150 - function: 14 - dependency_tree: 150 + class: 80 + function: 12 + dependency_tree: 80 dependency_tree_types: [] + reportUnmatchedIgnoredErrors: true diff --git a/pint.json b/pint.json index 46529c3..6f546dc 100644 --- a/pint.json +++ b/pint.json @@ -8,66 +8,122 @@ ], "rules": { "ordered_imports": { - "imports_order": ["class", "function", "const"], + "imports_order": [ + "class", + "function", + "const" + ], "sort_algorithm": "alpha" }, "no_unused_imports": true, - + "class_attributes_separation": { + "elements": { + "trait_import": "none", + "case": "one", + "const": "one", + "property": "one", + "method": "one" + } + }, "ordered_class_elements": { "order": [ "use_trait", - "case", - "constant_public", "constant_protected", "constant_private", "constant", - "property_public_static", "property_protected_static", "property_private_static", "property_static", - "property_public_readonly", "property_protected_readonly", "property_private_readonly", - "property_public_abstract", "property_protected_abstract", - "property_public", "property_protected", "property_private", "property", - "construct", "destruct", "magic", "phpunit", - "method_public_abstract_static", "method_protected_abstract_static", "method_private_abstract_static", - "method_public_abstract", "method_protected_abstract", "method_private_abstract", "method_abstract", - "method_public_static", "method_public", - "method_protected_static", "method_protected", - "method_private_static", "method_private", - "method_static", "method" ], "sort_algorithm": "alpha" - } + }, + "blank_line_after_opening_tag": true, + "no_alias_functions": true, + "multiline_whitespace_before_semicolons": true, + "no_trailing_whitespace": true, + "blank_line_before_statement": { + "statements": [ + "break", + "continue", + "declare", + "return", + "throw", + "try" + ] + }, + "phpdoc_align": { + "align": "left" + }, + "binary_operator_spaces": { + "default": "single_space" + }, + "concat_space": { + "spacing": "one" + }, + "cast_spaces": true, + "unary_operator_spaces": true, + "ternary_operator_spaces": true, + "array_indentation": true, + "trim_array_spaces": true, + "method_argument_space": { + "on_multiline": "ensure_fully_multiline" + }, + "trailing_comma_in_multiline": { + "elements": [ + "arrays", + "arguments", + "parameters", + "match" + ] + }, + "single_quote": true, + "single_line_empty_body": true, + "no_multiple_statements_per_line": true, + "no_extra_blank_lines": true, + "no_whitespace_in_blank_line": true, + "single_blank_line_at_eof": true, + "statement_indentation": true, + "control_structure_braces": true, + "control_structure_continuation_position": true, + "declare_parentheses": true, + "declare_strict_types": true, + "lowercase_keywords": true, + "constant_case": true, + "lowercase_static_reference": true, + "native_function_casing": true, + "nullable_type_declaration_for_default_null_value": true, + "no_superfluous_phpdoc_tags": true, + "phpdoc_trim": true } } diff --git a/psalm.xml b/psalm.xml index 49a4a35..0cbbcd3 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,6 +4,11 @@ xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" errorLevel="2" + findUnusedCode="true" + findUnusedPsalmSuppress="true" + runTaintAnalysis="true" + reportInfo="true" + checkForThrowsDocblock="true" > @@ -13,9 +18,7 @@ - - @@ -25,16 +28,12 @@ + - - - - - diff --git a/src/Core/ExecutionStrategy.php b/src/Core/ExecutionStrategy.php index 28903fb..0d69a7a 100644 --- a/src/Core/ExecutionStrategy.php +++ b/src/Core/ExecutionStrategy.php @@ -1,10 +1,14 @@ + * @phpstan-type DetailedContentItem array{ + * path: string, + * type: string, + * size: int, + * permissions: string|null, + * last_modified: int + * } + * @phpstan-type FindCriteria array{ + * name?: string, + * extension?: string, + * permissions?: int, + * minSize?: int, + * maxSize?: int + * } + */ +trait DirectoryOperationsEntryConcern +{ + /** + * @param StorageEntry $item + * @return DetailedContentItem + */ + private function buildDetailedContentItem(string $resolvedPath, array $item): array + { + $permissions = null; + if ($this->isLocalPath($resolvedPath) && file_exists($resolvedPath)) { + $permissionBits = fileperms($resolvedPath); + if (is_int($permissionBits)) { + $permissions = PermissionsHelper::formatPermissions($permissionBits); + } + } + + return [ + 'path' => $resolvedPath, + 'type' => $this->entryType($item), + 'size' => $this->entrySize($item), + 'permissions' => $permissions ?? $this->entryVisibility($item), + 'last_modified' => $this->entryLastModified($item), + ]; + } + + private function buildPath(string $basePath, string $relativePath): string + { + $relativePath = trim(str_replace('\\', '/', $relativePath), '/'); + if ($relativePath === '') { + return PathHelper::normalize($basePath); + } + + if (PathHelper::hasScheme($basePath)) { + return rtrim(str_replace('\\', '/', $basePath), '/') . '/' . $relativePath; + } + + return PathHelper::join($basePath, $relativePath); + } + + private function ensureDirectoryExists(string $path): string + { + $path = PathHelper::normalize($path); + if (!FlysystemHelper::directoryExists($path)) { + FlysystemHelper::createDirectory($path); + } + + return $path; + } + + /** + * @param StorageEntry $item + */ + private function entryLastModified(array $item): int + { + $lastModified = $item['last_modified'] ?? 0; + if (is_int($lastModified)) { + return $lastModified; + } + + return is_numeric($lastModified) ? (int) $lastModified : 0; + } + + /** + * @param StorageEntry $item + */ + private function entryPath(array $item): string + { + $path = $item['path'] ?? ''; + + return is_string($path) ? $path : ''; + } + + /** + * @param StorageEntry $item + */ + private function entrySize(array $item): int + { + $size = $item['file_size'] ?? 0; + if (is_int($size)) { + return $size; + } + + return is_numeric($size) ? (int) $size : 0; + } + + /** + * @param StorageEntry $item + */ + private function entryType(array $item): string + { + $type = $item['type'] ?? 'file'; + + return is_string($type) ? $type : 'file'; + } + + /** + * @param StorageEntry $item + */ + private function entryVisibility(array $item): string + { + $visibility = $item['visibility'] ?? ''; + + return is_string($visibility) ? $visibility : ''; + } + + /** + * @param StorageEntry $metadata + */ + private function invokeFilter(?callable $filter, string $path, array $metadata): bool + { + if ($filter === null) { + return true; + } + + try { + return (bool) $filter($path, $metadata); + } catch (\ArgumentCountError) { + return (bool) $filter($path); + } + } + + private function isLocalPath(string $path): bool + { + return !PathHelper::hasScheme($path) && PathHelper::isAbsolute($path); + } + + /** + * @return list + */ + private function listStorageEntries(string $path, bool $deep): array + { + $entries = []; + foreach (FlysystemHelper::listContents($path, $deep) as $item) { + $entries[] = $item; + } + + return $entries; + } + + /** + * @param FindCriteria $criteria + */ + private function matchesFindCriteria(array $criteria, string $resolvedPath, int $size, bool $isWindows): bool + { + return (empty($criteria['name']) || str_contains(basename($resolvedPath), $criteria['name'])) + && (empty($criteria['extension']) || pathinfo($resolvedPath, PATHINFO_EXTENSION) === $criteria['extension']) + && $this->matchesPermissionsCriteria($criteria, $resolvedPath, $isWindows) + && (empty($criteria['minSize']) || $size >= $criteria['minSize']) + && (empty($criteria['maxSize']) || $size <= $criteria['maxSize']); + } + + /** + * @param FindCriteria $criteria + */ + private function matchesPermissionsCriteria(array $criteria, string $resolvedPath, bool $isWindows): bool + { + if (empty($criteria['permissions']) || $isWindows) { + return true; + } + + if (!$this->isLocalPath($resolvedPath) || !file_exists($resolvedPath)) { + return false; + } + + $permissions = fileperms($resolvedPath); + + return is_int($permissions) && ($permissions & 0777) === $criteria['permissions']; + } + + private function relativeStoragePath(string $baseLocation, string $itemPath): string + { + $normalizedBase = trim(str_replace('\\', '/', $baseLocation), '/'); + $normalizedPath = trim(str_replace('\\', '/', $itemPath), '/'); + + if ($normalizedBase === '') { + return $normalizedPath; + } + + if ($normalizedPath === $normalizedBase) { + return ''; + } + + if (str_starts_with($normalizedPath, $normalizedBase . '/')) { + return substr($normalizedPath, strlen($normalizedBase) + 1); + } + + return $normalizedPath; + } + + private function storageLocation(string $directoryPath): string + { + [, $location] = FlysystemHelper::resolveDirectory($directoryPath); + + return trim(str_replace('\\', '/', $location), '/'); + } +} diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php new file mode 100644 index 0000000..66d9d30 --- /dev/null +++ b/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php @@ -0,0 +1,249 @@ + + * @phpstan-type SyncReport array{ + * created: list, + * updated: list, + * deleted: list, + * unchanged: list + * } + */ +trait DirectoryOperationsSyncConcern +{ + private function applyPermissionsSilently(string $path, int $permissions): void + { + $this->runSilently(static fn(): bool => chmod($path, $permissions)); + } + + 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 attemptNativeCopy(string $destination, ?callable $progress): bool + { + if (!$this->canAttemptNativeCopy($destination)) { + return false; + } + + $this->emitCopyProgress($progress, 0); + $native = NativeOperationsAdapter::copyDirectory($this->path, $destination, false); + if ($native['success']) { + $this->emitCopyProgress($progress, 1); + + return true; + } + + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + throw new DirectoryOperationException("Native directory copy failed for '{$this->path}' to '{$destination}'."); + } + + return false; + } + + private function canAttemptNativeCopy(string $destination): bool + { + return $this->executionStrategy !== ExecutionStrategy::PHP + && NativeOperationsAdapter::canUseNativeDirectoryCopy() + && $this->isLocalPath($this->path) + && $this->isLocalPath($destination); + } + + private function cleanupTemporaryFile(bool $shouldCleanup, string $path): void + { + if ($shouldCleanup && is_file($path)) { + $this->unlinkFileSilently($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 createDirectorySilently(string $path): void + { + if (is_dir($path)) { + return; + } + + $this->runSilently(static fn(): bool => mkdir($path, 0755, true)); + } + + /** + * @param array $sourceEntries + * @param array> $report + */ + private function deleteSyncOrphans(string $destination, array $sourceEntries, array &$report): void + { + $destinationLocation = $this->storageLocation($destination); + $destinationItems = $this->listStorageEntries($destination, true); + + usort( + $destinationItems, + fn(array $a, array $b): int => strlen($this->entryPath($b)) <=> strlen($this->entryPath($a)), + ); + + foreach ($destinationItems as $item) { + $relative = $this->relativeStoragePath($destinationLocation, $this->entryPath($item)); + if ($relative === '' || isset($sourceEntries[$relative])) { + continue; + } + + $targetPath = $this->buildPath($destination, $relative); + if ($this->entryType($item) === 'dir') { + FlysystemHelper::deleteDirectory($targetPath); + $report['deleted'][] = $relative . '/'; + + continue; + } + + FlysystemHelper::delete($targetPath); + $report['deleted'][] = $relative; + } + } + + private function emitCopyProgress(?callable $progress, int $current): void + { + if (!is_callable($progress)) { + return; + } + + $progress([ + 'operation' => 'copy', + 'path' => $this->path, + 'current' => $current, + 'total' => 1, + ]); + } + + 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), + ]); + } + + /** + * @param array> $report + * @return SyncReport + */ + private function finalizeSyncReport(array $report): array + { + return [ + 'created' => $report['created'] ?? [], + 'updated' => $report['updated'] ?? [], + 'deleted' => $report['deleted'] ?? [], + 'unchanged' => $report['unchanged'] ?? [], + ]; + } + + /** + * @return SyncReport + */ + private function newSyncReport(): array + { + return [ + 'created' => [], + 'updated' => [], + 'deleted' => [], + 'unchanged' => [], + ]; + } + + private function parentDirectoryExists(string $path): bool + { + $parent = dirname($path); + if ($parent === '' || $parent === '.' || $parent === $path) { + return true; + } + + return is_dir($parent) || FlysystemHelper::directoryExists($parent); + } + + private function runSilently(callable $operation): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + + /** + * @param StorageEntry $item + * @param array $sourceEntries + * @param array> $report + */ + private function syncOneItem(string $destination, string $relative, array $item, array &$sourceEntries, array &$report): void + { + $type = $this->entryType($item); + $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 unlinkFileSilently(string $path): void + { + if (!is_file($path)) { + return; + } + + $this->runSilently(static fn(): bool => unlink($path)); + } +} diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php new file mode 100644 index 0000000..a38a323 --- /dev/null +++ b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php @@ -0,0 +1,331 @@ +getRealPath(); + if (!is_string($realPath) || $realPath === '') { + continue; + } + + if ($file->isDir()) { + rmdir($realPath); + + continue; + } + + unlink($realPath); + } + + 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 ($this->listStorageEntries($this->path, true) as $item) { + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); + if ($relative === '') { + continue; + } + + $zipPathName = str_replace('\\', '/', $relative); + if ($this->entryType($item) === '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); + $normalizedSourcePath = rtrim(PathHelper::normalize($this->path), '/'); + $directoryIterator = new RecursiveDirectoryIterator($this->path, FilesystemIterator::SKIP_DOTS); + $iterator = new RecursiveIteratorIterator($directoryIterator, RecursiveIteratorIterator::SELF_FIRST); + + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo) { + continue; + } + + $currentPath = PathHelper::normalize($file->getPathname()); + if ($currentPath === $normalizedZipPath) { + continue; + } + + $subPathName = ltrim(str_replace('\\', '/', substr($currentPath, strlen($normalizedSourcePath))), '/'); + if ($file->isDir()) { + $zip->addEmptyDir($subPathName); + + continue; + } + + $zip->addFile($file->getPathname(), $subPathName); + } + } + + 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 = $this->sanitizeZipEntryPath((string) $zip->getNameIndex($index)); + if ($entry === '') { + return; + } + + if (str_ends_with((string) $entry, '/')) { + FlysystemHelper::createDirectory($this->buildPath($this->path, rtrim((string) $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 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)) { + $this->unlinkFileSilently($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)) { + $this->unlinkFileSilently($zipPath); + } + + throw new DirectoryOperationException("Unable to stream ZIP archive at '{$zipPath}'."); + } + + try { + FlysystemHelper::writeStream($destination, $stream); + } finally { + fclose($stream); + if (is_file($zipPath)) { + $this->unlinkFileSilently($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); + } + $this->unlinkFileSilently($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.'); + } + + $this->unlinkFileSilently($tempZip); + + return $tempZip; + } + + $parent = dirname($destination); + if (!is_dir($parent)) { + $this->createDirectorySilently($parent); + } + + return $destination; + } + + private function sanitizeZipEntryPath(string $entry): string + { + $normalized = str_replace('\\', '/', $entry); + $trimmed = ltrim($normalized, '/'); + if ($trimmed === '') { + return ''; + } + + $safePath = preg_replace('#/+#', '/', $trimmed) ?? ''; + $safePath = preg_replace('#(^|/)\./#', '$1', $safePath) ?? $safePath; + $trimmedSafePath = rtrim($safePath, '/'); + + if ( + str_contains($trimmedSafePath, "\0") + || preg_match('#(^|/)\.\.(/|$)#', $trimmedSafePath) === 1 + || preg_match('/^[A-Za-z]:($|\/)/', $trimmedSafePath) === 1 + ) { + throw new DirectoryOperationException("Unsafe ZIP entry path detected: {$entry}"); + } + + if ($trimmedSafePath === '') { + return ''; + } + + return str_ends_with($normalized, '/') ? $trimmedSafePath . '/' : $trimmedSafePath; + } + + 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/DirectoryManager/DirectoryOperations.php b/src/DirectoryManager/DirectoryOperations.php index c30ce20..2252d26 100644 --- a/src/DirectoryManager/DirectoryOperations.php +++ b/src/DirectoryManager/DirectoryOperations.php @@ -1,28 +1,59 @@ + * @phpstan-type DetailedContentItem array{ + * path: string, + * type: string, + * size: int, + * permissions: string|null, + * last_modified: int + * } + * @phpstan-type FindCriteria array{ + * name?: string, + * extension?: string, + * permissions?: int, + * minSize?: int, + * maxSize?: int + * } + * @phpstan-type SyncReport array{ + * created: list, + * updated: list, + * deleted: list, + * unchanged: list + * } + */ class DirectoryOperations { + use DirectoryOperationsEntryConcern; + use DirectoryOperationsSyncConcern; + use DirectoryOperationsZipConcern; + private ExecutionStrategy $executionStrategy = ExecutionStrategy::AUTO; /** * Constructor to initialize the directory path. * - * @param string $path The path to the directory. + * @param string $path The path to the directory. * * @throws InvalidArgumentException If the path is not a valid directory. */ @@ -34,7 +65,7 @@ public function __construct(protected string $path) /** * Copies the contents of the directory to the specified destination. * - * @param string $destination The path to the destination directory. + * @param string $destination The path to the destination directory. * @return bool True if the copy operation was successful, false otherwise. */ public function copy(string $destination, ?callable $progress = null): bool @@ -64,8 +95,8 @@ public function copy(string $destination, ?callable $progress = null): bool /** * Creates the directory. * - * @param int $permissions The permissions to set for the newly created directory. - * @param bool $recursive Whether to create the directory recursively. + * @param int $permissions The permissions to set for the newly created directory. + * @param bool $recursive Whether to create the directory recursively. * @return bool True if the directory was successfully created, false otherwise. */ public function create(int $permissions = 0755, bool $recursive = true): bool @@ -74,9 +105,13 @@ public function create(int $permissions = 0755, bool $recursive = true): bool return true; } + if (!$recursive && !$this->parentDirectoryExists($this->path)) { + return false; + } + FlysystemHelper::createDirectory($this->path); if ($this->isLocalPath($this->path) && is_dir($this->path)) { - @chmod($this->path, $permissions); + $this->applyPermissionsSilently($this->path, $permissions); } return true; @@ -98,7 +133,7 @@ public function createTempDir(): string /** * Deletes the directory. * - * @param bool $recursive Whether to delete the contents of the directory first. + * @param bool $recursive Whether to delete the contents of the directory first. * @return bool True if the directory was successfully deleted, false otherwise. */ public function delete(bool $recursive = false): bool @@ -110,7 +145,7 @@ public function delete(bool $recursive = false): bool if ($recursive) { FlysystemHelper::deleteDirectory($this->path); - return !FlysystemHelper::directoryExists($this->path); + return true; } if (FlysystemHelper::listContents($this->path, false) !== []) { @@ -119,7 +154,7 @@ public function delete(bool $recursive = false): bool FlysystemHelper::deleteDirectory($this->path); - return !FlysystemHelper::directoryExists($this->path); + return true; } /** @@ -132,8 +167,8 @@ public function delete(bool $recursive = false): bool * - minSize: minimum size of the file * - maxSize: maximum size of the file * - * @param array $criteria The criteria to match against - * @return array A list of file paths that match the criteria + * @param FindCriteria $criteria The criteria to match against. + * @return list A list of file paths that match the criteria. */ public function find(array $criteria = []): array { @@ -141,18 +176,18 @@ public function find(array $criteria = []): array $sourceLocation = $this->storageLocation($this->path); $isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; - foreach (FlysystemHelper::listContents($this->path, true) as $item) { - if (($item['type'] ?? null) !== 'file') { + foreach ($this->listStorageEntries($this->path, true) as $item) { + if ($this->entryType($item) !== 'file') { continue; } - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); if ($relative === '') { continue; } $resolvedPath = $this->buildPath($this->path, $relative); - $size = (int) ($item['file_size'] ?? 0); + $size = $this->entrySize($item); if (!$this->matchesFindCriteria($criteria, $resolvedPath, $size, $isWindows)) { continue; @@ -167,21 +202,21 @@ public function find(array $criteria = []): array /** * Flatten the directory structure and return an array of file paths. * - * @param callable|null $filter Optional callback with signature: - * fn(string $path, array $metadata): bool - * @return array An array of file paths. + * @param callable|null $filter Optional callback with signature: + * fn(string $path, array $metadata): bool + * @return list An array of file paths. */ public function flatten(?callable $filter = null): array { $flattened = []; $sourceLocation = $this->storageLocation($this->path); - foreach (FlysystemHelper::listContents($this->path, true) as $item) { - if (($item['type'] ?? null) !== 'file') { + foreach ($this->listStorageEntries($this->path, true) as $item) { + if ($this->entryType($item) !== 'file') { continue; } - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); if ($relative === '') { continue; } @@ -207,8 +242,8 @@ public function getDepth(): int $maxDepth = 0; $sourceLocation = $this->storageLocation($this->path); - foreach (FlysystemHelper::listContents($this->path, true) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + foreach ($this->listStorageEntries($this->path, true) as $item) { + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); if ($relative === '') { continue; } @@ -223,7 +258,7 @@ public function getDepth(): int /** * Gets an iterator that traverses the local directory tree. * - * @return RecursiveIteratorIterator An iterator that traverses the directory tree. + * @return RecursiveIteratorIterator An iterator that traverses the directory tree. */ public function getIterator(): RecursiveIteratorIterator { @@ -258,18 +293,18 @@ public function getPermissions(): int /** * Returns an array of items in the directory. * - * @param bool $detailed Whether to return detailed information about each item. - * @param callable|null $filter Optional callback with signature: - * fn(string $path, array $metadata): bool - * @return array An array of items in the directory. + * @param bool $detailed Whether to return detailed information about each item. + * @param callable|null $filter Optional callback with signature: + * fn(string $path, array $metadata): bool + * @return array An array of items in the directory. */ public function listContents(bool $detailed = false, ?callable $filter = null): array { $contents = []; $sourceLocation = $this->storageLocation($this->path); - foreach (FlysystemHelper::listContents($this->path, true) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + foreach ($this->listStorageEntries($this->path, true) as $item) { + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); if ($relative === '') { continue; } @@ -279,25 +314,13 @@ public function listContents(bool $detailed = false, ?callable $filter = null): continue; } - if ($detailed) { - $permissions = null; - if ($this->isLocalPath($resolvedPath) && file_exists($resolvedPath)) { - $permissionBits = fileperms($resolvedPath); - if (is_int($permissionBits)) { - $permissions = PermissionsHelper::formatPermissions($permissionBits); - } - } - - $contents[] = [ - 'path' => $resolvedPath, - 'type' => (string) ($item['type'] ?? 'file'), - 'size' => (int) ($item['file_size'] ?? 0), - 'permissions' => $permissions ?? (string) ($item['visibility'] ?? ''), - 'last_modified' => (int) ($item['last_modified'] ?? 0), - ]; - } else { + if (!$detailed) { $contents[] = $resolvedPath; + + continue; } + + $contents[] = $this->buildDetailedContentItem($resolvedPath, $item); } return $contents; @@ -307,7 +330,7 @@ public function listContents(bool $detailed = false, ?callable $filter = null): * List directory contents as a DirectoryListing object. * * @param bool $deep Whether to list contents recursively. Defaults to true. - * @return DirectoryListing The directory listing. + * @return DirectoryListing The directory listing. */ public function listContentsListing(bool $deep = true): DirectoryListing { @@ -327,16 +350,16 @@ public function listPermissions(): string /** * Returns a sorted array of the directory's first-level contents. * - * @param string $sortOrder The sort order of the contents. Defaults to 'asc'. - * @return array An array of the directory's contents, sorted by the given order. + * @param string $sortOrder The sort order of the contents. Defaults to 'asc'. + * @return list An array of the directory's contents, sorted by the given order. */ public function listSortedContents(string $sortOrder = 'asc'): array { $sourceLocation = $this->storageLocation($this->path); $contents = []; - foreach (FlysystemHelper::listContents($this->path, false) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + foreach ($this->listStorageEntries($this->path, false) as $item) { + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); if ($relative === '') { continue; } @@ -355,7 +378,7 @@ public function listSortedContents(string $sortOrder = 'asc'): array /** * Moves the directory to the given destination. * - * @param string $destination The path to move the directory to. + * @param string $destination The path to move the directory to. * @return bool True if the directory was successfully moved, false otherwise. */ public function move(string $destination): bool @@ -385,7 +408,7 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self /** * Set the permissions of the directory to the given value. * - * @param int $permissions The new permissions for the directory. + * @param int $permissions The new permissions for the directory. * @return bool True if the permissions were successfully set, false otherwise. */ public function setPermissions(int $permissions): bool @@ -413,8 +436,8 @@ public function setVisibility(string $visibility): self /** * Calculates the total size of all files in the directory. * - * @param callable|null $filter Optional callback with signature: - * fn(string $path, array $metadata): bool + * @param callable|null $filter Optional callback with signature: + * fn(string $path, array $metadata): bool * @return int The total size of all files that pass the filter in bytes. */ public function size(?callable $filter = null): int @@ -422,12 +445,12 @@ public function size(?callable $filter = null): int $size = 0; $sourceLocation = $this->storageLocation($this->path); - foreach (FlysystemHelper::listContents($this->path, true) as $item) { - if (($item['type'] ?? null) !== 'file') { + foreach ($this->listStorageEntries($this->path, true) as $item) { + if ($this->entryType($item) !== 'file') { continue; } - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); if ($relative === '') { continue; } @@ -437,7 +460,7 @@ public function size(?callable $filter = null): int continue; } - $size += (int) ($item['file_size'] ?? 0); + $size += $this->entrySize($item); } return $size; @@ -446,7 +469,7 @@ public function size(?callable $filter = null): int /** * Mirror the source directory to destination and return a diff report. * - * @return array{created: array, updated: array, deleted: array, unchanged: array} + * @return SyncReport */ public function syncTo(string $destination, bool $deleteOrphans = true, ?callable $progress = null): array { @@ -456,12 +479,12 @@ public function syncTo(string $destination, bool $deleteOrphans = true, ?callabl $sourceEntries = []; $sourceLocation = $this->storageLocation($this->path); - $sourceItems = FlysystemHelper::listContents($this->path, true); + $sourceItems = $this->listStorageEntries($this->path, true); $total = count($sourceItems); $current = 0; foreach ($sourceItems as $item) { - $relative = $this->relativeStoragePath($sourceLocation, (string) ($item['path'] ?? '')); + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); if ($relative === '') { continue; } @@ -475,13 +498,13 @@ public function syncTo(string $destination, bool $deleteOrphans = true, ?callabl $this->deleteSyncOrphans($destination, $sourceEntries, $report); } - return $report; + return $this->finalizeSyncReport($report); } /** * Extracts the contents of a zip file to the directory represented by this object. * - * @param string $source The path to the zip file. + * @param string $source The path to the zip file. * @return bool True if the extraction was successful, false otherwise. */ public function unzip(string $source): bool @@ -522,7 +545,7 @@ public function visibility(): ?string /** * Zip the contents of the directory to a file. * - * @param string $destination The path to the zip file. + * @param string $destination The path to the zip file. * @return bool True if the zip was created successfully, false otherwise. */ public function zip(string $destination): bool @@ -537,6 +560,7 @@ public function zip(string $destination): bool $zipPath = $this->prepareZipPath($destination, $useLocalDestination); $zip = $this->openZipArchive($zipPath, $destination, $useLocalDestination); + try { $this->addContentsToZip($zip, $zipPath); } finally { @@ -549,546 +573,4 @@ public function zip(string $destination): bool return true; } - - /** - * Deletes all files and directories in the given local directory. - * - * @param string $directory The directory to delete contents of. - * @return bool True if the directory contents were successfully deleted, false otherwise. - */ - protected function deleteDirectoryContents(string $directory): bool - { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $file) { - if ($file->isDir()) { - rmdir($file->getRealPath()); - - continue; - } - - unlink($file->getRealPath()); - } - - 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 attemptNativeCopy(string $destination, ?callable $progress): bool - { - if (!$this->canAttemptNativeCopy($destination)) { - return false; - } - - $this->emitCopyProgress($progress, 0); - $native = NativeOperationsAdapter::copyDirectory($this->path, $destination, false); - if ($native['success']) { - $this->emitCopyProgress($progress, 1); - - return true; - } - - if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new DirectoryOperationException("Native directory copy failed for '{$this->path}' to '{$destination}'."); - } - - return false; - } - - private function buildPath(string $basePath, string $relativePath): string - { - $relativePath = trim(str_replace('\\', '/', $relativePath), '/'); - if ($relativePath === '') { - return PathHelper::normalize($basePath); - } - - if (PathHelper::hasScheme($basePath)) { - return rtrim(str_replace('\\', '/', $basePath), '/') . '/' . $relativePath; - } - - return PathHelper::join($basePath, $relativePath); - } - - private function canAttemptNativeCopy(string $destination): bool - { - return $this->executionStrategy !== ExecutionStrategy::PHP - && NativeOperationsAdapter::canUseNativeDirectoryCopy() - && $this->isLocalPath($this->path) - && $this->isLocalPath($destination); - } - - 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 emitCopyProgress(?callable $progress, int $current): void - { - if (!is_callable($progress)) { - return; - } - - $progress([ - 'operation' => 'copy', - 'path' => $this->path, - 'current' => $current, - 'total' => 1, - ]); - } - - 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 = $this->sanitizeZipEntryPath((string) $zip->getNameIndex($index)); - 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) { - return true; - } - - try { - return (bool) $filter($path, $metadata); - } catch (\ArgumentCountError) { - return (bool) $filter($path); - } - } - - private function isLocalPath(string $path): bool - { - return !PathHelper::hasScheme($path) && PathHelper::isAbsolute($path); - } - - private function matchesFindCriteria(array $criteria, string $resolvedPath, int $size, bool $isWindows): bool - { - return (empty($criteria['name']) || str_contains(basename($resolvedPath), (string) $criteria['name'])) - && (empty($criteria['extension']) || pathinfo($resolvedPath, PATHINFO_EXTENSION) === (string) $criteria['extension']) - && $this->matchesPermissionsCriteria($criteria, $resolvedPath, $isWindows) - && (empty($criteria['minSize']) || $size >= (int) $criteria['minSize']) - && (empty($criteria['maxSize']) || $size <= (int) $criteria['maxSize']); - } - - private function matchesPermissionsCriteria(array $criteria, string $resolvedPath, bool $isWindows): bool - { - if (empty($criteria['permissions']) || $isWindows) { - return true; - } - - if (!$this->isLocalPath($resolvedPath) || !file_exists($resolvedPath)) { - return false; - } - - $permissions = fileperms($resolvedPath); - - return is_int($permissions) && ($permissions & 0777) === (int) $criteria['permissions']; - } - - /** - * @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), '/'); - $normalizedPath = trim(str_replace('\\', '/', $itemPath), '/'); - - if ($normalizedBase === '') { - return $normalizedPath; - } - - if ($normalizedPath === $normalizedBase) { - return ''; - } - - if (str_starts_with($normalizedPath, $normalizedBase . '/')) { - return substr($normalizedPath, strlen($normalizedBase) + 1); - } - - return $normalizedPath; - } - - private function sanitizeZipEntryPath(string $entry): string - { - $normalized = str_replace('\\', '/', $entry); - $trimmed = ltrim($normalized, '/'); - if ($trimmed === '') { - return ''; - } - - $safePath = preg_replace('#/+#', '/', $trimmed) ?? ''; - $safePath = preg_replace('#(^|/)\./#', '$1', $safePath) ?? $safePath; - $trimmedSafePath = rtrim($safePath, '/'); - - if ( - str_contains($trimmedSafePath, "\0") - || preg_match('#(^|/)\.\.(/|$)#', $trimmedSafePath) === 1 - || preg_match('/^[A-Za-z]:($|\/)/', $trimmedSafePath) === 1 - ) { - throw new DirectoryOperationException("Unsafe ZIP entry path detected: {$entry}"); - } - - if ($trimmedSafePath === '') { - return ''; - } - - return str_ends_with($normalized, '/') ? $trimmedSafePath . '/' : $trimmedSafePath; - } - - private function storageLocation(string $directoryPath): string - { - [, $location] = FlysystemHelper::resolveDirectory($directoryPath); - - 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/Exceptions/CompressionException.php b/src/Exceptions/CompressionException.php index 216cf8c..18fdf52 100644 --- a/src/Exceptions/CompressionException.php +++ b/src/Exceptions/CompressionException.php @@ -1,5 +1,7 @@ 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. + * + * This method traverses the specified directory and adds files to the + * ZIP archive. Directories are added as empty directories. If the + * password is set, files are added with encryption. + * + * @param string $path The path to add files from. + * @param ZipArchive $zip The ZIP archive to add files to. + * @param string|null $baseDir The base directory to use for relative paths. + */ + private function addFilesToZip(string $path, ZipArchive $zip, ?string $baseDir = null): void + { + $baseDir ??= $path; + + if (is_dir($path)) { + $this->addDirectoryEntriesToZip($path, $zip, $baseDir); + + return; + } + + $this->addSinglePathToZip($path, $zip, $baseDir); + } + + /** + * Recursively add files to the ZIP archive, filtering by extensions. + * + * This method traverses the specified directory and adds files to the + * ZIP archive based on the provided file extensions. Directories are + * added as empty directories if no matching files are found within them. + * If a password is set, files are encrypted using the specified algorithm. + * + * @param string $path The path to the directory or file to add. + * @param ZipArchive $zip The ZIP archive instance to add files to. + * @param string|null $relativePath The relative path within the ZIP archive. + * @param list $extensions An array of file extensions to filter by. + */ + private function addFilesToZipWithFilter(string $path, ZipArchive $zip, ?string $relativePath, array $extensions): void + { + $relativePath ??= basename($path); + $relativePath = $this->normalizeZipPath($relativePath); + + if (is_dir($path)) { + if ($relativePath !== '' && !$this->shouldTraverseDirectory($relativePath)) { + return; + } + $zip->addEmptyDir($relativePath); + $entries = scandir($path); + if ($entries === false) { + throw new CompressionException("Failed to read directory: {$path}"); + } + + foreach ($entries as $file) { + if ($file !== '.' && $file !== '..') { + $this->addFilesToZipWithFilter($path . DIRECTORY_SEPARATOR . $file, $zip, "$relativePath/$file", $extensions); + } + } + } elseif ((empty($extensions) || in_array(pathinfo($path, PATHINFO_EXTENSION), $extensions)) && $this->shouldIncludePath($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)) { + return; + } + + $this->progressCurrent++; + ($this->progressCallback)([ + 'operation' => $operation, + 'path' => $path, + 'current' => $this->progressCurrent, + 'total' => $this->progressTotal, + ]); + } + + 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 copyLocalDirectoryToFlysystem(string $localSource, string $destination): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($localSource, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + if (!$item instanceof \SplFileInfo) { + continue; + } + + $relative = $this->getRelativePath($item->getPathname(), $localSource); + if ($relative === '') { + continue; + } + + $targetPath = PathHelper::join($destination, $relative); + + if ($item->isDir()) { + FlysystemHelper::createDirectory($targetPath); + + continue; + } + + $stream = fopen($item->getPathname(), 'rb'); + if (!is_resource($stream)) { + throw new CompressionException("Unable to read extracted file: {$item->getPathname()}"); + } + + try { + FlysystemHelper::writeStream($targetPath, $stream); + } finally { + fclose($stream); + } + } + } + + /** + * @param list $extensions + */ + private function countFilesForCompression(string $source, array $extensions = []): int + { + if (is_file($source)) { + $relative = basename($source); + if (!$this->matchesExtensions($source, $extensions)) { + return 0; + } + + return $this->shouldIncludePath($relative) ? 1 : 0; + } + + if (!is_dir($source)) { + return 0; + } + + $count = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + if (!$item instanceof \SplFileInfo) { + continue; + } + + if ($item->isDir()) { + continue; + } + + $relative = $this->getRelativePath($item->getPathname(), $source); + if (!$this->matchesExtensions($item->getPathname(), $extensions)) { + continue; + } + if ($this->shouldIncludePath($relative)) { + $count++; + } + } + + return $count; + } + + private function createExtractionTempDirectory(): string + { + $extractTempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_extract_', true); + if (!$this->runSilently(static fn(): bool => mkdir($extractTempDir, 0755, true)) && !is_dir($extractTempDir)) { + throw new CompressionException("Unable to create extraction directory: {$extractTempDir}"); + } + + return PathHelper::normalize($extractTempDir); + } + + 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 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. + */ + private function getRelativePath(string $path, string $baseDir): string + { + $normalizedPath = str_replace('\\', '/', PathHelper::normalize($path)); + $normalizedBase = rtrim(str_replace('\\', '/', PathHelper::normalize($baseDir)), '/'); + + if ($normalizedPath === $normalizedBase) { + return ''; + } + + if (str_starts_with($normalizedPath, $normalizedBase . '/')) { + return substr($normalizedPath, strlen($normalizedBase) + 1); + } + + return ltrim($normalizedPath, '/'); + } + + /** + * @param list $extensions + */ + private function initializeProgress(string $source, array $extensions = []): void + { + $this->progressCurrent = 0; + $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)); + } + + /** + * @param list $extensions + */ + private function matchesExtensions(string $path, array $extensions): bool + { + if ($extensions === []) { + return true; + } + + return in_array(pathinfo($path, PATHINFO_EXTENSION), $extensions, true); + } + + private function normalizeZipPath(string $path): string + { + $hadTrailingSlash = str_ends_with(str_replace('\\', '/', $path), '/'); + $normalized = ltrim(str_replace('\\', '/', PathHelper::normalize($path)), '/'); + + if ($hadTrailingSlash && $normalized !== '' && !str_ends_with($normalized, '/')) { + $normalized .= '/'; + } + + return $normalized; + } + + /** + * @return ExtractionDestination + */ + 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, + ]; + } + + private function resolveDecompressionDestination(?string $destination): string + { + $destination ??= $this->defaultDecompressionPath; + if (!$destination) { + throw new CompressionException('No destination path provided for decompression.'); + } + + return PathHelper::normalize($destination); + } + + private function shouldAttemptNativeCompression(): bool + { + if ($this->executionStrategy === ExecutionStrategy::PHP) { + return false; + } + + // Native path currently targets whole-source archive operations only. + return $this->password === null + && $this->includePatterns === [] + && $this->excludePatterns === [] + && $this->ignorePatterns === [] + && $this->hooks === []; + } +} diff --git a/src/FileManager/Concerns/FileCompressionRuntimeConcern.php b/src/FileManager/Concerns/FileCompressionRuntimeConcern.php new file mode 100644 index 0000000..0a98070 --- /dev/null +++ b/src/FileManager/Concerns/FileCompressionRuntimeConcern.php @@ -0,0 +1,233 @@ +localizedCleanupPaths === []) { + return; + } + + foreach (array_keys($this->localizedCleanupPaths) as $path) { + $this->cleanupLocalizedPath($path); + } + + $this->localizedCleanupPaths = []; + } + + private function cleanupLocalizedPath(?string $path): void + { + if (!is_string($path) || $path === '' || !file_exists($path)) { + return; + } + + if (is_file($path)) { + $this->unlinkPathSilently($path); + + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + if (!$item instanceof \SplFileInfo) { + continue; + } + + if ($item->isDir()) { + $this->removeDirectorySilently($item->getPathname()); + } else { + $this->unlinkPathSilently($item->getPathname()); + } + } + + $this->removeDirectorySilently($path); + } + + /** + * Closes the current ZIP archive. + * + * If the archive is currently open, this method will close it using the + * ZipArchive::close() method. The $isOpen flag is then set to false to + * indicate that the archive is no longer open. + */ + private function closeZip(): void + { + if ($this->isOpen) { + $this->zip->close(); + $this->isOpen = false; + $this->syncWorkingZipIfNeeded(); + } + + $this->cleanupDeferredLocalizedPaths(); + } + + private function deferLocalizedCleanupPath(?string $path): void + { + if (!is_string($path) || $path === '') { + return; + } + + $this->localizedCleanupPaths[$path] = true; + } + + private function loadIgnorePatterns(string $source): void + { + $this->doLoadIgnorePatterns($source); + } + + private function localizeCompressionSource(string $source, ?string &$cleanupPath = null): string + { + return $this->doLocalizeCompressionSource($source, $cleanupPath); + } + + /** + * Logs a message using the registered logger callback. + * + * If a logger function is set, this method will invoke it + * with the provided message. + * + * @param string $message The message to log. + */ + private function log(string $message): void + { + if (is_callable($this->logger)) { + ($this->logger)($message); + } + } + + /** + * @param list $values + * @return list + */ + private function normalizeNonEmptyStrings(array $values): array + { + $normalized = []; + foreach ($values as $value) { + $trimmed = trim($value); + if ($trimmed === '') { + continue; + } + + $normalized[] = $trimmed; + } + + return $normalized; + } + + /** + * Opens the ZIP archive with the specified flags. + * + * This method attempts to open the ZIP archive located at the specified + * file path using the provided flags. If the archive cannot be opened, + * an exception is thrown with the corresponding error code. + * + * @param int $flags Optional flags to use when opening the ZIP archive. + * @throws CompressionException if the ZIP archive cannot be opened. + */ + private function openZip(int $flags = 0): void + { + if (($flags & ZipArchive::CREATE) === 0 && !FlysystemHelper::fileExists($this->zipFilePath)) { + throw new CompressionException("ZIP archive does not exist: {$this->zipFilePath}"); + } + + $result = $this->zip->open($this->workingZipPath, $flags); + if ($result !== true) { + throw new CompressionException("Failed to open ZIP archive at {$this->zipFilePath}. Error: $result"); + } + $this->isOpen = true; + } + + private function removeDirectorySilently(string $path): void + { + if (!is_dir($path)) { + return; + } + + $this->runSilently(static fn(): bool => rmdir($path)); + } + + /** + * Reopen the ZIP archive if it has been closed. + * + * If the archive is already open, this method is a no-op. + * @throws CompressionException + */ + private function reopenIfNeeded(): void + { + if (!$this->isOpen) { + $this->openZip(); + } + } + + private function resolveWorkingZipPath(bool $create): string + { + return $this->doResolveWorkingZipPath($create); + } + + private function runSilently(callable $operation): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + + private function shouldIncludePath(string $relativePath): bool + { + return $this->doShouldIncludePath($relativePath); + } + + private function shouldTraverseDirectory(string $relativePath): bool + { + return $this->doShouldTraverseDirectory($relativePath); + } + + private function syncWorkingZipIfNeeded(): void + { + $this->doSyncWorkingZipIfNeeded(); + } + + /** + * Triggers all registered hooks for a specified event. + * + * This method iterates over the registered callbacks for the given event + * and executes each callback with the provided arguments. If no hooks are + * registered for the event, the method does nothing. + * + * @param string $event The name of the event to trigger hooks for. + * @param mixed ...$args Arguments to pass to the callback functions. + */ + private function triggerHook(string $event, mixed ...$args): void + { + foreach ($this->hooks[$event] ?? [] as $callback) { + $callback(...$args); + } + } + + private function unlinkPathSilently(string $path): void + { + if (!is_file($path)) { + return; + } + + $this->runSilently(static fn(): bool => unlink($path)); + } +} diff --git a/src/FileManager/Concerns/FsConcern.php b/src/FileManager/Concerns/FsConcern.php index d5ca93d..c46d709 100644 --- a/src/FileManager/Concerns/FsConcern.php +++ b/src/FileManager/Concerns/FsConcern.php @@ -1,5 +1,7 @@ doRunSilently(static fn(): bool => mkdir($path, 0755, true)); } private function doLoadIgnorePatterns(string $source): void @@ -53,10 +56,6 @@ private function doLoadIgnorePatterns(string $source): void } $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, '#')) { @@ -77,41 +76,13 @@ private function doLocalizeCompressionSource(string $source, ?string &$cleanupPa } 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); + $cleanupPath = $this->doLocalizeRemoteFileSource($normalizedSource, $source); 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); + $cleanupPath = $this->doLocalizeRemoteDirectorySource($normalizedSource, $source); return $cleanupPath; } @@ -119,6 +90,47 @@ private function doLocalizeCompressionSource(string $source, ?string &$cleanupPa throw new CompressionException("Source path does not exist: {$source}"); } + private function doLocalizeRemoteDirectorySource(string $normalizedSource, string $originalSource): string + { + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_src_dir_', true); + if (!$this->doRunSilently(static fn(): bool => mkdir($tempDir, 0755, true)) && !is_dir($tempDir)) { + throw new CompressionException("Unable to localize source path: {$originalSource}"); + } + + $cleanupPath = PathHelper::normalize($tempDir); + $this->doMaterializeDirectoryToLocal($normalizedSource, $cleanupPath); + + return $cleanupPath; + } + + private function doLocalizeRemoteFileSource(string $normalizedSource, string $originalSource): string + { + $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_src_'); + if ($tempFile === false) { + throw new CompressionException("Unable to localize source path: {$originalSource}"); + } + + $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); + } + $this->doUnlinkFileSilently($tempFile); + + throw new CompressionException("Unable to localize source path: {$originalSource}"); + } + + stream_copy_to_stream($stream, $target); + fclose($stream); + fclose($target); + + return PathHelper::normalize($tempFile); + } + private function doMaterializeDirectoryToLocal(string $sourcePath, string $localDirectory): void { $base = $this->doResolveMaterializationBase($sourcePath); @@ -148,9 +160,18 @@ private function doResolveMaterializationBase(string $sourcePath): string return trim(str_replace('\\', '/', $baseLocation), '/'); } - private function doResolveMaterializedRelativePath(array $item, string $base): ?string + private function doResolveMaterializedRelativePath(mixed $item, string $base): ?string { - $itemPath = trim((string) ($item['path'] ?? ''), '/'); + if (!is_array($item)) { + return null; + } + + $itemPathRaw = $item['path'] ?? null; + if (!is_string($itemPathRaw)) { + return null; + } + + $itemPath = trim($itemPathRaw, '/'); if ($itemPath === '') { return null; } @@ -191,6 +212,7 @@ private function doResolveWorkingZipPath(bool $create): string if (is_resource($target)) { fclose($target); } + throw new CompressionException("Unable to read ZIP archive: {$this->zipFilePath}"); } @@ -198,15 +220,27 @@ private function doResolveWorkingZipPath(bool $create): string fclose($source); fclose($target); } elseif (!$create) { - @unlink($normalizedTemp); + $this->doUnlinkFileSilently($normalizedTemp); + throw new CompressionException("ZIP archive does not exist: {$this->zipFilePath}"); } else { - @unlink($normalizedTemp); + $this->doUnlinkFileSilently($normalizedTemp); } return $normalizedTemp; } + private function doRunSilently(callable $operation): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + private function doShouldIncludePath(string $relativePath): bool { $relativePath = str_replace('\\', '/', ltrim($relativePath, '/')); @@ -219,6 +253,7 @@ private function doShouldIncludePath(string $relativePath): bool foreach ($this->includePatterns as $pattern) { if (fnmatch($pattern, $relativePath)) { $matchesInclude = true; + break; } } @@ -270,4 +305,13 @@ private function doSyncWorkingZipIfNeeded(): void fclose($stream); } } + + private function doUnlinkFileSilently(string $path): void + { + if (!is_file($path)) { + return; + } + + $this->doRunSilently(static fn(): bool => unlink($path)); + } } diff --git a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php new file mode 100644 index 0000000..ca1b2df --- /dev/null +++ b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php @@ -0,0 +1,360 @@ + $params + */ + private function optionalBoolParam(array $params, int $index, bool $default): bool + { + $value = $params[$index] ?? null; + if ($value === null) { + return $default; + } + + if (!is_bool($value)) { + throw new Exception("Expected bool parameter at index {$index}."); + } + + return $value; + } + + /** + * @param list $params + */ + private function optionalStringParam(array $params, int $index, string $default): string + { + $value = $params[$index] ?? null; + if ($value === null) { + return $default; + } + + if (!is_string($value)) { + throw new Exception("Expected string parameter at index {$index}."); + } + + return $value; + } + + /** + * @param list $params + * @return array + */ + private function requireArrayParam(array $params, int $index, string $type): array + { + $value = $params[$index] ?? null; + if (!is_array($value)) { + throw new Exception("Write type '{$type}' expects array parameter at index {$index}."); + } + + return $value; + } + + /** + * @param list $params + * @return array + */ + private function requireCsvRowParam(array $params, int $index, string $type): array + { + $value = $this->requireArrayParam($params, $index, $type); + $row = []; + foreach ($value as $column) { + if (!is_string($column) && !is_int($column) && !is_float($column) && !is_bool($column) && $column !== null) { + throw new Exception("Write type '{$type}' expects scalar CSV values."); + } + + $row[] = $column; + } + + return $row; + } + + private function requireFileHandle(): SplFileObject + { + if (!$this->file instanceof SplFileObject) { + throw new FileAccessException("Cannot write to file: {$this->filename}"); + } + + return $this->file; + } + + /** + * @param list $params + * @return array + */ + private function requireFixedWidthDataParam(array $params, int $index, string $type): array + { + return $this->requireCsvRowParam($params, $index, $type); + } + + /** + * @param list $params + */ + private function requireStringParam(array $params, int $index, string $type): string + { + $value = $params[$index] ?? null; + if (!is_string($value)) { + throw new Exception("Write type '{$type}' expects string parameter at index {$index}."); + } + + return $value; + } + + /** + * @param list $params + * @return array + */ + private function requireWidthsParam(array $params, int $index, string $type): array + { + $value = $this->requireArrayParam($params, $index, $type); + $widths = []; + foreach ($value as $width) { + if (!is_int($width)) { + throw new Exception("Write type '{$type}' expects integer widths."); + } + + $widths[] = $width; + } + + return $widths; + } + + /** + * @param list $params + */ + private function requireXmlParam(array $params, int $index, string $type): SimpleXMLElement + { + $value = $params[$index] ?? null; + if (!$value instanceof SimpleXMLElement) { + throw new Exception("Write type '{$type}' expects SimpleXMLElement at index {$index}."); + } + + return $value; + } + + /** + * Tracks the number of times a write type is called. + * + * @param string $type The type of write (e.g. 'character', 'line', 'csv', etc.). + */ + private function trackWriteType(string $type): void + { + $type = strtolower($type); + if (!isset($this->writeTypesCount[$type])) { + $this->writeTypesCount[$type] = 0; + } + $this->writeTypesCount[$type]++; + } + + /** + * Writes a string of binary data to the file. + * + * This function takes a string of binary data and writes it to the file. + * The write count is incremented after writing the data. + * + * @param string $data The binary data to write. + * @return int|false The number of bytes written, or false on failure. + */ + private function writeBinary(string $data): int|false + { + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($data); + } + + /** + * Writes a single character to the file. + * + * This function takes a single character and writes it to the file. + * The write count is incremented after writing the data. + * + * @param string $char The character to write to the file. + * @return int|false The number of bytes written, or false on failure. + */ + private function writeCharacter(string $char): int|false + { + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($char); + } + + /** + * Writes a row of data to the file in CSV format. + * + * This function takes an array of data and writes it to the file + * as a CSV line using the specified separator, enclosure, and + * escape characters. It increments the write count after writing. + * + * @param array $row The data to write as a CSV line. + * @param string $separator The character used to separate fields. Defaults to ','. + * @param string $enclosure The character used to enclose fields. Defaults to '"'. + * @param string $escape The character used to escape special characters. Defaults to '\\'. + * @return int|false The number of bytes written, or false on failure. + */ + private function writeCSV( + array $row, + string $separator = ',', + string $enclosure = '"', + string $escape = '\\', + ): int|false { + $this->writeCount++; + + return $this->requireFileHandle()->fputcsv($row, $separator, $enclosure, $escape); + } + + /** + * Writes a line of fixed-width fields to the file. + * + * The given $data array is padded and written to the file, with each + * element padded to the corresponding width in the $widths array. + * + * @param array $data The data to write. Each element is written as a string. + * @param array $widths The widths of each field. Each element is a positive integer. + * @return int|false The number of bytes written, or false on failure. + * @throws Exception If the count of $data does not match the count of $widths. + */ + private function writeFixedWidth(array $data, array $widths): int|false + { + if (count($data) !== count($widths)) { + throw new Exception('Data and widths arrays must match.'); + } + $line = ''; + foreach ($data as $index => $field) { + $width = $widths[$index] ?? null; + if (!is_int($width)) { + throw new Exception('Widths must contain integers.'); + } + + $line .= str_pad((string) $field, $width); + } + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($line . PHP_EOL); + } + + /** + * Writes JSON data to the file. + * + * This function encodes the provided data as JSON and writes it to the file. + * Optionally, it can format the JSON with indentation and whitespace for readability. + * + * @param mixed $data The data to encode as JSON and write. + * @param bool $prettyPrint If true, the JSON will be formatted for readability. Defaults to false. + * @return int|false The number of bytes written, or false on failure. + * @throws Exception If JSON encoding fails. + */ + private function writeJSON(mixed $data, bool $prettyPrint = false): int|false + { + $jsonOptions = $prettyPrint ? JSON_PRETTY_PRINT : 0; + $jsonData = json_encode($data, $jsonOptions); + if ($jsonData === false) { + throw new Exception('JSON encoding failed: ' . json_last_error_msg()); + } + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($jsonData . PHP_EOL); + } + + /** + * Writes a JSON array to the file. + * + * @param array $data The array of data to write. + * @param bool $prettyPrint If true, the JSON will be formatted with + * indentation and whitespace for readability. Defaults to false. + * @return int|false The number of bytes written, or false on failure. + * @throws Exception If the JSON encoding fails. + */ + private function writeJSONArray(array $data, bool $prettyPrint = false): int|false + { + $jsonOptions = $prettyPrint ? JSON_PRETTY_PRINT : 0; + $jsonData = json_encode($data, $jsonOptions); + if ($jsonData === false) { + throw new Exception('JSON encoding failed: ' . json_last_error_msg()); + } + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($jsonData . PHP_EOL); + } + + /** + * Writes a line of text to the file. + * + * This function takes a string of content and writes it to the file, + * appending a newline character at the end. + * The write count is incremented after writing the data. + * + * @param string $content The content to write to the file. + * @return int|false The number of bytes written, or false on failure. + */ + private function writeLine(string $content): int|false + { + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($content . PHP_EOL); + } + + /** + * Writes the given content to the file if it matches the specified pattern. + * + * This function checks if the provided content matches the given regex pattern. + * If a match is found, the content is written to the file with a newline appended. + * The write count is incremented each time content is successfully written. + * + * @param string $content The content to be checked and potentially written. + * @param string $pattern The regex pattern to match against the content. + * @return int|false The number of bytes written, or false on failure. + */ + private function writePatternMatch(string $content, string $pattern): int|false + { + if (preg_match($pattern, $content)) { + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($content . PHP_EOL); + } + + return false; + } + + /** + * Writes a serialized representation of the given data to the file. + * + * The `serialize` function is used to convert the data into a string + * representation. The resulting string is then written to the file, + * followed by a newline. + * + * @param mixed $data The data to serialize and write. + * @return int|false The number of bytes written, or false on failure. + */ + private function writeSerialized(mixed $data): int|false + { + $serializedData = serialize($data); + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($serializedData . PHP_EOL); + } + + /** + * Writes an XML element to the file. + * + * This function takes a SimpleXMLElement, converts it to an XML string, + * and writes it to the file, appending a newline character. + * + * @param SimpleXMLElement $element The XML element to write. + * @return int|false The number of bytes written, or false on failure. + */ + private function writeXML(SimpleXMLElement $element): int|false + { + $this->writeCount++; + + return $this->requireFileHandle()->fwrite($element->asXML() . PHP_EOL); + } +} diff --git a/src/FileManager/FileCompression.php b/src/FileManager/FileCompression.php index 70aa13f..8006419 100644 --- a/src/FileManager/FileCompression.php +++ b/src/FileManager/FileCompression.php @@ -1,38 +1,75 @@ */ private array $excludePatterns = []; + private ExecutionStrategy $executionStrategy = ExecutionStrategy::PHP; + + /** @var array> */ private array $hooks = []; + + /** @var list */ private array $ignoreFileNames = ['.pathwiseignore', '.gitignore']; + + /** @var list */ private array $ignorePatterns = []; + + /** @var list */ private array $includePatterns = []; + private bool $isOpen = false; + /** @var array */ private array $localizedCleanupPaths = []; + private mixed $logger = null; + private ?string $password = null; + private mixed $progressCallback = null; + private int $progressCurrent = 0; + private int $progressTotal = 0; + private bool $syncWorkingZipOnClose = false; + private string $workingZipPath; /** @@ -67,19 +104,18 @@ public function __destruct() } finally { $this->cleanupDeferredLocalizedPaths(); if ($this->cleanupWorkingZipPath && is_file($this->workingZipPath)) { - @unlink($this->workingZipPath); + $this->unlinkPathSilently($this->workingZipPath); } } } - /** * Adds a single file to the current ZIP archive. * * @param string $filePath The path to the file to be added. * @param string|null $zipPath The path in the ZIP archive where the file should be stored. - * If not provided, the file will be stored in the root directory of the ZIP file, - * with its original name. + * If not provided, the file will be stored in the root directory of the ZIP file, + * with its original name. * * @return $this */ @@ -99,38 +135,48 @@ public function addFile(string $filePath, ?string $zipPath = null): self $this->advanceProgress('compress', $zipPath); $this->triggerHook('afterAdd', $filePath); + return $this; } - /** * Batch add multiple files to the current ZIP archive. * - * @param array $files An associative array of file paths mapped to their - * desired paths inside the ZIP archive. If a value is not provided for - * a key, the basename of the file will be used as the path in the ZIP - * archive. + * @param array $files An associative array of file paths mapped to their + * desired paths inside the ZIP archive. If a value is not provided for + * a key, the basename of the file will be used as the path in the ZIP + * archive. * @return $this */ public function batchAddFiles(array $files): self { $this->reopenIfNeeded(); - $this->log("Batch adding files."); + $this->log('Batch adding files.'); foreach ($files as $filePath => $zipPath) { + if (is_int($filePath)) { + if (!is_string($zipPath)) { + throw new CompressionException('Invalid file path provided for batch add.'); + } + + $this->addFile($zipPath); + + continue; + } + $this->addFile($filePath, $zipPath); } + return $this; } - /** * Batch extract multiple files from the current ZIP archive. * - * @param array $files An associative array mapping ZIP paths to local paths. + * @param array $files An associative array mapping ZIP paths to local paths. * @param string $destination The destination directory to extract to. * * - * @throws Exception If any of the files fail to extract. + * @throws CompressionException If any of the files fail to extract. */ public function batchExtractFiles(array $files, string $destination): self { @@ -139,18 +185,19 @@ public function batchExtractFiles(array $files, string $destination): self if (!FlysystemHelper::directoryExists($destination)) { FlysystemHelper::createDirectory($destination); } - $this->log("Batch extracting files."); + $this->log('Batch extracting files.'); $this->progressCurrent = 0; $this->progressTotal = count($files); foreach ($files as $zipPath => $localPath) { - $zipPath = $this->normalizeZipPath((string) $zipPath); - $localPath = ltrim(PathHelper::normalize((string) $localPath), DIRECTORY_SEPARATOR); + $zipPath = $this->normalizeZipPath($zipPath); + $localPath = ltrim(PathHelper::normalize($localPath), DIRECTORY_SEPARATOR); $targetPath = PathHelper::join($destination, $localPath); if (str_ends_with($zipPath, '/')) { if (!FlysystemHelper::directoryExists($targetPath)) { FlysystemHelper::createDirectory($targetPath); } + continue; } @@ -168,10 +215,10 @@ public function batchExtractFiles(array $files, string $destination): self $this->advanceProgress('decompress', $zipPath); } + return $this; } - /** * Check the integrity of the current ZIP archive. * @@ -183,10 +230,10 @@ public function batchExtractFiles(array $files, string $destination): self public function checkIntegrity(): bool { $this->reopenIfNeeded(); + return $this->zip->status === ZipArchive::ER_OK; } - /** * Compress a file or directory into the ZIP archive. * @@ -232,13 +279,12 @@ public function compress(string $source): self return $this; } - /** * Compress a file or directory, but only include files with the specified * extensions in the ZIP archive. * * @param string $source The path to the file or directory to compress. - * @param array $extensions An array of file extensions to include. + * @param list $extensions An array of file extensions to include. * @return static */ public function compressWithFilter(string $source, array $extensions = []): self @@ -256,7 +302,6 @@ public function compressWithFilter(string $source, array $extensions = []): self return $this; } - /** * Decompress the current ZIP archive to a directory. * @@ -293,10 +338,10 @@ public function decompress(?string $destination = null): self } $this->log("Decompressed to: $destination"); + return $this; } - /** * Returns the number of files in the current ZIP archive. * @@ -305,45 +350,53 @@ public function decompress(?string $destination = null): self public function fileCount(): int { $this->reopenIfNeeded(); + return $this->zip->numFiles; } - /** * Returns an iterator over the files in the current ZIP archive. * * Yields each file in the archive as a string, in the order they appear in the archive. * - * @return \Generator An iterator over the files in the current ZIP archive. + * @return \Generator An iterator over the files in the current ZIP archive. */ public function getFileIterator(): \Generator { $this->reopenIfNeeded(); for ($i = 0; $i < $this->zip->numFiles; $i++) { - yield $this->zip->getNameIndex($i); + $name = $this->zip->getNameIndex($i); + if (!is_string($name)) { + continue; + } + + yield $name; } } - /** * Get an array of all the files in the current ZIP archive. * * The returned array contains the names of all the files in the archive, * in the order they appear in the archive. * - * @return array An array of file names in the current ZIP archive. + * @return list An array of file names in the current ZIP archive. */ public function listFiles(): array { $this->reopenIfNeeded(); $files = []; for ($i = 0; $i < $this->zip->numFiles; $i++) { - $files[] = $this->zip->getNameIndex($i); + $name = $this->zip->getNameIndex($i); + if (!is_string($name)) { + continue; + } + $files[] = $name; } + return $files; } - /** * Registers a callback to be called when a certain event occurs. * @@ -371,6 +424,7 @@ public function listFiles(): array public function registerHook(string $event, callable $callback): self { $this->hooks[$event][] = $callback; + return $this; } @@ -382,10 +436,10 @@ public function registerHook(string $event, callable $callback): self public function save(): self { $this->closeZip(); + return $this; } - /** * Set the default path to use for decompression if no path is provided. * @@ -398,10 +452,10 @@ public function save(): self public function setDefaultDecompressionPath(string $path): self { $this->defaultDecompressionPath = $path; + return $this; } - /** * Sets the encryption algorithm for the ZIP archive. * @@ -409,16 +463,17 @@ public function setDefaultDecompressionPath(string $path): self * when encrypting the ZIP archive. Supported algorithms are AES-256 and AES-128. * * @param int $algorithm The encryption algorithm to set. Must be one of - * ZipArchive::EM_AES_256 or ZipArchive::EM_AES_128. + * ZipArchive::EM_AES_256 or ZipArchive::EM_AES_128. * @throws CompressionException If an invalid encryption algorithm is specified. */ public function setEncryptionAlgorithm(int $algorithm): self { if (!in_array($algorithm, [ZipArchive::EM_AES_256, ZipArchive::EM_AES_128], true)) { - throw new CompressionException("Invalid encryption algorithm specified."); + throw new CompressionException('Invalid encryption algorithm specified.'); } $this->encryptionAlgorithm = $algorithm; + return $this; } @@ -438,14 +493,14 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self /** * Configure include/exclude glob patterns used during compression. * - * @param array $includePatterns Patterns to include in compression. - * @param array $excludePatterns Patterns to exclude from compression. + * @param list $includePatterns Patterns to include in compression. + * @param list $excludePatterns Patterns to exclude from compression. * @return self This instance for method chaining. */ public function setGlobPatterns(array $includePatterns = [], array $excludePatterns = []): self { - $this->includePatterns = array_values(array_filter(array_map(trim(...), $includePatterns), fn($v) => $v !== '')); - $this->excludePatterns = array_values(array_filter(array_map(trim(...), $excludePatterns), fn($v) => $v !== '')); + $this->includePatterns = $this->normalizeNonEmptyStrings($includePatterns); + $this->excludePatterns = $this->normalizeNonEmptyStrings($excludePatterns); return $this; } @@ -453,17 +508,16 @@ public function setGlobPatterns(array $includePatterns = [], array $excludePatte /** * Configure ignore file names (e.g. .gitignore, .pathwiseignore) read from source root. * - * @param array $ignoreFileNames Array of ignore file names. + * @param list $ignoreFileNames Array of ignore file names. * @return self This instance for method chaining. */ public function setIgnoreFileNames(array $ignoreFileNames): self { - $this->ignoreFileNames = array_values(array_filter(array_map(trim(...), $ignoreFileNames), fn($v) => $v !== '')); + $this->ignoreFileNames = $this->normalizeNonEmptyStrings($ignoreFileNames); return $this; } - /** * Sets a logger callable to be called when certain events occur. * @@ -471,17 +525,17 @@ public function setIgnoreFileNames(array $ignoreFileNames): self * the ZipArchive object as its second argument. * * @param callable $logger The logger callable. The callable should accept - * two arguments: the first is a string message, and the second is the - * ZipArchive object. + * two arguments: the first is a string message, and the second is the + * ZipArchive object. * @return self This instance for method chaining. */ public function setLogger(callable $logger): self { $this->logger = $logger; + return $this; } - /** * Set the password for the ZIP archive. * @@ -491,6 +545,7 @@ public function setLogger(callable $logger): self public function setPassword(string $password): self { $this->password = $password; + return $this; } @@ -506,573 +561,4 @@ 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. - * - * This method traverses the specified directory and adds files to the - * ZIP archive. Directories are added as empty directories. If the - * password is set, files are added with encryption. - * - * @param string $path The path to add files from. - * @param ZipArchive $zip The ZIP archive to add files to. - * @param string|null $baseDir The base directory to use for relative paths. - */ - private function addFilesToZip(string $path, ZipArchive $zip, ?string $baseDir = null): void - { - $baseDir ??= $path; - - if (is_dir($path)) { - $this->addDirectoryEntriesToZip($path, $zip, $baseDir); - - return; - } - - $this->addSinglePathToZip($path, $zip, $baseDir); - } - - - /** - * Recursively add files to the ZIP archive, filtering by extensions. - * - * This method traverses the specified directory and adds files to the - * ZIP archive based on the provided file extensions. Directories are - * added as empty directories if no matching files are found within them. - * If a password is set, files are encrypted using the specified algorithm. - * - * @param string $path The path to the directory or file to add. - * @param ZipArchive $zip The ZIP archive instance to add files to. - * @param string|null $relativePath The relative path within the ZIP archive. - * @param array $extensions An array of file extensions to filter by. - */ - private function addFilesToZipWithFilter(string $path, ZipArchive $zip, ?string $relativePath, array $extensions): void - { - $relativePath ??= basename($path); - $relativePath = $this->normalizeZipPath($relativePath); - - if (is_dir($path)) { - if ($relativePath !== '' && !$this->shouldTraverseDirectory($relativePath)) { - return; - } - $zip->addEmptyDir($relativePath); - $entries = scandir($path); - if ($entries === false) { - throw new CompressionException("Failed to read directory: {$path}"); - } - - foreach ($entries as $file) { - if ($file !== '.' && $file !== '..') { - $this->addFilesToZipWithFilter($path . DIRECTORY_SEPARATOR . $file, $zip, "$relativePath/$file", $extensions); - } - } - } elseif ((empty($extensions) || in_array(pathinfo($path, PATHINFO_EXTENSION), $extensions)) && $this->shouldIncludePath($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)) { - return; - } - - $this->progressCurrent++; - ($this->progressCallback)([ - 'operation' => $operation, - 'path' => $path, - 'current' => $this->progressCurrent, - 'total' => $this->progressTotal, - ]); - } - - 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 === []) { - return; - } - - foreach (array_keys($this->localizedCleanupPaths) as $path) { - $this->cleanupLocalizedPath($path); - } - - $this->localizedCleanupPaths = []; - } - - private function cleanupLocalizedPath(?string $path): void - { - if (!is_string($path) || $path === '' || !file_exists($path)) { - return; - } - - if (is_file($path)) { - @unlink($path); - - return; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $item) { - if ($item->isDir()) { - @rmdir($item->getPathname()); - } else { - @unlink($item->getPathname()); - } - } - - @rmdir($path); - } - - - /** - * Closes the current ZIP archive. - * - * If the archive is currently open, this method will close it using the - * ZipArchive::close() method. The $isOpen flag is then set to false to - * indicate that the archive is no longer open. - */ - private function closeZip(): void - { - if ($this->isOpen) { - $this->zip->close(); - $this->isOpen = false; - $this->syncWorkingZipIfNeeded(); - } - - $this->cleanupDeferredLocalizedPaths(); - } - - private function copyLocalDirectoryToFlysystem(string $localSource, string $destination): void - { - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($localSource, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::SELF_FIRST, - ); - - foreach ($iterator as $item) { - $relative = $this->getRelativePath($item->getPathname(), $localSource); - if ($relative === '') { - continue; - } - - $targetPath = PathHelper::join($destination, $relative); - - if ($item->isDir()) { - FlysystemHelper::createDirectory($targetPath); - continue; - } - - $stream = fopen($item->getPathname(), 'rb'); - if (!is_resource($stream)) { - throw new CompressionException("Unable to read extracted file: {$item->getPathname()}"); - } - - try { - FlysystemHelper::writeStream($targetPath, $stream); - } finally { - fclose($stream); - } - } - } - - private function countFilesForCompression(string $source, array $extensions = []): int - { - if (is_file($source)) { - $relative = basename($source); - if (!empty($extensions) && !in_array(pathinfo($source, PATHINFO_EXTENSION), $extensions, true)) { - return 0; - } - return $this->shouldIncludePath($relative) ? 1 : 0; - } - - if (!is_dir($source)) { - return 0; - } - - $count = 0; - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::SELF_FIRST, - ); - - foreach ($iterator as $item) { - if ($item->isDir()) { - continue; - } - - $relative = $this->getRelativePath($item->getPathname(), $source); - if (!empty($extensions) && !in_array(pathinfo((string) $item->getPathname(), PATHINFO_EXTENSION), $extensions, true)) { - continue; - } - if ($this->shouldIncludePath($relative)) { - $count++; - } - } - - 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 === '') { - return; - } - - $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 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. - */ - private function getRelativePath(string $path, string $baseDir): string - { - $normalizedPath = str_replace('\\', '/', PathHelper::normalize($path)); - $normalizedBase = rtrim(str_replace('\\', '/', PathHelper::normalize($baseDir)), '/'); - - if ($normalizedPath === $normalizedBase) { - return ''; - } - - if (str_starts_with($normalizedPath, $normalizedBase . '/')) { - return substr($normalizedPath, strlen($normalizedBase) + 1); - } - - return ltrim($normalizedPath, '/'); - } - - private function initializeProgress(string $source, array $extensions = []): void - { - $this->progressCurrent = 0; - $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)); - } - - private function loadIgnorePatterns(string $source): void - { - $this->doLoadIgnorePatterns($source); - } - - private function localizeCompressionSource(string $source, ?string &$cleanupPath = null): string - { - return $this->doLocalizeCompressionSource($source, $cleanupPath); - } - - - /** - * Logs a message using the registered logger callback. - * - * If a logger function is set, this method will invoke it - * with the provided message. - * - * @param string $message The message to log. - */ - private function log(string $message): void - { - if (is_callable($this->logger)) { - ($this->logger)($message); - } - } - - private function normalizeZipPath(string $path): string - { - $hadTrailingSlash = str_ends_with(str_replace('\\', '/', $path), '/'); - $normalized = ltrim(str_replace('\\', '/', PathHelper::normalize($path)), '/'); - - if ($hadTrailingSlash && $normalized !== '' && !str_ends_with($normalized, '/')) { - $normalized .= '/'; - } - - return $normalized; - } - - - /** - * Opens the ZIP archive with the specified flags. - * - * This method attempts to open the ZIP archive located at the specified - * file path using the provided flags. If the archive cannot be opened, - * an exception is thrown with the corresponding error code. - * - * @param int $flags Optional flags to use when opening the ZIP archive. - * @throws CompressionException if the ZIP archive cannot be opened. - */ - private function openZip(int $flags = 0): void - { - if (($flags & ZipArchive::CREATE) === 0 && !FlysystemHelper::fileExists($this->zipFilePath)) { - throw new CompressionException("ZIP archive does not exist: {$this->zipFilePath}"); - } - - $result = $this->zip->open($this->workingZipPath, $flags); - if ($result !== true) { - throw new CompressionException("Failed to open ZIP archive at {$this->zipFilePath}. Error: $result"); - } - $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. - * - * If the archive is already open, this method is a no-op. - * @throws CompressionException - */ - private function reopenIfNeeded(): void - { - if (!$this->isOpen) { - $this->openZip(); - } - } - - private function resolveDecompressionDestination(?string $destination): string - { - $destination ??= $this->defaultDecompressionPath; - if (!$destination) { - throw new CompressionException("No destination path provided for decompression."); - } - - return PathHelper::normalize($destination); - } - - private function resolveWorkingZipPath(bool $create): string - { - return $this->doResolveWorkingZipPath($create); - } - - private function shouldAttemptNativeCompression(): bool - { - if ($this->executionStrategy === ExecutionStrategy::PHP) { - return false; - } - - // Native path currently targets whole-source archive operations only. - return $this->password === null - && $this->includePatterns === [] - && $this->excludePatterns === [] - && $this->ignorePatterns === [] - && $this->hooks === []; - } - - private function shouldIncludePath(string $relativePath): bool - { - return $this->doShouldIncludePath($relativePath); - } - - private function shouldTraverseDirectory(string $relativePath): bool - { - return $this->doShouldTraverseDirectory($relativePath); - } - - private function syncWorkingZipIfNeeded(): void - { - $this->doSyncWorkingZipIfNeeded(); - } - - - /** - * Triggers all registered hooks for a specified event. - * - * This method iterates over the registered callbacks for the given event - * and executes each callback with the provided arguments. If no hooks are - * registered for the event, the method does nothing. - * - * @param string $event The name of the event to trigger hooks for. - * @param mixed ...$args Arguments to pass to the callback functions. - */ - private function triggerHook(string $event, mixed ...$args): void - { - foreach ($this->hooks[$event] ?? [] as $callback) { - $callback(...$args); - } - } } diff --git a/src/FileManager/FileOperations.php b/src/FileManager/FileOperations.php index 4b78c4c..0193372 100644 --- a/src/FileManager/FileOperations.php +++ b/src/FileManager/FileOperations.php @@ -1,5 +1,7 @@ */ private array $rollbackActions = []; + private bool $transactionActive = false; /** @@ -48,6 +55,7 @@ public function append(string $content): self $newContent = ($previousContent ?? '') . $content; FlysystemHelper::write($this->filePath, $newContent); $this->audit('append', ['path' => $this->filePath, 'bytes' => strlen($content)]); + return $this; } @@ -165,6 +173,7 @@ public function create(?string $content = ''): self }); FlysystemHelper::write($this->filePath, (string) $content); $this->audit('create', ['path' => $this->filePath]); + return $this; } @@ -181,12 +190,14 @@ public function delete(): self $this->recordRollback(function () use ($content): void { FlysystemHelper::write($this->filePath, $content); }); + try { FlysystemHelper::delete($this->filePath); } catch (\Throwable $e) { throw new FileAccessException("Unable to delete file at $this->filePath.", 0, $e); } $this->audit('delete', ['path' => $this->filePath]); + return $this; } @@ -203,13 +214,25 @@ public function exists(): bool */ public function getLineCount(): int { - $this->initFile(); - $this->file->seek(PHP_INT_MAX); - return $this->file->key() + 1; + $file = $this->requireFile(); + $file->seek(PHP_INT_MAX); + + return $file->key() + 1; } /** * Get all metadata for the file. + * + * @return array{ + * permissions: string, + * size: int, + * last_modified: int, + * owner: int|false, + * group: int|false, + * type: string|false, + * mime_type: string|null, + * extension: string + * } */ public function getMetadata(): array { @@ -237,6 +260,7 @@ public function isReadable(): bool if (!$this->exists()) { throw new FileNotFoundException("File not found at $this->filePath."); } + return is_readable($this->filePath); } @@ -247,13 +271,13 @@ public function isReadable(): bool */ public function openWithLock(bool $exclusive = true, int $timeout = 0): self { - $this->initFile('r+'); + $file = $this->requireFile('r+'); $lockType = $exclusive ? LOCK_EX : LOCK_SH; $lockType |= LOCK_NB; $startTime = time(); - while (!$this->file->flock($lockType)) { + while (!$file->flock($lockType)) { if ($timeout > 0 && (time() - $startTime) >= $timeout) { throw new FileAccessException("Timeout reached while trying to acquire lock on file: {$this->filePath}."); } @@ -266,7 +290,7 @@ public function openWithLock(bool $exclusive = true, int $timeout = 0): self /** * Get the public URL for this file. * - * @param array $config Additional configuration for URL generation. + * @param array $config Additional configuration for URL generation. * @return string The public URL. * @throws FileNotFoundException If the file does not exist. */ @@ -287,6 +311,7 @@ public function publicUrl(array $config = []): string public function read(): string { $this->isReadable(); + return FlysystemHelper::read($this->filePath); } @@ -310,6 +335,7 @@ public function rename(string $newPath): self { $this->assertPolicy('rename', $this->filePath, ['destination' => $newPath]); $newPath = PathHelper::normalize($newPath); + try { FlysystemHelper::move($this->filePath, $newPath); } catch (\Throwable $e) { @@ -324,6 +350,7 @@ public function rename(string $newPath): self $this->filePath = $newPath; $this->initFile(); // Reinitialize file object with new path $this->audit('rename', ['from' => $oldPath, 'to' => $newPath]); + return $this; } @@ -345,6 +372,8 @@ public function rollbackTransaction(): self /** * Search for a term in the file using OS-native commands and return matching lines. + * + * @return list */ public function searchContent(string $searchTerm): array { @@ -403,6 +432,7 @@ public function setGroup(int $groupId): self throw new FileAccessException("Unable to change group for file: {$this->filePath}."); } $this->audit('set-group', ['path' => $this->filePath, 'group' => $groupId]); + return $this; } @@ -416,6 +446,7 @@ public function setOwner(int $ownerId): self throw new FileAccessException("Unable to change owner for file: {$this->filePath}."); } $this->audit('set-owner', ['path' => $this->filePath, 'owner' => $ownerId]); + return $this; } @@ -438,6 +469,7 @@ public function setPermissions(int $permissions): self throw new FileAccessException("Unable to set permissions for file: {$this->filePath}."); } $this->audit('set-permissions', ['path' => $this->filePath, 'permissions' => $permissions]); + return $this; } @@ -473,7 +505,7 @@ public function setVisibility(string $visibility): self * Get a temporary URL for this file. * * @param DateTimeInterface $expiresAt The expiration time for the URL. - * @param array $config Additional configuration for URL generation. + * @param array $config Additional configuration for URL generation. * @return string The temporary URL. * @throws FileNotFoundException If the file does not exist. */ @@ -504,6 +536,7 @@ public function transaction(callable $callback): mixed return $result; } catch (\Throwable $e) { $this->rollbackTransaction(); + throw $e; } } @@ -513,7 +546,8 @@ public function transaction(callable $callback): mixed */ public function unlock(): self { - $this->file->flock(LOCK_UN); + $this->requireFile()->flock(LOCK_UN); + return $this; } @@ -532,6 +566,7 @@ public function update(string $content): self }); FlysystemHelper::write($this->filePath, $content); $this->audit('update', ['path' => $this->filePath, 'bytes' => strlen($content)]); + return $this; } @@ -595,7 +630,7 @@ public function writeAndVerify(string $content, string $algorithm = 'sha256'): s * Write to the file from a stream. * * @param mixed $stream The stream resource to write from. - * @param array $config Additional configuration for the write operation. + * @param array $config Additional configuration for the write operation. * @return self This instance for method chaining. */ public function writeStream(mixed $stream, array $config = []): self @@ -613,17 +648,25 @@ public function writeStream(mixed $stream, array $config = []): self protected function initFile(string $mode = 'r'): self { $this->file = new SplFileObject($this->filePath, $mode); + return $this; } + /** + * @param array $context + */ private function assertPolicy(string $operation, string $path, array $context = []): void { $this->policyEngine?->assertAllowed($operation, PathHelper::normalize($path), $context); } + /** + * @param array $context + */ private function audit(string $operation, array $context = []): void { - $context['path'] = PathHelper::normalize((string) ($context['path'] ?? $this->filePath)); + $path = $context['path'] ?? $this->filePath; + $context['path'] = PathHelper::normalize(is_string($path) ? $path : $this->filePath); $this->auditTrail?->log($operation, $context); } @@ -660,4 +703,17 @@ private function recordRollback(callable $rollbackAction): void $this->rollbackActions[] = $rollbackAction; } + + private function requireFile(string $mode = 'r'): SplFileObject + { + if (!$this->file instanceof SplFileObject) { + $this->initFile($mode); + } + + if (!$this->file instanceof SplFileObject) { + throw new FileAccessException("Unable to initialize file object for {$this->filePath}."); + } + + return $this->file; + } } diff --git a/src/FileManager/SafeFileReader.php b/src/FileManager/SafeFileReader.php index 4cddf2a..95f063c 100644 --- a/src/FileManager/SafeFileReader.php +++ b/src/FileManager/SafeFileReader.php @@ -1,5 +1,7 @@ $widths) Fixed-width field iterator * @method SafeFileReader xml(string $element) XML iterator * @method SafeFileReader serialized() Serialized object iterator * @method SafeFileReader jsonArray() JSON array iterator + * @implements Iterator + * @implements SeekableIterator */ final class SafeFileReader implements Countable, Iterator, SeekableIterator { private bool $cleanupLocalWorkingPath = false; + private int $count = 0; + private ?Generator $currentIterator = null; + private SplFileObject $file; + private int $fileSize; + private bool $isLocked = false; + private ?string $localWorkingPath = null; + private int $position = 0; /** @@ -46,7 +57,7 @@ final class SafeFileReader implements Countable, Iterator, SeekableIterator * @param string $filename The path to the file to read. * @param string $mode The file mode to open the file with. Defaults to 'r'. * @param bool $exclusiveLock When true, a lock is acquired on the file before - * reading. The type of lock is determined by the $mode parameter. + * reading. The type of lock is determined by the $mode parameter. */ public function __construct( private readonly string $filename, @@ -65,7 +76,7 @@ public function __destruct() { $this->releaseLock(); if ($this->cleanupLocalWorkingPath && is_string($this->localWorkingPath) && is_file($this->localWorkingPath)) { - @unlink($this->localWorkingPath); + $this->unlinkPathSilently($this->localWorkingPath); } } @@ -79,8 +90,8 @@ public function __destruct() * generates the appropriate iterator for processing the file content. * * @param string $type The type of iterator to create. - * @param array $params Parameters to pass to the iterator method. - * @return NoRewindIterator The requested iterator wrapped in a NoRewindIterator. + * @param list $params Parameters to pass to the iterator method. + * @return NoRewindIterator The requested iterator wrapped in a NoRewindIterator. * @throws Exception If the specified iterator type is unknown. */ public function __call(string $type, array $params): NoRewindIterator @@ -89,12 +100,16 @@ public function __call(string $type, array $params): NoRewindIterator $this->currentIterator = match ($type) { 'character' => $this->characterIterator(), 'line' => $this->lineIterator(), - 'csv' => $this->csvIterator(...$params), - 'binary' => $this->binaryIterator(...$params), + 'csv' => $this->csvIterator( + $this->optionalStringParam($params, 0, ','), + $this->optionalStringParam($params, 1, '"'), + $this->optionalStringParam($params, 2, '\\'), + ), + 'binary' => $this->binaryIterator($this->optionalIntParam($params, 0, 1024)), 'json' => $this->jsonIteratorWithHandling(), - 'regex' => $this->regexIterator(...$params), - 'fixedWidth' => $this->fixedWidthIterator(...$params), - 'xml' => $this->xmlIterator(...$params), + 'regex' => $this->regexIterator($this->requireStringParam($params, 0, 'regex pattern')), + 'fixedWidth' => $this->fixedWidthIterator($this->requireWidthsParam($params)), + 'xml' => $this->xmlIterator($this->requireStringParam($params, 0, 'xml element')), 'serialized' => $this->serializedIterator(), 'jsonArray' => $this->jsonArrayIteratorWithHandling(), default => throw new Exception("Unknown iterator type '$type'"), @@ -333,7 +348,7 @@ private function deserializeValue(string $serializedLine): mixed * The given $widths array is used to determine the width of each field. * The fields are extracted from each line using substr(), and are yielded as an array. * - * @param array $widths An array of positive integers, each specifying the width of a field. + * @param list $widths An array of positive integers, each specifying the width of a field. * @return Generator Yields an array of fields for each line of the file. */ private function fixedWidthIterator(array $widths): Generator @@ -343,6 +358,10 @@ private function fixedWidthIterator(array $widths): Generator $fields = []; $offset = 0; foreach ($widths as $width) { + if ($width < 1) { + continue; + } + $fields[] = substr($line, $offset, $width); $offset += $width; } @@ -378,7 +397,6 @@ private function initiate(): void } } - /** * Iterates over a JSON array with error handling. * @@ -391,7 +409,12 @@ private function initiate(): void */ private function jsonArrayIteratorWithHandling(): Generator { - $jsonArray = json_decode($this->file->fread($this->fileSize), true); + $jsonContent = $this->file->fread($this->fileSize); + if (!is_string($jsonContent)) { + throw new Exception('JSON array decoding error: failed to read file content.'); + } + + $jsonArray = json_decode($jsonContent, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($jsonArray)) { throw new Exception('JSON array decoding error: ' . json_last_error_msg()); } @@ -452,6 +475,32 @@ private function lineIterator(): Generator } } + /** + * @param list $params + */ + private function optionalIntParam(array $params, int $index, int $default): int + { + $value = $params[$index] ?? $default; + if (!is_int($value)) { + throw new Exception("Parameter #{$index} must be an integer."); + } + + return $value; + } + + /** + * @param list $params + */ + private function optionalStringParam(array $params, int $index, string $default): string + { + $value = $params[$index] ?? $default; + if (!is_string($value)) { + throw new Exception("Parameter #{$index} must be a string."); + } + + return $value; + } + /** * Iterates over the file line by line, applying the given regex pattern to each line. * @@ -477,16 +526,43 @@ private function regexIterator(string $pattern): Generator } /** - * Resets the internal state of the file reader. - * - * This function is used to reset the internal state of the file reader to - * its initial state. It rewinds the file to its beginning and resets the - * count and position. + * @param list $params */ - private function reset(): void + private function requireStringParam(array $params, int $index, string $name): string { - $this->file->rewind(); - $this->resetPosition(); + $value = $params[$index] ?? null; + if (!is_string($value) || $value === '') { + throw new Exception("Missing or invalid {$name}."); + } + + return $value; + } + + /** + * @param list $params + * @return list + */ + private function requireWidthsParam(array $params): array + { + $value = $params[0] ?? null; + if (!is_array($value)) { + throw new Exception('Missing fixed-width field definitions.'); + } + + $widths = []; + foreach ($value as $width) { + if (!is_int($width) || $width < 1) { + throw new Exception('Fixed-width definitions must be positive integers.'); + } + + $widths[] = $width; + } + + if ($widths === []) { + throw new Exception('At least one fixed-width field definition is required.'); + } + + return $widths; } /** @@ -525,13 +601,15 @@ private function resolveReadablePath(): string $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_reader_'); if ($tempFile === false) { fclose($stream); + throw new FileAccessException("Cannot access file at path: {$this->filename}"); } $target = fopen($tempFile, 'wb'); if (!is_resource($target)) { fclose($stream); - @unlink($tempFile); + $this->unlinkPathSilently($tempFile); + throw new FileAccessException("Cannot access file at path: {$this->filename}"); } @@ -567,6 +645,21 @@ private function serializedIterator(): Generator } } + private function unlinkPathSilently(string $path): void + { + if (!is_file($path)) { + return; + } + + set_error_handler(static fn(): bool => true); + + try { + unlink($path); + } finally { + restore_error_handler(); + } + } + /** * Reads an XML file and yields each element with the given name. * diff --git a/src/FileManager/SafeFileWriter.php b/src/FileManager/SafeFileWriter.php index 0a7d19e..97e3156 100644 --- a/src/FileManager/SafeFileWriter.php +++ b/src/FileManager/SafeFileWriter.php @@ -1,5 +1,7 @@ $widths) Fixed-width field iterator * @method SafeFileReader xml(string $element) XML iterator * @method SafeFileReader serialized() Serialized object iterator * @method SafeFileReader jsonArray() JSON array iterator */ class SafeFileWriter implements Countable, Stringable, JsonSerializable { + use SafeFileWriterWriteConcern; + private ?string $atomicTempFilePath = null; + private bool $atomicWriteEnabled = false; + private bool $cleanupLocalWorkingPath = false; + private ?SplFileObject $file = null; + private bool $isLocked = false; + private ?string $localWorkingPath = null; + private bool $syncBackOnClose = false; + private int $writeCount = 0; + + /** @var array */ private array $writeTypesCount = []; /** @@ -60,7 +73,7 @@ public function __destruct() // Never throw from destructors. } finally { if ($this->cleanupLocalWorkingPath && is_string($this->localWorkingPath) && is_file($this->localWorkingPath)) { - @unlink($this->localWorkingPath); + $this->unlinkPathSilently($this->localWorkingPath); } } } @@ -75,26 +88,41 @@ public function __destruct() * finally releases the lock. * * @param string $type The type of write operation to perform. - * @param array $params The parameters to be passed to the specific write operation. + * @param list $params The parameters to be passed to the specific write operation. * @throws Exception If the specified write type is unknown. */ - public function __call(string $type, array $params) + public function __call(string $type, array $params): mixed { $this->initiate($this->append ? 'a' : 'w'); $returnable = match ($type) { - 'character' => $this->writeCharacter(...$params), - 'line' => $this->writeLine(...$params), - 'csv' => $this->writeCSV(...$params), - 'binary' => $this->writeBinary(...$params), - 'json' => $this->writeJSON(...$params), - 'regex' => $this->writePatternMatch(...$params), - 'fixedWidth' => $this->writeFixedWidth(...$params), - 'xml' => $this->writeXML(...$params), - 'serialized' => $this->writeSerialized(...$params), - 'jsonArray' => $this->writeJSONArray(...$params), + 'character' => $this->writeCharacter($this->requireStringParam($params, 0, $type)), + 'line' => $this->writeLine($this->requireStringParam($params, 0, $type)), + 'csv' => $this->writeCSV( + $this->requireCsvRowParam($params, 0, $type), + $this->optionalStringParam($params, 1, ','), + $this->optionalStringParam($params, 2, '"'), + $this->optionalStringParam($params, 3, '\\'), + ), + 'binary' => $this->writeBinary($this->requireStringParam($params, 0, $type)), + 'json' => $this->writeJSON($params[0] ?? null, $this->optionalBoolParam($params, 1, false)), + 'regex' => $this->writePatternMatch( + $this->requireStringParam($params, 0, $type), + $this->requireStringParam($params, 1, $type), + ), + 'fixedWidth' => $this->writeFixedWidth( + $this->requireFixedWidthDataParam($params, 0, $type), + $this->requireWidthsParam($params, 1, $type), + ), + 'xml' => $this->writeXML($this->requireXmlParam($params, 0, $type)), + 'serialized' => $this->writeSerialized($params[0] ?? null), + 'jsonArray' => $this->writeJSONArray( + $this->requireArrayParam($params, 0, $type), + $this->optionalBoolParam($params, 1, false), + ), default => throw new Exception("Unknown write type '$type'"), }; $this->trackWriteType($type); + return $returnable; } @@ -109,7 +137,7 @@ public function __call(string $type, array $params) public function __toString(): string { return sprintf( - "SafeFileWriter [File: %s, Size: %d bytes, Writes: %d]", + 'SafeFileWriter [File: %s, Size: %d bytes, Writes: %d]', $this->filename, $this->getSize(), $this->writeCount, @@ -245,7 +273,7 @@ public function getSize(): int * - `modificationDate`: The last modification date in ISO 8601 format. * - `creationDate`: The creation date in ISO 8601 format. * - * @return array The associative array to be JSON serialized. + * @return array The associative array to be JSON serialized. */ public function jsonSerialize(): array { @@ -270,13 +298,19 @@ public function jsonSerialize(): array */ public function lock(int $lockType = LOCK_EX, bool $waitForLock = false, int $retries = 5, int $delay = 200): void { + if (!in_array($lockType, [LOCK_EX, LOCK_SH], true)) { + throw new FileAccessException("Invalid lock type for file {$this->filename}."); + } + $this->initiate($this->append ? 'a' : 'w'); + $file = $this->requireFileHandle(); $attempt = 0; do { $lockMode = $waitForLock ? $lockType : $lockType | LOCK_NB; - if ($this->file->flock($lockMode)) { + if ($file->flock($lockMode)) { $this->isLocked = true; + return; } if (!$waitForLock) { @@ -406,16 +440,17 @@ private function finalizeAtomicWrite(): void if (!is_file($this->atomicTempFilePath)) { $this->atomicTempFilePath = null; + return; } if ($this->isRemoteTarget()) { $this->localWorkingPath ??= $this->createLocalTempFile('pathwise_writer_sync_'); - if (!@rename($this->atomicTempFilePath, $this->localWorkingPath)) { - if (!@copy($this->atomicTempFilePath, $this->localWorkingPath)) { + if (!$this->runSilently(fn(): bool => rename($this->atomicTempFilePath, $this->localWorkingPath))) { + if (!$this->runSilently(fn(): bool => copy($this->atomicTempFilePath, $this->localWorkingPath))) { throw new FileAccessException("Failed to finalize atomic write for {$this->filename}"); } - @unlink($this->atomicTempFilePath); + $this->unlinkPathSilently($this->atomicTempFilePath); } $this->syncBackOnClose = true; $this->atomicTempFilePath = null; @@ -423,11 +458,11 @@ private function finalizeAtomicWrite(): void return; } - if (!@rename($this->atomicTempFilePath, $this->filename)) { - if (!@copy($this->atomicTempFilePath, $this->filename)) { + if (!$this->runSilently(fn(): bool => rename($this->atomicTempFilePath, $this->filename))) { + if (!$this->runSilently(fn(): bool => copy($this->atomicTempFilePath, $this->filename))) { throw new FileAccessException("Failed to finalize atomic write for {$this->filename}"); } - @unlink($this->atomicTempFilePath); + $this->unlinkPathSilently($this->atomicTempFilePath); } $this->atomicTempFilePath = null; @@ -468,7 +503,7 @@ private function initiate(string $mode = 'w'): void if (!$this->file) { $targetFile = $this->resolveTargetFilePath(); if (!$this->isRemoteTarget() && !is_writable(dirname($targetFile)) && !file_exists($targetFile)) { - throw new FileAccessException("Cannot write to directory: " . dirname($targetFile)); + throw new FileAccessException('Cannot write to directory: ' . dirname($targetFile)); } $this->file = new SplFileObject($targetFile, $mode); } @@ -494,6 +529,7 @@ private function preloadRemoteAppendSourceIfNeeded(): void if (is_resource($target)) { fclose($target); } + throw new FileAccessException("Cannot write to file: {$this->filename}"); } @@ -530,6 +566,17 @@ private function resolveTargetFilePath(): string return $this->atomicTempFilePath; } + private function runSilently(callable $operation): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + private function syncWorkingCopyBack(): void { if (!$this->syncBackOnClose || !is_string($this->localWorkingPath) || !is_file($this->localWorkingPath)) { @@ -548,204 +595,12 @@ private function syncWorkingCopyBack(): void } } - /** - * Tracks the number of times a write type is called. - * - * @param string $type The type of write (e.g. 'character', 'line', 'csv', etc.). - */ - private function trackWriteType(string $type): void - { - $type = strtolower($type); - if (!isset($this->writeTypesCount[$type])) { - $this->writeTypesCount[$type] = 0; - } - $this->writeTypesCount[$type]++; - } - - /** - * Writes a string of binary data to the file. - * - * This function takes a string of binary data and writes it to the file. - * The write count is incremented after writing the data. - * - * @param string $data The binary data to write. - * @return int|false The number of bytes written, or false on failure. - */ - private function writeBinary(string $data): int|false + private function unlinkPathSilently(string $path): void { - $this->writeCount++; - return $this->file->fwrite($data); - } - - /** - * Writes a single character to the file. - * - * This function takes a single character and writes it to the file. - * The write count is incremented after writing the data. - * - * @param string $char The character to write to the file. - * @return int|false The number of bytes written, or false on failure. - */ - private function writeCharacter(string $char): int|false - { - $this->writeCount++; - return $this->file->fwrite($char); - } - - /** - * Writes a row of data to the file in CSV format. - * - * This function takes an array of data and writes it to the file - * as a CSV line using the specified separator, enclosure, and - * escape characters. It increments the write count after writing. - * - * @param array $row The data to write as a CSV line. - * @param string $separator The character used to separate fields. Defaults to ','. - * @param string $enclosure The character used to enclose fields. Defaults to '"'. - * @param string $escape The character used to escape special characters. Defaults to '\\'. - * @return int|false The number of bytes written, or false on failure. - */ - private function writeCSV( - array $row, - string $separator = ",", - string $enclosure = "\"", - string $escape = "\\", - ): int|false { - $this->writeCount++; - return $this->file->fputcsv($row, $separator, $enclosure, $escape); - } - - /** - * Writes a line of fixed-width fields to the file. - * - * The given $data array is padded and written to the file, with each - * element padded to the corresponding width in the $widths array. - * - * @param array $data The data to write. Each element is written as a string. - * @param array $widths The widths of each field. Each element is a positive integer. - * @return int|false The number of bytes written, or false on failure. - * @throws Exception If the count of $data does not match the count of $widths. - */ - private function writeFixedWidth(array $data, array $widths): int|false - { - if (count($data) !== count($widths)) { - throw new Exception("Data and widths arrays must match."); - } - $line = ''; - foreach ($data as $index => $field) { - $line .= str_pad((string) $field, $widths[$index]); - } - $this->writeCount++; - return $this->file->fwrite($line . PHP_EOL); - } - - /** - * Writes JSON data to the file. - * - * This function encodes the provided data as JSON and writes it to the file. - * Optionally, it can format the JSON with indentation and whitespace for readability. - * - * @param mixed $data The data to encode as JSON and write. - * @param bool $prettyPrint If true, the JSON will be formatted for readability. Defaults to false. - * @return int|false The number of bytes written, or false on failure. - * @throws Exception If JSON encoding fails. - */ - private function writeJSON(mixed $data, bool $prettyPrint = false): int|false - { - $jsonOptions = $prettyPrint ? JSON_PRETTY_PRINT : 0; - $jsonData = json_encode($data, $jsonOptions); - if ($jsonData === false) { - throw new Exception("JSON encoding failed: " . json_last_error_msg()); - } - $this->writeCount++; - return $this->file->fwrite($jsonData . PHP_EOL); - } - - /** - * Writes a JSON array to the file. - * - * @param array $data The array of data to write. - * @param bool $prettyPrint If true, the JSON will be formatted with - * indentation and whitespace for readability. Defaults to false. - * @return int|false The number of bytes written, or false on failure. - * @throws Exception If the JSON encoding fails. - */ - private function writeJSONArray(array $data, bool $prettyPrint = false): int|false - { - $jsonOptions = $prettyPrint ? JSON_PRETTY_PRINT : 0; - $jsonData = json_encode($data, $jsonOptions); - if ($jsonData === false) { - throw new Exception("JSON encoding failed: " . json_last_error_msg()); - } - $this->writeCount++; - return $this->file->fwrite($jsonData . PHP_EOL); - } - - /** - * Writes a line of text to the file. - * - * This function takes a string of content and writes it to the file, - * appending a newline character at the end. - * The write count is incremented after writing the data. - * - * @param string $content The content to write to the file. - * @return int|false The number of bytes written, or false on failure. - */ - private function writeLine(string $content): int|false - { - $this->writeCount++; - return $this->file->fwrite($content . PHP_EOL); - } - - /** - * Writes the given content to the file if it matches the specified pattern. - * - * This function checks if the provided content matches the given regex pattern. - * If a match is found, the content is written to the file with a newline appended. - * The write count is incremented each time content is successfully written. - * - * @param string $content The content to be checked and potentially written. - * @param string $pattern The regex pattern to match against the content. - * @return int|false The number of bytes written, or false on failure. - */ - private function writePatternMatch(string $content, string $pattern): int|false - { - if (preg_match($pattern, $content)) { - $this->writeCount++; - return $this->file->fwrite($content . PHP_EOL); + if (!is_file($path)) { + return; } - return false; - } - /** - * Writes a serialized representation of the given data to the file. - * - * The `serialize` function is used to convert the data into a string - * representation. The resulting string is then written to the file, - * followed by a newline. - * - * @param mixed $data The data to serialize and write. - * @return int|false The number of bytes written, or false on failure. - */ - private function writeSerialized(mixed $data): int|false - { - $serializedData = serialize($data); - $this->writeCount++; - return $this->file->fwrite($serializedData . PHP_EOL); - } - - /** - * Writes an XML element to the file. - * - * This function takes a SimpleXMLElement, converts it to an XML string, - * and writes it to the file, appending a newline character. - * - * @param SimpleXMLElement $element The XML element to write. - * @return int|false The number of bytes written, or false on failure. - */ - private function writeXML(SimpleXMLElement $element): int|false - { - $this->writeCount++; - return $this->file->fwrite($element->asXML() . PHP_EOL); + $this->runSilently(static fn(): bool => unlink($path)); } } diff --git a/src/Indexing/ChecksumIndexer.php b/src/Indexing/ChecksumIndexer.php index 54f5245..1bc4f8f 100644 --- a/src/Indexing/ChecksumIndexer.php +++ b/src/Indexing/ChecksumIndexer.php @@ -1,5 +1,7 @@ , skipped: list} Array with linked and skipped file paths. */ public static function deduplicateWithHardLinks(string $directory, string $algorithm = 'sha256'): array { @@ -57,20 +59,22 @@ public static function deduplicateWithHardLinks(string $directory, string $algor foreach ($paths as $path) { if (!is_string($canonical) || !self::isLocalFile($canonical) || !self::isLocalFile($path)) { $skipped[] = $path; + continue; } $tmp = $path . '.tmp_delete'; - if (!@rename($path, $tmp)) { + if (!self::runSilently(static fn(): bool => rename($path, $tmp))) { $skipped[] = $path; + continue; } - if (@link($canonical, $path)) { - @unlink($tmp); + if (self::runSilently(static fn(): bool => link($canonical, $path))) { + self::unlinkSilently($tmp); $linked[] = $path; } else { - @rename($tmp, $path); + self::runSilently(static fn(): bool => rename($tmp, $path)); $skipped[] = $path; } } @@ -125,7 +129,7 @@ private static function isLocalFile(string $path): bool } /** - * @return array + * @return list */ private static function iterFiles(string $directory): array { @@ -137,7 +141,7 @@ private static function iterFiles(string $directory): array } /** - * @return array + * @return list */ private static function iterFilesLocal(string $directory): array { @@ -152,6 +156,10 @@ private static function iterFilesLocal(string $directory): array ); foreach ($iterator as $item) { + if (!$item instanceof \SplFileInfo) { + continue; + } + if ($item->isDir()) { continue; } @@ -163,7 +171,7 @@ private static function iterFilesLocal(string $directory): array } /** - * @return array + * @return list */ private static function iterFilesViaFlysystem(string $directory): array { @@ -176,7 +184,12 @@ private static function iterFilesViaFlysystem(string $directory): array continue; } - $itemPath = trim((string) ($item['path'] ?? ''), '/'); + $itemPathRaw = $item['path'] ?? null; + if (!is_string($itemPathRaw)) { + continue; + } + + $itemPath = trim($itemPathRaw, '/'); if ($itemPath === '') { continue; } @@ -193,4 +206,24 @@ private static function iterFilesViaFlysystem(string $directory): array return $paths; } + + private static function runSilently(callable $operation): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + + private static function unlinkSilently(string $path): void + { + if (!is_file($path)) { + return; + } + + self::runSilently(static fn(): bool => unlink($path)); + } } diff --git a/src/Native/NativeCommandRunner.php b/src/Native/NativeCommandRunner.php index 2588e71..989702f 100644 --- a/src/Native/NativeCommandRunner.php +++ b/src/Native/NativeCommandRunner.php @@ -1,5 +1,7 @@ NUL 2>&1" - : "command -v " . escapeshellarg($command) . " >/dev/null 2>&1"; + ? 'where ' . escapeshellcmd($command) . ' >NUL 2>&1' + : 'command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1'; $result = self::run($lookup); return $result['success']; } + /** * @return array{success: bool, output: array, code: int} */ diff --git a/src/Native/NativeOperationsAdapter.php b/src/Native/NativeOperationsAdapter.php index 027da88..e013d59 100644 --- a/src/Native/NativeOperationsAdapter.php +++ b/src/Native/NativeOperationsAdapter.php @@ -1,5 +1,7 @@ $context Additional context data. */ public function log(string $operation, array $context = []): void { diff --git a/src/PathwiseFacade.php b/src/PathwiseFacade.php index f6b9158..b0c4ecf 100644 --- a/src/PathwiseFacade.php +++ b/src/PathwiseFacade.php @@ -1,5 +1,7 @@ + * @phpstan-type DiffReport array{created: list, modified: list, deleted: list} + */ final class PathwiseFacade { /** @@ -71,19 +78,24 @@ public static function createFilesystem(array $config): FilesystemOperator * * @param string $directory The directory to deduplicate. * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'. - * @return array{linked: array, skipped: array} Array with linked and skipped file paths. + * @return array{linked: list, skipped: list} Array with linked and skipped file paths. */ public static function deduplicate(string $directory, string $algorithm = 'sha256'): array { - return ChecksumIndexer::deduplicateWithHardLinks($directory, $algorithm); + $result = ChecksumIndexer::deduplicateWithHardLinks($directory, $algorithm); + + return [ + 'linked' => self::normalizeStringList($result['linked']), + 'skipped' => self::normalizeStringList($result['skipped']), + ]; } /** * Compare two snapshots and return the differences. * - * @param array $previousSnapshot The previous snapshot data. - * @param array $currentSnapshot The current snapshot data. - * @return array{created: array, modified: array, deleted: array} The diff report. + * @param SnapshotMap $previousSnapshot The previous snapshot data. + * @param SnapshotMap $currentSnapshot The current snapshot data. + * @return DiffReport The diff report. */ public static function diffSnapshots(array $previousSnapshot, array $currentSnapshot): array { @@ -185,7 +197,7 @@ public static function queue(string $queueFilePath): FileJobQueue * @param int|null $keepLast Number of most recent files to keep (null for unlimited). * @param int|null $maxAgeDays Maximum age of files in days (null for unlimited). * @param string $sortBy Field to sort by ('mtime' or 'ctime'). - * @return array{deleted: array, kept: array} Array with deleted and kept file paths. + * @return array{deleted: list, kept: list} Array with deleted and kept file paths. */ public static function retain( string $directory, @@ -193,7 +205,12 @@ public static function retain( ?int $maxAgeDays = null, string $sortBy = 'mtime', ): array { - return RetentionManager::apply($directory, $keepLast, $maxAgeDays, $sortBy); + $result = RetentionManager::apply($directory, $keepLast, $maxAgeDays, $sortBy); + + return [ + 'deleted' => self::normalizeStringList($result['deleted']), + 'kept' => self::normalizeStringList($result['kept']), + ]; } /** @@ -283,11 +300,11 @@ public function file(): FileOperations * Get metadata for this file or directory. * * @param bool $humanReadableSize If true, return size in human-readable format. - * @return array|null The metadata array, or null if the path doesn't exist. + * @return array|null The metadata array, or null if the path doesn't exist. */ public function metadata(bool $humanReadableSize = false): ?array { - return MetadataHelper::getAllMetadata($this->path, $humanReadableSize); + return self::normalizeStringMap(MetadataHelper::getAllMetadata($this->path, $humanReadableSize)); } /** @@ -332,4 +349,47 @@ public function writer(bool $append = false): SafeFileWriter { return new SafeFileWriter($this->path, $append); } + + /** + * @return list + */ + private static function normalizeStringList(mixed $values): array + { + if (!is_array($values)) { + return []; + } + + $result = []; + foreach ($values as $value) { + if (!is_string($value)) { + continue; + } + + $result[] = $value; + } + + return $result; + } + + /** + * @param array|null $values + * @return array|null + */ + private static function normalizeStringMap(?array $values): ?array + { + if ($values === null) { + return null; + } + + $result = []; + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $result[$key] = $value; + } + + return $result; + } } diff --git a/src/Queue/FileJobQueue.php b/src/Queue/FileJobQueue.php index fde4036..7facbc5 100644 --- a/src/Queue/FileJobQueue.php +++ b/src/Queue/FileJobQueue.php @@ -1,10 +1,28 @@ , + * priority: int, + * createdAt: int, + * error?: string, + * failedAt?: int + * } + * @phpstan-type QueueState array{ + * pending: list, + * processing: list, + * failed: list + * } + */ final readonly class FileJobQueue { public function __construct(private string $queueFilePath) @@ -22,7 +40,7 @@ public function __construct(private string $queueFilePath) * Add a job to the queue. * * @param string $type The job type. - * @param array $payload The job payload data. + * @param array $payload The job payload data. * @param int $priority The job priority (higher is more important). * @return string The job ID. */ @@ -47,7 +65,7 @@ public function enqueue(string $type, array $payload = [], int $priority = 0): s /** * Process jobs from the queue. * - * @param callable $handler Callback to process each job. Receives job array as argument. + * @param callable(QueueJob): void $handler Callback to process each job. * @param int $maxJobs Maximum number of jobs to process (0 for unlimited). * @return array{processed: int, failed: int} Array with processed and failed counts. */ @@ -99,7 +117,7 @@ public function process(callable $handler, int $maxJobs = 0): array /** * Get queue statistics. * - * @return array Array with pending, processing, failed counts and file path. + * @return array{pending: int, processing: int, failed: int, file: string} */ public function stats(): array { @@ -113,6 +131,95 @@ public function stats(): array ]; } + /** + * @return QueueJob|null + */ + private function normalizeJob(mixed $value): ?array + { + if (!is_array($value)) { + return null; + } + + $id = $value['id'] ?? null; + $type = $value['type'] ?? null; + $payload = $this->normalizePayload($value['payload'] ?? []); + $priority = $value['priority'] ?? 0; + $createdAt = $value['createdAt'] ?? time(); + if (!is_string($id) || !is_string($type)) { + return null; + } + + if ((!is_int($priority) && !is_numeric($priority)) || (!is_int($createdAt) && !is_numeric($createdAt))) { + return null; + } + + $job = [ + 'id' => $id, + 'type' => $type, + 'payload' => $payload, + 'priority' => (int) $priority, + 'createdAt' => (int) $createdAt, + ]; + + $error = $value['error'] ?? null; + if (is_string($error) && $error !== '') { + $job['error'] = $error; + } + + $failedAt = $value['failedAt'] ?? null; + if (is_int($failedAt) || is_numeric($failedAt)) { + $job['failedAt'] = (int) $failedAt; + } + + return $job; + } + + /** + * @return list + */ + private function normalizeJobList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $jobs = []; + foreach ($value as $rawJob) { + $job = $this->normalizeJob($rawJob); + if ($job === null) { + continue; + } + + $jobs[] = $job; + } + + return $jobs; + } + + /** + * @return array + */ + private function normalizePayload(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $payload = []; + foreach ($value as $key => $item) { + if (!is_string($key)) { + continue; + } + + $payload[$key] = $item; + } + + return $payload; + } + + /** + * @return QueueState + */ private function readQueueData(): array { if (!FlysystemHelper::fileExists($this->queueFilePath)) { @@ -130,12 +237,15 @@ private function readQueueData(): array } return [ - 'pending' => $decoded['pending'] ?? [], - 'processing' => $decoded['processing'] ?? [], - 'failed' => $decoded['failed'] ?? [], + 'pending' => $this->normalizeJobList($decoded['pending'] ?? []), + 'processing' => $this->normalizeJobList($decoded['processing'] ?? []), + 'failed' => $this->normalizeJobList($decoded['failed'] ?? []), ]; } + /** + * @param QueueState $data + */ private function writeQueueData(array $data): void { FlysystemHelper::write($this->queueFilePath, (string) json_encode($data, JSON_PRETTY_PRINT)); diff --git a/src/Retention/RetentionManager.php b/src/Retention/RetentionManager.php index 523663e..48c8376 100644 --- a/src/Retention/RetentionManager.php +++ b/src/Retention/RetentionManager.php @@ -1,5 +1,7 @@ , kept: list} Array with deleted and kept file paths. */ public static function apply( string $directory, @@ -39,8 +41,8 @@ public static function apply( foreach ($files as $index => $file) { $shouldDeleteByCount = $keepLast !== null && $index >= $keepLast; - $shouldDeleteByAge = $cutoff !== null && ($file[$sortBy] ?? 0) < $cutoff; - $path = (string) $file['path']; + $shouldDeleteByAge = $cutoff !== null && $file[$sortBy] < $cutoff; + $path = $file['path']; if (($shouldDeleteByCount || $shouldDeleteByAge) && FlysystemHelper::fileExists($path)) { FlysystemHelper::delete($path); @@ -84,6 +86,10 @@ private static function collectFilesLocal(string $directory): array ); foreach ($iterator as $item) { + if (!$item instanceof \SplFileInfo) { + continue; + } + if ($item->isDir()) { continue; } @@ -91,7 +97,7 @@ private static function collectFilesLocal(string $directory): array $files[] = [ 'path' => $item->getPathname(), 'mtime' => (int) $item->getMTime(), - 'ctime' => (int) $item->getCTime(), + 'ctime' => $item->getCTime(), ]; } @@ -108,32 +114,54 @@ private static function collectFilesViaFlysystem(string $directory): array $base = trim(str_replace('\\', '/', $baseLocation), '/'); foreach (FlysystemHelper::listContents($directory, true) as $item) { - if (($item['type'] ?? null) !== 'file') { + $entry = self::normalizeFlysystemEntry($directory, $base, $item); + if ($entry === null) { continue; } - $itemPath = trim((string) ($item['path'] ?? ''), '/'); - if ($itemPath === '') { - continue; - } + $files[] = $entry; + } - $relative = $base !== '' && str_starts_with($itemPath, $base . '/') - ? substr($itemPath, strlen($base) + 1) - : ($itemPath === $base ? '' : $itemPath); - if ($relative === '') { - continue; - } + return $files; + } - $resolved = PathHelper::join($directory, $relative); - $mtime = (int) ($item['last_modified'] ?? 0); + /** + * @param array $item + * @return array{path: string, mtime: int, ctime: int}|null + */ + private static function normalizeFlysystemEntry(string $directory, string $base, array $item): ?array + { + $type = $item['type'] ?? null; + if (!is_string($type) || $type !== 'file') { + return null; + } - $files[] = [ - 'path' => $resolved, - 'mtime' => $mtime, - 'ctime' => $mtime, - ]; + $itemPathRaw = $item['path'] ?? null; + if (!is_string($itemPathRaw)) { + return null; } - return $files; + $itemPath = trim($itemPathRaw, '/'); + if ($itemPath === '') { + return null; + } + + $relative = $base !== '' && str_starts_with($itemPath, $base . '/') + ? substr($itemPath, strlen($base) + 1) + : ($itemPath === $base ? '' : $itemPath); + if ($relative === '') { + return null; + } + + $lastModified = $item['last_modified'] ?? 0; + $mtime = is_int($lastModified) + ? $lastModified + : (is_numeric($lastModified) ? (int) $lastModified : 0); + + return [ + 'path' => PathHelper::join($directory, $relative), + 'mtime' => $mtime, + 'ctime' => $mtime, + ]; } } diff --git a/src/Security/PolicyEngine.php b/src/Security/PolicyEngine.php index c898f84..965e8bc 100644 --- a/src/Security/PolicyEngine.php +++ b/src/Security/PolicyEngine.php @@ -1,5 +1,7 @@ + * @var array): bool)|null + * }> */ private array $rules = []; @@ -36,7 +43,7 @@ public function allow(string $operation, string $pattern = '*', ?callable $condi * * @param string $operation The operation to check. * @param string $path The path to check. - * @param array $context Additional context for condition evaluation. + * @param array $context Additional context for condition evaluation. * @throws PolicyViolationException If the operation is not allowed. */ public function assertAllowed(string $operation, string $path, array $context = []): void @@ -71,7 +78,7 @@ public function deny(string $operation, string $pattern = '*', ?callable $condit * * @param string $operation The operation to check. * @param string $path The path to check. - * @param array $context Additional context for condition evaluation. + * @param array $context Additional context for condition evaluation. * @return bool True if allowed, false otherwise. */ public function isAllowed(string $operation, string $path, array $context = []): bool diff --git a/src/Storage/StorageFactory.php b/src/Storage/StorageFactory.php index 7ae455d..921dd2d 100644 --- a/src/Storage/StorageFactory.php +++ b/src/Storage/StorageFactory.php @@ -1,5 +1,7 @@ 'ziparchive', 'zip-archive' => 'ziparchive', ]; + /** - * @var array + * @var array */ private const array OFFICIAL_DRIVERS = [ 'local' => [ @@ -144,7 +147,7 @@ public static function createFilesystem(array $config): FilesystemOperator /** * Get the names of all registered custom drivers. * - * @return array The driver names. + * @return list The driver names. */ public static function driverNames(): array { @@ -203,7 +206,7 @@ public static function mountMany(array $mounts): void /** * Get all official driver metadata. * - * @return array The official drivers. + * @return array The official drivers. */ public static function officialDrivers(): array { @@ -266,12 +269,7 @@ private static function createFromRegisteredDriver(string $driver, array $config return null; } - $filesystem = self::$drivers[$driver]($config); - if (!$filesystem instanceof FilesystemOperator) { - throw new \InvalidArgumentException("Driver '{$driver}' factory must return a FilesystemOperator."); - } - - return $filesystem; + return self::$drivers[$driver]($config); } /** @@ -279,8 +277,8 @@ private static function createFromRegisteredDriver(string $driver, array $config */ private static function createLocalFilesystem(array $config): FilesystemOperator { - $root = (string) ($config['root'] ?? ''); - if ($root === '') { + $root = $config['root'] ?? null; + if (!is_string($root) || $root === '') { throw new \InvalidArgumentException('Local driver requires a non-empty "root" path.'); } @@ -306,6 +304,10 @@ private static function createLocalFilesystemFromConfig(array $config): Filesyst private static function createOfficialFilesystem(string $driver, array $config): FilesystemOperator { $driver = self::canonicalDriverName($driver); + if ($driver === 'local') { + return self::createLocalFilesystem($config); + } + $metadata = self::OFFICIAL_DRIVERS[$driver] ?? null; if ($metadata === null) { throw new \InvalidArgumentException("Unsupported official storage driver '{$driver}'."); @@ -323,10 +325,15 @@ private static function createOfficialFilesystem(string $driver, array $config): . "Install it and provide either 'adapter' or 'constructor' config.", ); } - if ($driver === 'inmemory') { - /** @var FilesystemAdapter $adapter */ - $adapter = new $adapterClass(); + $reflection = new \ReflectionClass($adapterClass); + $required = $reflection->getConstructor()?->getNumberOfRequiredParameters() ?? 0; + if ($required > 0) { + throw new \InvalidArgumentException( + "Storage driver '{$driver}' requires explicit constructor config.", + ); + } + $adapter = $reflection->newInstance(); return new Filesystem($adapter, self::resolveOptions($config)); } @@ -339,7 +346,7 @@ private static function createOfficialFilesystem(string $driver, array $config): } $arguments = array_is_list($constructor) ? $constructor : array_values($constructor); - $adapter = new $adapterClass(...$arguments); + $adapter = new \ReflectionClass($adapterClass)->newInstanceArgs($arguments); return new Filesystem($adapter, self::resolveOptions($config)); } @@ -371,7 +378,12 @@ private static function resolveAdapter(array $config): ?FilesystemAdapter */ private static function resolveDriver(array $config): string { - $driver = self::canonicalDriverName((string) ($config['driver'] ?? 'local')); + $driverInput = $config['driver'] ?? 'local'; + if (!is_string($driverInput)) { + throw new \InvalidArgumentException('Storage "driver" must be a non-empty string.'); + } + + $driver = self::canonicalDriverName($driverInput); if ($driver === '') { throw new \InvalidArgumentException('Storage "driver" must be a non-empty string.'); } @@ -390,7 +402,16 @@ private static function resolveOptions(array $config): array throw new \InvalidArgumentException('Storage "options" must be an array.'); } - return $options; + $normalized = []; + foreach ($options as $key => $value) { + if (!is_string($key)) { + continue; + } + + $normalized[$key] = $value; + } + + return $normalized; } /** diff --git a/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php b/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php new file mode 100644 index 0000000..b6ca8f7 --- /dev/null +++ b/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php @@ -0,0 +1,255 @@ +, + * createdAt: int + * } + */ +trait UploadProcessorChunkConcern +{ + private function appendChunkToStream(string $chunkPath, mixed $output, int $index): void + { + if (!is_resource($output)) { + throw new UploadException("Invalid merge stream for chunk index {$index}."); + } + + $input = FlysystemHelper::readStream($chunkPath); + if (!is_resource($input)) { + throw new UploadException("Failed to read chunk index {$index}."); + } + + try { + stream_copy_to_stream($input, $output); + } finally { + fclose($input); + } + } + + /** + * @param array $received + */ + private function cleanupChunkUploadArtifacts(string $uploadId, string $chunkDirectory, array $received): void + { + foreach ($received as $chunkName) { + $chunkPath = PathHelper::join($chunkDirectory, $chunkName); + if (FlysystemHelper::fileExists($chunkPath)) { + FlysystemHelper::delete($chunkPath); + } + } + + $manifestPath = $this->getChunkManifestPath($uploadId); + if (FlysystemHelper::fileExists($manifestPath)) { + FlysystemHelper::delete($manifestPath); + } + if (FlysystemHelper::directoryExists($chunkDirectory)) { + FlysystemHelper::deleteDirectory($chunkDirectory); + } + } + + private function getChunkDirectory(string $uploadId): string + { + $safeUploadId = preg_replace('/[^A-Za-z0-9_\-]/', '', $uploadId) ?: 'upload'; + $baseTemp = $this->tempDir ? rtrim($this->tempDir, '/\\') : sys_get_temp_dir(); + + return PathHelper::join($baseTemp, 'pathwise_chunks', $safeUploadId); + } + + private function getChunkManifestPath(string $uploadId): string + { + return PathHelper::join($this->getChunkDirectory($uploadId), 'manifest.json'); + } + + /** + * @return ChunkManifest|null + */ + private function loadChunkManifest(string $uploadId): ?array + { + $path = $this->getChunkManifestPath($uploadId); + if (!FlysystemHelper::fileExists($path)) { + return null; + } + + $content = FlysystemHelper::read($path); + + $manifest = json_decode($content, true); + if (!is_array($manifest)) { + throw new UploadException('Invalid chunk manifest.'); + } + + $receivedRaw = $manifest['received'] ?? null; + if (!is_array($receivedRaw)) { + throw new UploadException('Invalid chunk manifest.'); + } + + $received = []; + foreach ($receivedRaw as $chunkIndex => $chunkName) { + if (!is_string($chunkName)) { + throw new UploadException('Invalid chunk manifest.'); + } + + $received[$chunkIndex] = $chunkName; + } + + $originalFilename = $manifest['originalFilename'] ?? null; + $storedUploadId = $manifest['uploadId'] ?? $uploadId; + $createdAt = $manifest['createdAt'] ?? time(); + $totalChunks = $manifest['totalChunks'] ?? null; + + if (!is_string($originalFilename) || !is_string($storedUploadId)) { + throw new UploadException('Invalid chunk manifest.'); + } + + if (!is_int($createdAt) && !is_numeric($createdAt)) { + throw new UploadException('Invalid chunk manifest.'); + } + + if (!is_int($totalChunks) && !is_numeric($totalChunks)) { + throw new UploadException('Invalid chunk manifest.'); + } + + return [ + 'uploadId' => $storedUploadId, + 'originalFilename' => $originalFilename, + 'totalChunks' => (int) $totalChunks, + 'received' => $received, + 'createdAt' => (int) $createdAt, + ]; + } + + /** + * @param array $received + */ + private function mergeChunksToDestination(string $chunkDirectory, array $received, int $totalChunks, string $destination): void + { + $output = fopen('php://temp', 'rb+'); + if ($output === false) { + throw new UploadException('Failed to create destination file for chunk merge.'); + } + + /** @var resource $output */ + + try { + for ($i = 0; $i < $totalChunks; $i++) { + $chunkPath = $this->resolveChunkPath($chunkDirectory, $received, $i); + $this->appendChunkToStream($chunkPath, $output, $i); + } + + rewind($output); + FlysystemHelper::writeStream($destination, $output); + } finally { + fclose($output); + } + } + + /** + * @param array $received + */ + private function resolveChunkPath(string $chunkDirectory, array $received, int $index): string + { + $chunkName = $received[(string) $index] ?? null; + if (!is_string($chunkName)) { + throw new UploadException("Missing chunk index {$index}."); + } + + $chunkPath = PathHelper::join($chunkDirectory, $chunkName); + if (!FlysystemHelper::fileExists($chunkPath)) { + throw new UploadException("Missing chunk file for index {$index}."); + } + + return $chunkPath; + } + + /** + * @return array{0: ChunkManifest, 1: int, 2: array} + */ + private function resolveCompleteChunkState(string $uploadId): array + { + $manifest = $this->loadChunkManifest($uploadId); + if ($manifest === null) { + throw new UploadException("Upload session not found: {$uploadId}"); + } + + $totalChunks = $manifest['totalChunks']; + $received = $manifest['received']; + if ($totalChunks < 1 || count($received) !== $totalChunks) { + throw new UploadException('Upload is not complete.'); + } + if ($this->maxChunkCount > 0 && $totalChunks > $this->maxChunkCount) { + throw new UploadException('Total chunks exceed configured limit.'); + } + + ksort($received); + + return [$manifest, $totalChunks, $received]; + } + + /** + * @param ChunkManifest $manifest + */ + private function saveChunkManifest(string $uploadId, array $manifest): void + { + $path = $this->getChunkManifestPath($uploadId); + $json = json_encode($manifest, JSON_PRETTY_PRINT); + if ($json === false) { + throw new UploadException('Failed to persist chunk manifest.'); + } + + FlysystemHelper::write($path, $json); + } + + /** + * @param UploadInput $chunkFile + */ + private function validateChunkLimits(array $chunkFile, int $totalChunks): void + { + if ($this->maxChunkCount > 0 && $totalChunks > $this->maxChunkCount) { + throw new UploadException('Total chunks exceed configured limit.'); + } + + $chunkSize = $this->normalizeUploadSize($chunkFile['size']); + if ($this->maxChunkSize > 0 && $chunkSize > $this->maxChunkSize) { + throw new FileSizeExceededException('Chunk exceeds configured size limit.'); + } + } + + /** + * @param UploadInput $chunkFile + */ + private function validateChunkUploadRequest(array $chunkFile, string $uploadId, int $chunkIndex, int $totalChunks, string $originalFilename): void + { + $this->validateUploadId($uploadId); + + if ($chunkIndex < 0 || $totalChunks < 1 || $chunkIndex >= $totalChunks) { + throw new UploadException('Invalid chunk metadata.'); + } + + $this->validateChunkLimits($chunkFile, $totalChunks); + $this->validateFileExtension(pathinfo($originalFilename, PATHINFO_EXTENSION)); + } + + private function validateUploadId(string $uploadId): void + { + if ($uploadId === '' || strlen($uploadId) > 128 || preg_match('/^[A-Za-z0-9_-]+$/', $uploadId) !== 1) { + throw new UploadException('Invalid upload session id.'); + } + } +} diff --git a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php new file mode 100644 index 0000000..17286d1 --- /dev/null +++ b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php @@ -0,0 +1,456 @@ +unlinkFileSilently($tempFile); + + throw new UploadException('Unable to inspect image dimensions.'); + } + + stream_copy_to_stream($stream, $target); + fclose($stream); + fclose($target); + } + + /** + * Ensure the upload directory exists. + */ + private function ensureUploadDirectoryExists(): void + { + if (!FlysystemHelper::directoryExists($this->uploadDir)) { + FlysystemHelper::createDirectory($this->uploadDir); + } + } + + /** + * Get the MIME type of a file. + */ + private function getFileMimeType(string $filePath): string + { + $mimeType = MetadataHelper::getMimeType($filePath); + if ($mimeType === null) { + throw new UploadException('Unable to determine file MIME type.'); + } + + return $mimeType; + } + + /** + * Get a unique destination for the uploaded file. + */ + private function getUniqueDestination(string $fileName): string + { + $subDir = $this->useDateDirectories ? date('Y/m/d') : ''; + $destinationDir = $subDir !== '' + ? PathHelper::join($this->uploadDir, $subDir) + : $this->uploadDir; + + if (!FlysystemHelper::directoryExists($destinationDir)) { + FlysystemHelper::createDirectory($destinationDir); + } + + $destination = PathHelper::join($destinationDir, $fileName); + + if (FlysystemHelper::fileExists($destination)) { + throw new UploadException('File with the same name already exists.'); + } + + return $destination; + } + + /** + * Check if a file is an image. + */ + private function isImage(string $fileType): bool + { + return str_starts_with($fileType, 'image/'); + } + + private function moveIncomingFile(string $source, string $destination): void + { + if (is_uploaded_file($source)) { + if (move_uploaded_file($source, $destination)) { + return; + } + + $stream = fopen($source, 'rb'); + if (is_resource($stream)) { + try { + FlysystemHelper::writeStream($destination, $stream); + } finally { + fclose($stream); + } + + $this->unlinkFileSilently($source); + + return; + } + + throw new UploadException('Failed to move uploaded file.'); + } + + if (PathHelper::hasScheme($source) || PathHelper::hasScheme($destination)) { + try { + FlysystemHelper::copy($source, $destination); + } catch (\Throwable) { + throw new UploadException('Failed to move incoming file.'); + } + + FlysystemHelper::delete($source); + + return; + } + + if (!$this->runSilently(static fn(): bool => rename($source, $destination))) { + try { + FlysystemHelper::copy($source, $destination); + } catch (\Throwable) { + throw new UploadException('Failed to move incoming file.'); + } + FlysystemHelper::delete($source); + } + } + + private function normalizeExtension(string $extension): string + { + return strtolower(ltrim(trim($extension), '.')); + } + + /** + * @param array $extensions + * @return list + */ + private function normalizeExtensions(array $extensions): array + { + $normalized = []; + foreach ($extensions as $extension) { + $candidate = $this->normalizeExtension($extension); + if ($candidate === '') { + continue; + } + $normalized[] = $candidate; + } + + return array_values(array_unique($normalized)); + } + + private function normalizeUploadSize(int|string $size): int + { + if (is_int($size)) { + return $size; + } + + if (!is_numeric($size)) { + throw new UploadException('Invalid upload size metadata.'); + } + + return (int) $size; + } + + /** + * @return array{string, bool} + */ + private function prepareImagePathForInspection(string $filePath): array + { + if (!PathHelper::hasScheme($filePath) && is_file($filePath)) { + return [$filePath, false]; + } + + $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_img_'); + if ($tempFile === false) { + throw new UploadException('Unable to create temporary file for image validation.'); + } + + $this->copyImageToInspectionFile($filePath, $tempFile); + + return [$tempFile, true]; + } + + private function readHeaderBytes(string $filePath, int $length): ?string + { + $length = max(1, $length); + $stream = FlysystemHelper::readStream($filePath); + if (!is_resource($stream)) { + return null; + } + + try { + $bytes = fread($stream, $length); + } finally { + fclose($stream); + } + + return is_string($bytes) ? $bytes : null; + } + + private function runSilently(callable $operation): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return $operation(); + } finally { + restore_error_handler(); + } + } + + /** + * Sanitize a path to remove invalid characters. + */ + private function sanitizePath(string $path): string + { + $sanitized = preg_replace('/[^a-zA-Z0-9\/\\\\:_.-]/', '', $path) ?? ''; + + return rtrim($sanitized, '/\\'); + } + + private function scanForMalware(string $filePath, string $fileType): void + { + if (!is_callable($this->malwareScanner)) { + if ($this->requireMalwareScan) { + throw new UploadException('Malware scanner is required but not configured.'); + } + + return; + } + + try { + $result = ($this->malwareScanner)($filePath, $fileType); + } catch (\Throwable $e) { + throw new UploadException('Malware scanner failed: ' . $e->getMessage(), 0, $e); + } + + if ($result === false) { + throw new UploadException('Malware scan failed.'); + } + } + + private function unlinkFileSilently(string $path): void + { + if (!is_file($path)) { + return; + } + + $this->runSilently(static fn(): bool => unlink($path)); + } + + private function validateContentTypeIntegrity(string $filePath, string $fileType, string $extension): void + { + if (!$this->strictContentTypeValidation) { + return; + } + + $normalizedExtension = $this->normalizeExtension($extension); + if ($normalizedExtension === '') { + return; + } + + $this->validateMimeTypeMatchesExtension($fileType, $normalizedExtension); + $this->validateMagicSignatureForExtension($filePath, $normalizedExtension); + } + + /** + * Validate the uploaded file. + */ + /** + * @param array $file + */ + private function validateFile(array $file): void + { + $error = $file['error'] ?? null; + if (!is_int($error)) { + throw new UploadException('Invalid file upload parameters.'); + } + + switch ($error) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_NO_FILE: + throw new UploadException('No file sent.'); + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + throw new FileSizeExceededException('Exceeded file size limit.'); + default: + throw new UploadException('Unknown errors.'); + } + + $size = $file['size'] ?? null; + $tmpName = $file['tmp_name'] ?? null; + $name = $file['name'] ?? null; + if ((!is_int($size) && !is_string($size)) || !is_string($tmpName) || !is_string($name)) { + throw new UploadException('Invalid file upload parameters.'); + } + + $this->validateFileSize($this->normalizeUploadSize($size)); + } + + private function validateFileExtension(string $extension): void + { + $normalized = $this->normalizeExtension($extension); + if ($normalized === '') { + if ($this->allowedExtensions !== []) { + throw new UploadException('File extension is required.'); + } + + return; + } + + if (in_array($normalized, $this->blockedExtensions, true)) { + throw new UploadException('Blocked file extension.'); + } + + if ($this->allowedExtensions !== [] && !in_array($normalized, $this->allowedExtensions, true)) { + throw new UploadException('File extension is not allowed.'); + } + } + + /** + * Validate file size. + */ + private function validateFileSize(int $size): void + { + if ($size > $this->maxFileSize) { + throw new FileSizeExceededException('Exceeded file size limit.'); + } + } + + /** + * Validate the file type. + */ + private function validateFileType(string $fileType): void + { + if ($this->allowedFileTypes === []) { + return; + } + if (!in_array($fileType, $this->allowedFileTypes, true)) { + throw new UploadException('Invalid file format.'); + } + } + + private function validateFinalizedUpload(string $destination): void + { + $finalSize = FlysystemHelper::size($destination); + $this->validateFileSize($finalSize); + + $fileType = $this->getFileMimeType($destination); + $extension = pathinfo($destination, PATHINFO_EXTENSION); + $this->validateFileExtension($extension); + $this->validateFileType($fileType); + $this->validateContentTypeIntegrity($destination, $fileType, $extension); + if ($this->isImage($fileType)) { + $this->validateImageDimensions($destination); + } + + $this->scanForMalware($destination, $fileType); + } + + /** + * Validate image dimensions. + */ + private function validateImageDimensions(string $filePath): void + { + [$pathForInspection, $cleanup] = $this->prepareImagePathForInspection($filePath); + + try { + $dimensions = getimagesize($pathForInspection); + } finally { + if ($cleanup && is_file($pathForInspection)) { + $this->unlinkFileSilently($pathForInspection); + } + } + + if ($dimensions === false) { + throw new UploadException('Unable to inspect image dimensions.'); + } + + [$width, $height] = $dimensions; + if ($this->maxImageWidth > 0 && $width > $this->maxImageWidth) { + throw new UploadException('Image width exceeds the maximum allowed.'); + } + if ($this->maxImageHeight > 0 && $height > $this->maxImageHeight) { + throw new UploadException('Image height exceeds the maximum allowed.'); + } + } + + private function validateMagicSignatureForExtension(string $filePath, string $extension): void + { + $header = $this->readHeaderBytes($filePath, 16); + if ($header === null) { + throw new UploadException('Unable to inspect file signature.'); + } + + $matchesSignature = match ($extension) { + 'jpg', 'jpeg' => str_starts_with($header, "\xFF\xD8\xFF"), + 'png' => str_starts_with($header, "\x89PNG\r\n\x1A\n"), + 'gif' => str_starts_with($header, 'GIF87a') || str_starts_with($header, 'GIF89a'), + 'webp' => str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WEBP', + 'pdf' => str_starts_with($header, '%PDF-'), + 'zip', 'docx' => str_starts_with($header, "PK\x03\x04") + || str_starts_with($header, "PK\x05\x06") + || str_starts_with($header, "PK\x07\x08"), + default => true, + }; + + if (!$matchesSignature) { + throw new UploadException('File signature does not match extension.'); + } + } + + private function validateMimeTypeMatchesExtension(string $fileType, string $extension): void + { + $allowedMimes = match ($extension) { + 'jpg', 'jpeg' => ['image/jpeg'], + 'png' => ['image/png'], + 'gif' => ['image/gif'], + 'webp' => ['image/webp'], + 'pdf' => ['application/pdf'], + 'txt' => ['text/plain', 'application/octet-stream'], + 'csv' => ['text/csv', 'text/plain', 'application/vnd.ms-excel', 'application/octet-stream'], + 'zip' => ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'], + 'doc' => ['application/msword', 'application/octet-stream'], + 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip', 'application/octet-stream'], + 'mp4' => ['video/mp4', 'application/octet-stream'], + 'webm' => ['video/webm', 'application/octet-stream'], + 'mov', 'qt' => ['video/quicktime', 'application/octet-stream'], + default => [], + }; + + if ($allowedMimes === []) { + return; + } + + $normalizedMime = strtolower(trim(explode(';', $fileType, 2)[0])); + if (!in_array($normalizedMime, $allowedMimes, true)) { + throw new UploadException('File content type does not match extension.'); + } + } +} diff --git a/src/StreamHandler/DownloadProcessor.php b/src/StreamHandler/DownloadProcessor.php index 43a97a3..4977664 100644 --- a/src/StreamHandler/DownloadProcessor.php +++ b/src/StreamHandler/DownloadProcessor.php @@ -1,5 +1,7 @@ */ private array $allowedExtensions = []; + + /** @var list */ private array $allowedRoots = []; + + /** @var list */ private array $blockedExtensions = ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com']; + private bool $blockHiddenFiles = true; + private int $chunkSize = 8192; + private string $defaultDownloadName = 'download.bin'; + private bool $forceAttachment = true; + private int $maxDownloadSize = 0; + private bool $rangeRequestsEnabled = true; /** @@ -146,14 +159,16 @@ public function setDefaultDownloadName(string $name): void /** * Set extension allow/block policy for downloads. * - * @param array $allowedExtensions Array of allowed extensions (empty for all). - * @param array $blockedExtensions Array of blocked extensions (default: dangerous types). + * @param list $allowedExtensions Array of allowed extensions (empty for all). + * @param list $blockedExtensions Array of blocked extensions (default: dangerous types). */ public function setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = []): void { $this->allowedExtensions = $this->normalizeExtensions($allowedExtensions); + /** @var list $defaultBlocked */ + $defaultBlocked = ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com']; $this->blockedExtensions = $blockedExtensions === [] - ? ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com'] + ? $defaultBlocked : $this->normalizeExtensions($blockedExtensions); } @@ -233,7 +248,7 @@ public function streamDownload( $remaining = $manifest['contentLength']; $bytesSent = 0; while ($remaining > 0) { - $chunk = fread($inputStream, min($this->chunkSize, $remaining)); + $chunk = fread($inputStream, $this->readLength($remaining)); if (!is_string($chunk) || $chunk === '') { break; } @@ -281,9 +296,13 @@ private function buildEtag(string $path, int $size, int $lastModified): string private function discardBytes(mixed $stream, int $bytes): void { + if (!is_resource($stream)) { + throw new DownloadException('Invalid stream resource for seek.'); + } + $remaining = $bytes; while ($remaining > 0 && !feof($stream)) { - $chunk = fread($stream, min($this->chunkSize, $remaining)); + $chunk = fread($stream, $this->readLength($remaining)); if (!is_string($chunk) || $chunk === '') { break; } @@ -309,8 +328,8 @@ private function normalizeExtension(string $extension): string } /** - * @param array $extensions - * @return array + * @param list $extensions + * @return list */ private function normalizeExtensions(array $extensions): array { @@ -329,22 +348,15 @@ private function normalizeExtensions(array $extensions): array private function pathStartsWith(string $path, string $prefix): bool { - if ($path === $prefix) { - return true; - } - $pathNormalized = rtrim($path, '/\\'); $prefixNormalized = rtrim($prefix, '/\\'); - if ($pathNormalized === $prefixNormalized) { - return true; - } - - $needle = $prefixNormalized . DIRECTORY_SEPARATOR; if (PHP_OS_FAMILY === 'Windows') { - return str_starts_with(strtolower($pathNormalized), strtolower($needle)); + $pathNormalized = strtolower($pathNormalized); + $prefixNormalized = strtolower($prefixNormalized); } - return str_starts_with($pathNormalized, $needle); + return $pathNormalized === $prefixNormalized + || str_starts_with($pathNormalized, $prefixNormalized . DIRECTORY_SEPARATOR); } private function pathWithinAllowedRoot(string $path): bool @@ -357,29 +369,14 @@ private function pathWithinAllowedRoot(string $path): bool foreach ($this->allowedRoots as $root) { $rootIsScheme = PathHelper::hasScheme($root); if ($pathIsScheme || $rootIsScheme) { - if (!$pathIsScheme || !$rootIsScheme) { - continue; - } - - $normalizedPath = rtrim(str_replace('\\', '/', $path), '/'); - $normalizedRoot = rtrim(str_replace('\\', '/', $root), '/'); - if ($normalizedPath === $normalizedRoot || str_starts_with($normalizedPath, $normalizedRoot . '/')) { + if ($this->pathWithinSchemeRoot($path, $root, $pathIsScheme, $rootIsScheme)) { return true; } continue; } - $pathAbsolute = PathHelper::isAbsolute($path) - ? $path - : PathHelper::toAbsolutePath($path); - $rootAbsolute = PathHelper::isAbsolute($root) - ? $root - : PathHelper::toAbsolutePath($root); - - $resolvedPath = realpath($pathAbsolute) ?: PathHelper::normalize($pathAbsolute); - $resolvedRoot = realpath($rootAbsolute) ?: PathHelper::normalize($rootAbsolute); - if ($this->pathStartsWith($resolvedPath, $resolvedRoot)) { + if ($this->pathWithinLocalRoot($path, $root)) { return true; } } @@ -387,6 +384,41 @@ private function pathWithinAllowedRoot(string $path): bool return false; } + private function pathWithinLocalRoot(string $path, string $root): bool + { + $pathAbsolute = PathHelper::isAbsolute($path) + ? $path + : PathHelper::toAbsolutePath($path); + $rootAbsolute = PathHelper::isAbsolute($root) + ? $root + : PathHelper::toAbsolutePath($root); + + $resolvedPath = realpath($pathAbsolute) ?: PathHelper::normalize($pathAbsolute); + $resolvedRoot = realpath($rootAbsolute) ?: PathHelper::normalize($rootAbsolute); + + return $this->pathStartsWith($resolvedPath, $resolvedRoot); + } + + private function pathWithinSchemeRoot(string $path, string $root, bool $pathIsScheme, bool $rootIsScheme): bool + { + if (!$pathIsScheme || !$rootIsScheme) { + return false; + } + + $normalizedPath = rtrim(str_replace('\\', '/', $path), '/'); + $normalizedRoot = rtrim(str_replace('\\', '/', $root), '/'); + + return $normalizedPath === $normalizedRoot || str_starts_with($normalizedPath, $normalizedRoot . '/'); + } + + /** + * @return int<1, max> + */ + private function readLength(int $remaining): int + { + return max(1, min($this->chunkSize, $remaining)); + } + private function resolveDownloadName(?string $downloadName, string $path): string { $fallback = basename($path); @@ -416,8 +448,8 @@ private function resolveRange(?string $rangeHeader, int $size): array throw new DownloadException('Invalid range header.'); } - $startRaw = $matches[1] ?? ''; - $endRaw = $matches[2] ?? ''; + $startRaw = $matches[1]; + $endRaw = $matches[2]; if ($startRaw === '' && $endRaw === '') { throw new DownloadException('Invalid range header.'); @@ -480,15 +512,34 @@ private function sanitizeFilename(string $name): string return $candidate; } + private function seekSilently(mixed $stream, int $offset, int $whence): int + { + if (!is_resource($stream)) { + throw new DownloadException('Invalid stream resource for seek.'); + } + + set_error_handler(static fn(): bool => true); + + try { + return fseek($stream, $offset, $whence); + } finally { + restore_error_handler(); + } + } + private function seekStreamToOffset(mixed $stream, int $offset): void { if ($offset < 1) { return; } + if (!is_resource($stream)) { + throw new DownloadException('Invalid stream resource for seek.'); + } + $metadata = stream_get_meta_data($stream); - $seekable = is_array($metadata) && ($metadata['seekable'] ?? false); - if ($seekable && @fseek($stream, $offset, SEEK_SET) === 0) { + $seekable = $metadata['seekable']; + if ($seekable && $this->seekSilently($stream, $offset, SEEK_SET) === 0) { return; } @@ -532,6 +583,10 @@ private function validateExtension(string $extension): void private function writeFully(mixed $stream, string $payload): int { + if (!is_resource($stream)) { + throw new DownloadException('Invalid output stream.'); + } + $totalWritten = 0; $payloadLength = strlen($payload); while ($totalWritten < $payloadLength) { diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 33ba70f..83ed54b 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -1,16 +1,52 @@ , + * createdAt: int + * } + * @phpstan-type UploadInfo array{ + * uploadDir: string, + * useDateDirectories: bool, + * tempDir: string, + * allowedFileTypes: list, + * allowedExtensions: list, + * blockedExtensions: list, + * maxFileSize: int, + * maxChunkCount: int, + * maxChunkSize: int, + * namingStrategy: string, + * validationProfile: string|null, + * hasMalwareScanner: bool, + * requireMalwareScan: bool, + * strictContentTypeValidation: bool + * } + */ class UploadProcessor { + use UploadProcessorChunkConcern; + use UploadProcessorValidationConcern; + private const array VALIDATION_PROFILES = [ 'image' => [ 'allowedFileTypes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], @@ -37,23 +73,42 @@ class UploadProcessor 'maxImageHeight' => 0, ], ]; + + /** @var list */ private array $allowedExtensions = []; + + /** @var list */ private array $allowedFileTypes = []; + + /** @var list */ private array $blockedExtensions = ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com']; + private LoggerInterface $logger; + private mixed $malwareScanner = null; + private int $maxChunkCount = 0; + private int $maxChunkSize = 0; + private int $maxFileSize = 30720; + private int $maxImageHeight = 0; + private int $maxImageWidth = 0; + private string $namingStrategy = 'hash'; + private bool $requireMalwareScan = false; + private bool $strictContentTypeValidation = false; + private ?string $tempDir = null; private string $uploadDir; + private bool $useDateDirectories = false; + private ?string $validationProfile = null; /** @@ -71,7 +126,7 @@ public function finalizeChunkUpload(string $uploadId): string $this->validateUploadId($uploadId); [$manifest, $totalChunks, $received] = $this->resolveCompleteChunkState($uploadId); - $originalFilename = (string) ($manifest['originalFilename'] ?? 'upload.bin'); + $originalFilename = $manifest['originalFilename']; $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); $this->validateFileExtension($extension); $fileName = $this->generateFileName(null, $extension); @@ -88,7 +143,7 @@ public function finalizeChunkUpload(string $uploadId): string /** * Get detailed info about the current configuration and settings. * - * @return array Array with upload configuration details. + * @return UploadInfo Array with upload configuration details. */ public function getInfo(): array { @@ -113,7 +168,7 @@ public function getInfo(): array /** * Retrieve available validation profiles. * - * @return array List of available validation profile names. + * @return list List of available validation profile names. */ public function getValidationProfiles(): array { @@ -123,7 +178,7 @@ public function getValidationProfiles(): array /** * Process an upload chunk and persist resumable state. * - * @param array $chunkFile The chunk file data from $_FILES. + * @param UploadInput $chunkFile The chunk file data from $_FILES. * @param string $uploadId The unique upload identifier. * @param int $chunkIndex The index of this chunk (0-based). * @param int $totalChunks Total number of chunks expected. @@ -147,6 +202,7 @@ public function processChunkUpload(array $chunkFile, string $uploadId, int $chun $chunkPath = PathHelper::join($chunkDirectory, sprintf('chunk_%06d.part', $chunkIndex)); $this->moveIncomingFile($chunkFile['tmp_name'], $chunkPath); + /** @var ChunkManifest $manifest */ $manifest = $this->loadChunkManifest($uploadId) ?? [ 'uploadId' => $uploadId, 'originalFilename' => $originalFilename, @@ -172,7 +228,7 @@ public function processChunkUpload(array $chunkFile, string $uploadId, int $chun /** * Process the upload and save the file. * - * @param array $file The file data from $_FILES. + * @param UploadInput $file The file data from $_FILES. * @return string The path to the saved file. * @throws UploadException If validation fails or upload directory is not set. */ @@ -184,23 +240,24 @@ public function processUpload(array $file): string } $this->validateFile($file); - $extension = pathinfo((string) ($file['name'] ?? ''), PATHINFO_EXTENSION); + $tmpName = $file['tmp_name']; + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $this->validateFileExtension($extension); - $fileType = $this->getFileMimeType((string) $file['tmp_name']); + $fileType = $this->getFileMimeType($tmpName); $this->validateFileType($fileType); - $this->validateContentTypeIntegrity((string) $file['tmp_name'], $fileType, $extension); + $this->validateContentTypeIntegrity($tmpName, $fileType, $extension); if ($this->isImage($fileType)) { - $this->validateImageDimensions($file['tmp_name']); + $this->validateImageDimensions($tmpName); } - $this->scanForMalware($file['tmp_name'], $fileType); + $this->scanForMalware($tmpName, $fileType); - $fileName = $this->generateFileName($file['tmp_name'], $extension); + $fileName = $this->generateFileName($tmpName, $extension); $destination = $this->getUniqueDestination($fileName); - if (!move_uploaded_file($file['tmp_name'], $destination)) { - $stream = fopen($file['tmp_name'], 'rb'); + if (!move_uploaded_file($tmpName, $destination)) { + $stream = fopen($tmpName, 'rb'); if (!is_resource($stream)) { throw new UploadException('Failed to move uploaded file.'); } @@ -211,7 +268,7 @@ public function processUpload(array $file): string fclose($stream); } - @unlink($file['tmp_name']); + $this->unlinkFileSilently($tmpName); } // Log upload metadata @@ -236,7 +293,7 @@ public function processUpload(array $file): string if (isset($this->logger)) { $this->logger->error('File upload failed.', [ 'error' => $e->getMessage(), - 'file' => $file['name'] ?? 'Unknown', + 'file' => $file['name'], ]); } @@ -276,14 +333,16 @@ public function setDirectorySettings(string $uploadDir, bool $useDateDirectories /** * Configure extension allow/block policy. * - * @param array $allowedExtensions - * @param array $blockedExtensions + * @param list $allowedExtensions + * @param list $blockedExtensions */ public function setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = []): void { $this->allowedExtensions = $this->normalizeExtensions($allowedExtensions); + /** @var list $defaultBlocked */ + $defaultBlocked = ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com']; $this->blockedExtensions = $blockedExtensions === [] - ? ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com'] + ? $defaultBlocked : $this->normalizeExtensions($blockedExtensions); } @@ -378,7 +437,7 @@ public function setValidationProfile(string $profile): void /** * Configure validation settings. * - * @param array $allowedFileTypes Array of allowed MIME types. + * @param list $allowedFileTypes Array of allowed MIME types. * @param int $maxFileSize Maximum file size in bytes. */ public function setValidationSettings(array $allowedFileTypes, int $maxFileSize): void @@ -388,68 +447,6 @@ public function setValidationSettings(array $allowedFileTypes, int $maxFileSize) $this->validationProfile = null; } - private function appendChunkToStream(string $chunkPath, mixed $output, int $index): void - { - $input = FlysystemHelper::readStream($chunkPath); - if (!is_resource($input)) { - throw new UploadException("Failed to read chunk index {$index}."); - } - - try { - stream_copy_to_stream($input, $output); - } finally { - fclose($input); - } - } - - private function cleanupChunkUploadArtifacts(string $uploadId, string $chunkDirectory, array $received): void - { - foreach ($received as $chunkName) { - $chunkPath = PathHelper::join($chunkDirectory, (string) $chunkName); - if (FlysystemHelper::fileExists($chunkPath)) { - FlysystemHelper::delete($chunkPath); - } - } - - $manifestPath = $this->getChunkManifestPath($uploadId); - if (FlysystemHelper::fileExists($manifestPath)) { - FlysystemHelper::delete($manifestPath); - } - if (FlysystemHelper::directoryExists($chunkDirectory)) { - FlysystemHelper::deleteDirectory($chunkDirectory); - } - } - - private function copyImageToInspectionFile(string $filePath, string $tempFile): void - { - $stream = FlysystemHelper::readStream($filePath); - $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 UploadException('Unable to inspect image dimensions.'); - } - - stream_copy_to_stream($stream, $target); - fclose($stream); - fclose($target); - } - - /** - * Ensure the upload directory exists. - */ - private function ensureUploadDirectoryExists(): void - { - if (!FlysystemHelper::directoryExists($this->uploadDir)) { - FlysystemHelper::createDirectory($this->uploadDir); - } - } - /** * Generate a unique file name based on the strategy and caller info. */ @@ -479,494 +476,4 @@ private function generateFileName(?string $dataSource, string $extension): strin ? sprintf('%s_%s.%s', $prefix, $hash, $extension) : sprintf('%s_%s', $prefix, $hash); } - - private function getChunkDirectory(string $uploadId): string - { - $safeUploadId = preg_replace('/[^A-Za-z0-9_\-]/', '', $uploadId) ?: 'upload'; - $baseTemp = $this->tempDir ? rtrim($this->tempDir, '/\\') : sys_get_temp_dir(); - return PathHelper::join($baseTemp, 'pathwise_chunks', $safeUploadId); - } - - private function getChunkManifestPath(string $uploadId): string - { - return PathHelper::join($this->getChunkDirectory($uploadId), 'manifest.json'); - } - - /** - * Get the MIME type of a file. - */ - private function getFileMimeType(string $filePath): string - { - $mimeType = MetadataHelper::getMimeType($filePath); - if ($mimeType === null) { - throw new UploadException('Unable to determine file MIME type.'); - } - - return $mimeType; - } - - /** - * Get a unique destination for the uploaded file. - */ - private function getUniqueDestination(string $fileName): string - { - $subDir = $this->useDateDirectories ? date('Y/m/d') : ''; - $destinationDir = $subDir !== '' - ? PathHelper::join($this->uploadDir, $subDir) - : $this->uploadDir; - - if (!FlysystemHelper::directoryExists($destinationDir)) { - FlysystemHelper::createDirectory($destinationDir); - } - - $destination = PathHelper::join($destinationDir, $fileName); - - if (FlysystemHelper::fileExists($destination)) { - throw new UploadException('File with the same name already exists.'); - } - - return $destination; - } - - /** - * Check if a file is an image. - */ - private function isImage(string $fileType): bool - { - return str_starts_with($fileType, 'image/'); - } - - private function loadChunkManifest(string $uploadId): ?array - { - $path = $this->getChunkManifestPath($uploadId); - if (!FlysystemHelper::fileExists($path)) { - return null; - } - - $content = FlysystemHelper::read($path); - - $manifest = json_decode($content, true); - if (!is_array($manifest)) { - throw new UploadException('Invalid chunk manifest.'); - } - - return $manifest; - } - - private function mergeChunksToDestination(string $chunkDirectory, array $received, int $totalChunks, string $destination): void - { - $output = fopen('php://temp', 'rb+'); - if ($output === false) { - throw new UploadException('Failed to create destination file for chunk merge.'); - } - - try { - for ($i = 0; $i < $totalChunks; $i++) { - $chunkPath = $this->resolveChunkPath($chunkDirectory, $received, $i); - $this->appendChunkToStream($chunkPath, $output, $i); - } - - rewind($output); - FlysystemHelper::writeStream($destination, $output); - } finally { - fclose($output); - } - } - - private function moveIncomingFile(string $source, string $destination): void - { - if (is_uploaded_file($source)) { - if (move_uploaded_file($source, $destination)) { - return; - } - - $stream = fopen($source, 'rb'); - if (is_resource($stream)) { - try { - FlysystemHelper::writeStream($destination, $stream); - } finally { - fclose($stream); - } - - @unlink($source); - - return; - } - - throw new UploadException('Failed to move uploaded file.'); - } - - if (PathHelper::hasScheme($source) || PathHelper::hasScheme($destination)) { - try { - FlysystemHelper::copy($source, $destination); - } catch (\Throwable) { - throw new UploadException('Failed to move incoming file.'); - } - - FlysystemHelper::delete($source); - - return; - } - - if (!@rename($source, $destination)) { - try { - FlysystemHelper::copy($source, $destination); - } catch (\Throwable) { - throw new UploadException('Failed to move incoming file.'); - } - FlysystemHelper::delete($source); - } - } - - private function normalizeExtension(string $extension): string - { - return strtolower(ltrim(trim($extension), '.')); - } - - /** - * @param array $extensions - * @return array - */ - private function normalizeExtensions(array $extensions): array - { - $normalized = []; - foreach ($extensions as $extension) { - $candidate = $this->normalizeExtension($extension); - if ($candidate === '') { - continue; - } - $normalized[] = $candidate; - } - - return array_values(array_unique($normalized)); - } - - /** - * @return array{string, bool} - */ - private function prepareImagePathForInspection(string $filePath): array - { - if (!PathHelper::hasScheme($filePath) && is_file($filePath)) { - return [$filePath, false]; - } - - $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_img_'); - if ($tempFile === false) { - throw new UploadException('Unable to create temporary file for image validation.'); - } - - $this->copyImageToInspectionFile($filePath, $tempFile); - - return [$tempFile, true]; - } - - private function readHeaderBytes(string $filePath, int $length): ?string - { - $stream = FlysystemHelper::readStream($filePath); - if (!is_resource($stream)) { - return null; - } - - try { - $bytes = fread($stream, $length); - } finally { - fclose($stream); - } - - return is_string($bytes) ? $bytes : null; - } - - private function resolveChunkPath(string $chunkDirectory, array $received, int $index): string - { - $chunkName = $received[(string) $index] ?? null; - if (!is_string($chunkName)) { - throw new UploadException("Missing chunk index {$index}."); - } - - $chunkPath = PathHelper::join($chunkDirectory, $chunkName); - if (!FlysystemHelper::fileExists($chunkPath)) { - throw new UploadException("Missing chunk file for index {$index}."); - } - - return $chunkPath; - } - - /** - * @return array{array, int, array} - */ - private function resolveCompleteChunkState(string $uploadId): array - { - $manifest = $this->loadChunkManifest($uploadId); - if ($manifest === null) { - throw new UploadException("Upload session not found: {$uploadId}"); - } - - $totalChunks = (int) ($manifest['totalChunks'] ?? 0); - $received = (array) ($manifest['received'] ?? []); - if ($totalChunks < 1 || count($received) !== $totalChunks) { - throw new UploadException('Upload is not complete.'); - } - if ($this->maxChunkCount > 0 && $totalChunks > $this->maxChunkCount) { - throw new UploadException('Total chunks exceed configured limit.'); - } - - ksort($received); - - return [$manifest, $totalChunks, $received]; - } - - /** - * Sanitize a path to remove invalid characters. - */ - private function sanitizePath(string $path): string - { - $sanitized = preg_replace('/[^a-zA-Z0-9\/\\\\:_.-]/', '', $path) ?? ''; - return rtrim($sanitized, '/\\'); - } - - private function saveChunkManifest(string $uploadId, array $manifest): void - { - $path = $this->getChunkManifestPath($uploadId); - $json = json_encode($manifest, JSON_PRETTY_PRINT); - if ($json === false) { - throw new UploadException('Failed to persist chunk manifest.'); - } - - FlysystemHelper::write($path, $json); - } - - private function scanForMalware(string $filePath, string $fileType): void - { - if (!is_callable($this->malwareScanner)) { - if ($this->requireMalwareScan) { - throw new UploadException('Malware scanner is required but not configured.'); - } - - return; - } - - try { - $result = ($this->malwareScanner)($filePath, $fileType); - } catch (\Throwable $e) { - throw new UploadException('Malware scanner failed: ' . $e->getMessage(), 0, $e); - } - - if ($result === false) { - throw new UploadException('Malware scan failed.'); - } - } - - private function validateChunkLimits(array $chunkFile, int $totalChunks): void - { - if ($this->maxChunkCount > 0 && $totalChunks > $this->maxChunkCount) { - throw new UploadException('Total chunks exceed configured limit.'); - } - - if (!isset($chunkFile['size']) || !is_numeric($chunkFile['size'])) { - throw new UploadException('Invalid chunk metadata.'); - } - - if ($this->maxChunkSize > 0 && (int) $chunkFile['size'] > $this->maxChunkSize) { - throw new FileSizeExceededException('Chunk exceeds configured size limit.'); - } - } - - private function validateChunkUploadRequest(array $chunkFile, string $uploadId, int $chunkIndex, int $totalChunks, string $originalFilename): void - { - $this->validateUploadId($uploadId); - - if ($chunkIndex < 0 || $totalChunks < 1 || $chunkIndex >= $totalChunks) { - throw new UploadException('Invalid chunk metadata.'); - } - - $this->validateChunkLimits($chunkFile, $totalChunks); - $this->validateFileExtension(pathinfo($originalFilename, PATHINFO_EXTENSION)); - } - - private function validateContentTypeIntegrity(string $filePath, string $fileType, string $extension): void - { - if (!$this->strictContentTypeValidation) { - return; - } - - $normalizedExtension = $this->normalizeExtension($extension); - if ($normalizedExtension === '') { - return; - } - - $this->validateMimeTypeMatchesExtension($fileType, $normalizedExtension); - $this->validateMagicSignatureForExtension($filePath, $normalizedExtension); - } - - /** - * Validate the uploaded file. - */ - private function validateFile(array $file): void - { - if (!isset($file['error']) || is_array($file['error'])) { - throw new UploadException('Invalid file upload parameters.'); - } - - switch ($file['error']) { - case UPLOAD_ERR_OK: - break; - case UPLOAD_ERR_NO_FILE: - throw new UploadException('No file sent.'); - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - throw new FileSizeExceededException('Exceeded file size limit.'); - default: - throw new UploadException('Unknown errors.'); - } - - $this->validateFileSize($file['size']); - } - - private function validateFileExtension(string $extension): void - { - $normalized = $this->normalizeExtension($extension); - if ($normalized === '') { - if ($this->allowedExtensions !== []) { - throw new UploadException('File extension is required.'); - } - - return; - } - - if (in_array($normalized, $this->blockedExtensions, true)) { - throw new UploadException('Blocked file extension.'); - } - - if ($this->allowedExtensions !== [] && !in_array($normalized, $this->allowedExtensions, true)) { - throw new UploadException('File extension is not allowed.'); - } - } - - /** - * Validate file size. - */ - private function validateFileSize(int $size): void - { - if ($size > $this->maxFileSize) { - throw new FileSizeExceededException('Exceeded file size limit.'); - } - } - - /** - * Validate the file type. - */ - private function validateFileType(string $fileType): void - { - if ($this->allowedFileTypes === []) { - return; - } - if (!in_array($fileType, $this->allowedFileTypes, true)) { - throw new UploadException('Invalid file format.'); - } - } - - private function validateFinalizedUpload(string $destination): void - { - $finalSize = FlysystemHelper::size($destination); - $this->validateFileSize($finalSize); - - $fileType = $this->getFileMimeType($destination); - $extension = pathinfo($destination, PATHINFO_EXTENSION); - $this->validateFileExtension($extension); - $this->validateFileType($fileType); - $this->validateContentTypeIntegrity($destination, $fileType, $extension); - if ($this->isImage($fileType)) { - $this->validateImageDimensions($destination); - } - - $this->scanForMalware($destination, $fileType); - } - - /** - * Validate image dimensions. - */ - private function validateImageDimensions(string $filePath): void - { - [$pathForInspection, $cleanup] = $this->prepareImagePathForInspection($filePath); - - try { - $dimensions = getimagesize($pathForInspection); - } finally { - if ($cleanup && is_file($pathForInspection)) { - @unlink($pathForInspection); - } - } - - if (!is_array($dimensions) || count($dimensions) < 2) { - throw new UploadException('Unable to inspect image dimensions.'); - } - - [$width, $height] = $dimensions; - if ($this->maxImageWidth > 0 && $width > $this->maxImageWidth) { - throw new UploadException('Image width exceeds the maximum allowed.'); - } - if ($this->maxImageHeight > 0 && $height > $this->maxImageHeight) { - throw new UploadException('Image height exceeds the maximum allowed.'); - } - } - - private function validateMagicSignatureForExtension(string $filePath, string $extension): void - { - $header = $this->readHeaderBytes($filePath, 16); - if ($header === null) { - throw new UploadException('Unable to inspect file signature.'); - } - - $matchesSignature = match ($extension) { - 'jpg', 'jpeg' => str_starts_with($header, "\xFF\xD8\xFF"), - 'png' => str_starts_with($header, "\x89PNG\r\n\x1A\n"), - 'gif' => str_starts_with($header, "GIF87a") || str_starts_with($header, "GIF89a"), - 'webp' => str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WEBP', - 'pdf' => str_starts_with($header, '%PDF-'), - 'zip', 'docx' => str_starts_with($header, "PK\x03\x04") - || str_starts_with($header, "PK\x05\x06") - || str_starts_with($header, "PK\x07\x08"), - default => true, - }; - - if (!$matchesSignature) { - throw new UploadException('File signature does not match extension.'); - } - } - - private function validateMimeTypeMatchesExtension(string $fileType, string $extension): void - { - $allowedMimes = match ($extension) { - 'jpg', 'jpeg' => ['image/jpeg'], - 'png' => ['image/png'], - 'gif' => ['image/gif'], - 'webp' => ['image/webp'], - 'pdf' => ['application/pdf'], - 'txt' => ['text/plain', 'application/octet-stream'], - 'csv' => ['text/csv', 'text/plain', 'application/vnd.ms-excel', 'application/octet-stream'], - 'zip' => ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'], - 'doc' => ['application/msword', 'application/octet-stream'], - 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip', 'application/octet-stream'], - 'mp4' => ['video/mp4', 'application/octet-stream'], - 'webm' => ['video/webm', 'application/octet-stream'], - 'mov', 'qt' => ['video/quicktime', 'application/octet-stream'], - default => [], - }; - - if ($allowedMimes === []) { - return; - } - - $normalizedMime = strtolower(trim(explode(';', $fileType, 2)[0])); - if (!in_array($normalizedMime, $allowedMimes, true)) { - throw new UploadException('File content type does not match extension.'); - } - } - - private function validateUploadId(string $uploadId): void - { - if ($uploadId === '' || strlen($uploadId) > 128 || preg_match('/^[A-Za-z0-9_-]+$/', $uploadId) !== 1) { - throw new UploadException('Invalid upload session id.'); - } - } } diff --git a/src/Utils/FileWatcher.php b/src/Utils/FileWatcher.php index ae7ee53..45e3a95 100644 --- a/src/Utils/FileWatcher.php +++ b/src/Utils/FileWatcher.php @@ -1,19 +1,26 @@ + * @phpstan-type DiffReport array{created: list, modified: list, deleted: list} + */ final class FileWatcher { /** * Compare snapshots and return change report. * - * @param array $previousSnapshot The previous snapshot data. - * @param array $currentSnapshot The current snapshot data. - * @return array{created: array, modified: array, deleted: array} The diff report with created, modified, and deleted files. + * @param SnapshotMap $previousSnapshot The previous snapshot data. + * @param SnapshotMap $currentSnapshot The current snapshot data. + * @return DiffReport The diff report with created, modified, and deleted files. */ public static function diff(array $previousSnapshot, array $currentSnapshot): array { @@ -24,11 +31,12 @@ public static function diff(array $previousSnapshot, array $currentSnapshot): ar foreach ($currentSnapshot as $path => $meta) { if (!isset($previousSnapshot[$path])) { $created[] = $path; + continue; } $old = $previousSnapshot[$path]; - if (($old['mtime'] ?? 0) !== ($meta['mtime'] ?? 0) || ($old['size'] ?? 0) !== ($meta['size'] ?? 0)) { + if ($old['mtime'] !== $meta['mtime'] || $old['size'] !== $meta['size']) { $modified[] = $path; } } @@ -51,7 +59,7 @@ public static function diff(array $previousSnapshot, array $currentSnapshot): ar * * @param string $path The path to snapshot. * @param bool $recursive Whether to include subdirectories recursively. - * @return array The snapshot map with file paths as keys. + * @return SnapshotMap The snapshot map with file paths as keys. */ public static function snapshot(string $path, bool $recursive = true): array { @@ -85,14 +93,24 @@ public static function snapshot(string $path, bool $recursive = true): array : new FilesystemIterator($normalized, FilesystemIterator::SKIP_DOTS); foreach ($iterator as $item) { + if (!$item instanceof \SplFileInfo) { + continue; + } + if ($item->isDir()) { continue; } + $mtime = $item->getMTime(); + $size = $item->getSize(); + if (!is_int($mtime) || !is_int($size)) { + continue; + } + $filePath = PathHelper::normalize($item->getPathname()); $entries[$filePath] = [ - 'mtime' => (int) $item->getMTime(), - 'size' => (int) $item->getSize(), + 'mtime' => $mtime, + 'size' => $size, ]; } @@ -109,7 +127,7 @@ public static function snapshot(string $path, bool $recursive = true): array * @param int $durationSeconds How long to watch in seconds. Defaults to 5. * @param int $intervalMilliseconds Polling interval in milliseconds. Defaults to 500. * @param bool $recursive Whether to watch subdirectories. Defaults to true. - * @return array Final snapshot. + * @return SnapshotMap Final snapshot. */ public static function watch( string $path, @@ -136,8 +154,45 @@ public static function watch( return $snapshot; } + private static function intFromMixed(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + return is_numeric($value) ? (int) $value : 0; + } + + private static function resolveFlysystemRelativePath(mixed $item, string $base): ?string + { + if (!is_array($item)) { + return null; + } + + $type = $item['type'] ?? null; + if (!is_string($type) || $type !== 'file') { + return null; + } + + $itemPathRaw = $item['path'] ?? null; + if (!is_string($itemPathRaw)) { + return null; + } + + $itemPath = trim($itemPathRaw, '/'); + if ($itemPath === '') { + return null; + } + + if ($base !== '' && str_starts_with($itemPath, $base . '/')) { + return substr($itemPath, strlen($base) + 1); + } + + return $itemPath === $base ? null : $itemPath; + } + /** - * @return array + * @return SnapshotMap */ private static function snapshotViaFlysystem(string $path, bool $recursive): array { @@ -146,27 +201,18 @@ private static function snapshotViaFlysystem(string $path, bool $recursive): arr $base = trim(str_replace('\\', '/', $baseLocation), '/'); foreach (FlysystemHelper::listContents($path, $recursive) as $item) { - if (($item['type'] ?? null) !== 'file') { - continue; - } - - $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 === '') { + $relative = self::resolveFlysystemRelativePath($item, $base); + if ($relative === null) { continue; } $resolved = PathHelper::join($path, $relative); + $lastModified = self::intFromMixed($item['last_modified'] ?? 0); + $fileSize = self::intFromMixed($item['file_size'] ?? 0); $entries[$resolved] = [ - 'mtime' => (int) ($item['last_modified'] ?? 0), - 'size' => (int) ($item['file_size'] ?? 0), + 'mtime' => $lastModified, + 'size' => $fileSize, ]; } diff --git a/src/Utils/FlysystemHelper.php b/src/Utils/FlysystemHelper.php index 6f28492..fc7946a 100644 --- a/src/Utils/FlysystemHelper.php +++ b/src/Utils/FlysystemHelper.php @@ -1,5 +1,7 @@ + */ final class FlysystemHelper { private static ?FilesystemOperator $defaultFilesystem = null; + /** @var array */ private static array $mounts = []; @@ -22,7 +28,7 @@ final class FlysystemHelper * * @param string $path The file path. * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. * @return string|null The checksum, or null if the file doesn't exist or algorithm is unsupported. */ public static function checksum(string $path, string $algorithm = 'sha256', array $config = []): ?string @@ -59,7 +65,7 @@ public static function clearMounts(): void * * @param string $source The source file path. * @param string $destination The destination file path. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. */ public static function copy(string $source, string $destination, array $config = []): void { @@ -90,7 +96,7 @@ public static function copy(string $source, string $destination, array $config = * * @param string $source The source directory path. * @param string $destination The destination directory path. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. */ public static function copyDirectory(string $source, string $destination, array $config = []): void { @@ -113,6 +119,7 @@ public static function copyDirectory(string $source, string $destination, array if ($item->isDir()) { $destinationFilesystem->createDirectory($targetPath, $config); + continue; } @@ -133,7 +140,7 @@ public static function copyDirectory(string $source, string $destination, array * Create a directory. * * @param string $path The directory path. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. */ public static function createDirectory(string $path, array $config = []): void { @@ -242,16 +249,12 @@ public static function lastModified(string $path): int * * @param string $path The directory path. * @param bool $deep Whether to list recursively. Defaults to true. - * @return array The list of contents. + * @return list> The list of contents. */ public static function listContents(string $path, bool $deep = true): array { $items = []; foreach (self::listContentsListing($path, $deep) as $item) { - if (!$item instanceof StorageAttributes) { - continue; - } - $items[] = self::normalizeStorageAttributes($item); } @@ -263,7 +266,7 @@ public static function listContents(string $path, bool $deep = true): array * * @param string $path The directory path. * @param bool $deep Whether to list recursively. - * @return DirectoryListing The directory listing. + * @return DirectoryListing The directory listing. */ public static function listContentsListing(string $path, bool $deep = true): DirectoryListing { @@ -306,7 +309,7 @@ public static function mount(string $name, FilesystemOperator $filesystem): void * * @param string $source The source file path. * @param string $destination The destination file path. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. */ public static function move(string $source, string $destination, array $config = []): void { @@ -328,7 +331,7 @@ public static function move(string $source, string $destination, array $config = * * @param string $source The source directory path. * @param string $destination The destination directory path. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. */ public static function moveDirectory(string $source, string $destination, array $config = []): void { @@ -340,21 +343,19 @@ public static function moveDirectory(string $source, string $destination, array * Get the public URL for a file. * * @param string $path The file path. - * @param array $config Additional configuration for URL generation. + * @param FsConfig $config Additional configuration for URL generation. * @return string The public URL. - * @throws \RuntimeException If public URL generation is not supported. + * @throws \RuntimeException If public URL generation fails. */ public static function publicUrl(string $path, array $config = []): string { [$filesystem, $location] = self::filesystemForFile($path); - if (!method_exists($filesystem, 'publicUrl')) { - throw new \RuntimeException('Public URL generation is not supported by the resolved filesystem.'); - } - - /** @var callable $callable */ - $callable = $filesystem->publicUrl(...); - return $callable($location, $config); + try { + return $filesystem->publicUrl($location, $config); + } catch (\Throwable $e) { + throw new \RuntimeException('Public URL generation failed for the resolved filesystem.', 0, $e); + } } /** @@ -452,21 +453,19 @@ public static function size(string $path): int * * @param string $path The file path. * @param DateTimeInterface $expiresAt The expiration time. - * @param array $config Additional configuration for URL generation. + * @param FsConfig $config Additional configuration for URL generation. * @return string The temporary URL. - * @throws \RuntimeException If temporary URL generation is not supported. + * @throws \RuntimeException If temporary URL generation fails. */ public static function temporaryUrl(string $path, DateTimeInterface $expiresAt, array $config = []): string { [$filesystem, $location] = self::filesystemForFile($path); - if (!method_exists($filesystem, 'temporaryUrl')) { - throw new \RuntimeException('Temporary URL generation is not supported by the resolved filesystem.'); - } - - /** @var callable $callable */ - $callable = $filesystem->temporaryUrl(...); - return $callable($location, $expiresAt, $config); + try { + return $filesystem->temporaryUrl($location, $expiresAt, $config); + } catch (\Throwable $e) { + throw new \RuntimeException('Temporary URL generation failed for the resolved filesystem.', 0, $e); + } } /** @@ -484,9 +483,9 @@ public static function unmount(string $name): void * Get the visibility of a file. * * @param string $path The file path. - * @return string|null The visibility, or null if not available. + * @return string The visibility. */ - public static function visibility(string $path): ?string + public static function visibility(string $path): string { [$filesystem, $location] = self::filesystemForFile($path); @@ -498,7 +497,7 @@ public static function visibility(string $path): ?string * * @param string $path The file path. * @param string $contents The contents to write. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. */ public static function write(string $path, string $contents, array $config = []): void { @@ -511,7 +510,7 @@ public static function write(string $path, string $contents, array $config = []) * * @param string $path The file path. * @param mixed $stream The stream resource. - * @param array $config Additional configuration. + * @param FsConfig $config Additional configuration. */ public static function writeStream(string $path, mixed $stream, array $config = []): void { @@ -632,7 +631,15 @@ private static function normalizeStorageAttributes(StorageAttributes $item): arr $normalized['visibility'] = null; } - return array_merge($normalized, $item->extraMetadata()); + foreach ($item->extraMetadata() as $key => $value) { + if (!is_string($key)) { + continue; + } + + $normalized[$key] = $value; + } + + return $normalized; } /** diff --git a/src/Utils/MetadataHelper.php b/src/Utils/MetadataHelper.php index 4851aaf..3e85e84 100644 --- a/src/Utils/MetadataHelper.php +++ b/src/Utils/MetadataHelper.php @@ -1,5 +1,7 @@ |null An associative array containing metadata information, or null if the path does not exist. */ public static function getAllMetadata(string $path, bool $humanReadableSize = false): ?array { @@ -56,10 +58,10 @@ public static function getAllMetadata(string $path, bool $humanReadableSize = fa * * @param string $path The path to the file to compute a checksum for. * @param string $algorithm The algorithm to use to compute the checksum. - * Supported algorithms are those that are listed in the return value - * of the hash_algos() function. + * Supported algorithms are those that are listed in the return value + * of the hash_algos() function. * @return string|null The checksum of the file, or null if the path is not - * a file or if the algorithm is not supported. + * a file or if the algorithm is not supported. */ public static function getChecksum(string $path, string $algorithm = 'md5'): ?string { @@ -91,7 +93,7 @@ public static function getDirectorySize(string $directory): ?int continue; } - $size += (int) ($item['file_size'] ?? 0); + $size += self::intFromMixed($item['file_size'] ?? 0); } return $size; @@ -109,9 +111,9 @@ public static function getDirectorySize(string $directory): ?int * * @param string $directory The path to the directory to count files in. * @param bool $recursive If true (default), recursively traverse the - * directory. If false, only count files in the top-level directory. + * directory. If false, only count files in the top-level directory. * @return int|null The number of files in the directory, or null if the - * directory does not exist. + * directory does not exist. */ public static function getFileCount(string $directory, bool $recursive = true): ?int { @@ -172,7 +174,7 @@ public static function getFileSize(string $path, bool $humanReadable = false): s * 'Y-m-d H:i:s'. If the file does not exist, returns null. * * @param string $path The path to the file to retrieve timestamps for. - * @return array|null The human-readable timestamps, or null if the file does not exist. + * @return array{created: string, modified: string, accessed: string}|null The human-readable timestamps, or null if the file does not exist. */ public static function getHumanReadableTimestamps(string $path): ?array { @@ -181,7 +183,11 @@ public static function getHumanReadableTimestamps(string $path): ?array return null; } - return array_map(fn($time) => date('Y-m-d H:i:s', $time), $timestamps); + return [ + 'created' => date('Y-m-d H:i:s', $timestamps['created']), + 'modified' => date('Y-m-d H:i:s', $timestamps['modified']), + 'accessed' => date('Y-m-d H:i:s', $timestamps['accessed']), + ]; } /** @@ -208,7 +214,7 @@ public static function getLastModifiedBy(string $path): ?string * * @param string $path The path to the file to retrieve the MIME type for. * @return string|null The MIME type of the file, or null if the path does not - * point to a file or metadata cannot be determined. + * point to a file or metadata cannot be determined. */ public static function getMimeType(string $path): ?string { @@ -246,10 +252,10 @@ public static function getMimeType(string $path): ?string * null. * * @param string $path The path to the file or directory to retrieve - * ownership for. - * @return array|null An array containing the owner and group of the file - * or directory, or null if the file or directory does not exist, or - * if ownership functions are not supported on the current system. + * ownership for. + * @return array{owner: string|null, group: string|null}|null An array containing the owner and group of the file + * or directory, or null if the file or directory does not exist, or + * if ownership functions are not supported on the current system. */ public static function getOwnershipDetails(string $path): ?array { @@ -263,7 +269,7 @@ public static function getOwnershipDetails(string $path): ?array * * @param string $path The path to check. * @return string|null Returns 'file' if the path is a file, 'directory' if it's a directory, - * 'link' if it's a symbolic link, or null if none of these. + * 'link' if it's a symbolic link, or null if none of these. */ public static function getPathType(string $path): ?string { @@ -286,11 +292,17 @@ public static function getPathType(string $path): ?string * * @param string $path The path to the symbolic link. * @return string|null The target of the symbolic link, or null if the path - * is not a symbolic link. + * is not a symbolic link. */ public static function getSymlinkTarget(string $path): ?string { - return is_link($path) ? readlink($path) : null; + if (!is_link($path)) { + return null; + } + + $target = readlink($path); + + return is_string($target) ? $target : null; } /** @@ -303,17 +315,24 @@ public static function getSymlinkTarget(string $path): ?string * this function returns null. * * @param string $path The path to the file or directory to retrieve - * timestamps for. - * @return array|null The timestamps, or null if the file or directory does - * not exist. + * timestamps for. + * @return array{created: int, modified: int, accessed: int}|null The timestamps, or null if the file or directory does + * not exist. */ public static function getTimestamps(string $path): ?array { if (file_exists($path)) { + $created = filectime($path); + $modified = filemtime($path); + $accessed = fileatime($path); + if (!is_int($created) || !is_int($modified) || !is_int($accessed)) { + return null; + } + return [ - 'created' => filectime($path), - 'modified' => filemtime($path), - 'accessed' => fileatime($path), + 'created' => $created, + 'modified' => $modified, + 'accessed' => $accessed, ]; } @@ -339,7 +358,7 @@ public static function getTimestamps(string $path): ?array * * @param string $path The path to check for a broken symbolic link. * @return bool|null True if the link is broken, false if it is not, or null - * if the path is not a symbolic link. + * if the path is not a symbolic link. */ public static function isBrokenSymlink(string $path): ?bool { @@ -375,7 +394,9 @@ public static function isHidden(string $path): bool private static function formatSize(int $size): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $power = $size > 0 ? floor(log($size, 1024)) : 0; + $power = $size > 0 ? (int) floor(log($size, 1024)) : 0; + $power = min($power, count($units) - 1); + return number_format($size / (1024 ** $power), 2) . ' ' . $units[$power]; } @@ -383,4 +404,13 @@ private static function getOwnershipResolver(): OwnershipResolverInterface { return self::$ownershipResolver ??= OwnershipResolverFactory::create(); } + + private static function intFromMixed(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + return is_numeric($value) ? (int) $value : 0; + } } diff --git a/src/Utils/Ownership/FallbackOwnershipResolver.php b/src/Utils/Ownership/FallbackOwnershipResolver.php index 6f2f815..90f13a5 100644 --- a/src/Utils/Ownership/FallbackOwnershipResolver.php +++ b/src/Utils/Ownership/FallbackOwnershipResolver.php @@ -1,5 +1,7 @@ */ private static array $cache = []; /** @@ -47,7 +50,13 @@ public static function createDirectory(string $path, int $permissions = 0755): b FlysystemHelper::createDirectory($path); if (!self::hasScheme($path)) { - @chmod($path, $permissions); + set_error_handler(static fn(): bool => true); + + try { + chmod($path, $permissions); + } finally { + restore_error_handler(); + } } return true; @@ -62,6 +71,7 @@ public static function createDirectory(string $path, int $permissions = 0755): b public static function createTempDirectory(string $prefix = 'temp_'): string|false { $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $prefix . uniqid(); + return mkdir($tempDir) ? $tempDir : false; } @@ -142,7 +152,7 @@ public static function getFilename(string $path, bool $withExtension = true): st * * @param string $path The path to check. * @return string|null Returns 'file' if the path is a file, 'directory' if it's a directory, - * 'link' if it's a symbolic link, or null if none of these. + * 'link' if it's a symbolic link, or null if none of these. */ public static function getPathType(string $path): ?string { @@ -231,29 +241,15 @@ public static function join(string ...$segments): string return ''; } - $path = array_shift($segments) ?? ''; + $path = array_shift($segments); foreach ($segments as $segment) { if ($segment === '') { continue; } if (self::hasScheme($path)) { - if (preg_match('/^([a-zA-Z0-9._-]+):\/\/(.*)$/', $path, $matches) === 1) { - $scheme = strtolower($matches[1]); - $base = trim(str_replace('\\', '/', $matches[2]), '/'); - $next = trim(str_replace('\\', '/', $segment), '/'); - - if ($next === '') { - $path = $scheme . '://' . $base; - continue; - } - - $base = $base === '' ? $next : $base . '/' . $next; - $path = $scheme . '://' . $base; - continue; - } + $path = self::joinSchemeSegment($path, $segment); - $path = rtrim(str_replace('\\', '/', $path), '/') . '/' . ltrim(str_replace('\\', '/', $segment), '/'); continue; } @@ -323,6 +319,7 @@ public static function relativePath(string $from, string $to): string array_shift($from); array_shift($to); } + return str_repeat('..' . DIRECTORY_SEPARATOR, count($from)) . implode(DIRECTORY_SEPARATOR, $to); } @@ -353,9 +350,13 @@ public static function toAbsolutePath(string $path, ?string $base = null): strin return self::normalize($path); } $base ??= getcwd() ?: '.'; + return self::normalize(self::join($base, $path)); } + /** + * @param list $stack + */ private static function buildNormalizedPath(string $prefix, array $stack): string { $normalized = $prefix . implode(DIRECTORY_SEPARATOR, $stack); @@ -382,16 +383,38 @@ private static function extractPathPrefix(string $path): array return ['', $path]; } + private static function joinSchemeSegment(string $path, string $segment): string + { + if (preg_match('/^([a-zA-Z0-9._-]+):\/\/(.*)$/', $path, $matches) !== 1) { + return rtrim(str_replace('\\', '/', $path), '/') . '/' . ltrim(str_replace('\\', '/', $segment), '/'); + } + + $scheme = strtolower($matches[1]); + $base = trim(str_replace('\\', '/', $matches[2]), '/'); + $next = trim(str_replace('\\', '/', $segment), '/'); + if ($next === '') { + return $scheme . '://' . $base; + } + + $base = $base === '' ? $next : $base . '/' . $next; + + return $scheme . '://' . $base; + } + /** - * @return array + * @return list */ private static function normalizePathParts(string $path, bool $hasAbsolutePrefix): array { $parts = preg_split('/[\\\\\\/]+/', $path, -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($parts)) { + return []; + } + $stack = []; foreach ($parts as $part) { - if ($part === '' || $part === '.') { + if ($part === '.') { continue; } @@ -422,10 +445,14 @@ private static function normalizeSchemePath(string $path): string $leadingSlash = str_starts_with($location, '/'); $parts = preg_split('/\/+/', trim($location, '/'), -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($parts)) { + return $scheme . '://'; + } + $stack = []; foreach ($parts as $part) { - if ($part === '.' || $part === '') { + if ($part === '.') { continue; } diff --git a/src/Utils/PermissionsHelper.php b/src/Utils/PermissionsHelper.php index c4ed9e0..6a292df 100644 --- a/src/Utils/PermissionsHelper.php +++ b/src/Utils/PermissionsHelper.php @@ -1,11 +1,14 @@ */ private static array $permissionCache = []; /** @@ -22,7 +25,6 @@ public static function canExecute(string $path): bool return is_executable($path); } - /** * Checks if the specified path is readable. * @@ -85,7 +87,6 @@ public static function formatPermissions(int $permissions): string return array_reduce(array_keys($flags), fn($info, $flag) => $info . (($permissions & $flag) ? $flags[$flag] : '-'), ''); } - /** * Converts the permissions of a file or directory into a human-readable string. * @@ -105,7 +106,12 @@ public static function getHumanReadablePermissions(string $path): ?string return null; } - return self::formatPermissions(fileperms($path)); + $permissions = fileperms($path); + if (!is_int($permissions)) { + return null; + } + + return self::formatPermissions($permissions); } /** @@ -118,23 +124,40 @@ public static function getHumanReadablePermissions(string $path): ?string * null. * * @param string $path The path to the file or directory to retrieve - * ownership for. - * @return array|null An array containing the owner and group of the file - * or directory, or null if the file or directory does not exist, or - * if ownership functions are not supported on the current system. + * ownership for. + * @return array{owner: string|null, group: string|null}|null An array containing the owner and group of the file + * or directory, or null if the file or directory does not exist, or + * if ownership functions are not supported on the current system. */ public static function getOwnership(string $path): ?array { if (!self::isPosixSupported()) { - throw new RuntimeException("Ownership functions are only supported on Unix-based systems."); + throw new RuntimeException('Ownership functions are only supported on Unix-based systems.'); } if (!file_exists($path)) { return null; } - $owner = posix_getpwuid(fileowner($path))['name'] ?? null; - $group = posix_getgrgid(filegroup($path))['name'] ?? null; + $ownerId = fileowner($path); + $groupId = filegroup($path); + + $owner = null; + if (is_int($ownerId)) { + $ownerInfo = posix_getpwuid($ownerId); + if (is_array($ownerInfo)) { + $owner = $ownerInfo['name']; + } + } + + $group = null; + if (is_int($groupId)) { + $groupInfo = posix_getgrgid($groupId); + if (is_array($groupInfo)) { + $group = $groupInfo['name']; + } + } + return compact('owner', 'group'); } @@ -147,7 +170,7 @@ public static function getOwnership(string $path): ?array * * @param string $path The path to the file or directory. * @return string|null The permissions as an octal string, or null if the - * path does not exist. + * path does not exist. */ public static function getPermissions(string $path): ?string { @@ -155,7 +178,16 @@ public static function getPermissions(string $path): ?string return null; } - return self::$permissionCache[$path]['permissions'] ??= substr(sprintf('%04o', fileperms($path)), -4); + if (!isset(self::$permissionCache[$path])) { + $permissions = fileperms($path); + if (!is_int($permissions)) { + return null; + } + + self::$permissionCache[$path] = substr(sprintf('%04o', $permissions), -4); + } + + return self::$permissionCache[$path]; } /** @@ -175,13 +207,12 @@ public static function isOwnedByCurrentUser(string $path): bool * @param string $path The path to the file or directory to set ownership on. * @param string $owner The username of the new owner. * @param string|null $group The groupname of the new group, or null to leave the group unchanged. - * @return $this * @throws RuntimeException If the operation fails or if ownership functions are not supported on the current system. */ public static function setOwnership(string $path, string $owner, ?string $group = null): self { if (!self::isPosixSupported()) { - throw new RuntimeException("Ownership functions are only supported on Unix-based systems."); + throw new RuntimeException('Ownership functions are only supported on Unix-based systems.'); } $result = chown($path, $owner); @@ -196,7 +227,6 @@ public static function setOwnership(string $path, string $owner, ?string $group return new self(); } - /** * Sets the permissions of the file or directory at the given path. * @@ -206,7 +236,6 @@ public static function setOwnership(string $path, string $owner, ?string $group * * @param string $path The path to the file or directory to set permissions on. * @param int $permissions The new permissions for the file or directory. - * @return $this * @throws RuntimeException If the operation fails. */ public static function setPermissions(string $path, int $permissions): self @@ -214,6 +243,7 @@ public static function setPermissions(string $path, int $permissions): self if (!chmod($path, $permissions)) { throw new RuntimeException("Failed to set permissions on {$path}"); } + return new self(); } @@ -227,7 +257,7 @@ public static function setPermissions(string $path, int $permissions): self * they are not available, it returns false. * * @return bool True if POSIX-style ownership functions are supported, - * false otherwise. + * false otherwise. */ private static function isPosixSupported(): bool { diff --git a/src/functions.php b/src/functions.php index b1aaf65..cca911b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -1,5 +1,7 @@ 0 ? floor(log($sizeInBytes, 1024)) : 0; + $power = $sizeInBytes > 0 ? (int) floor(log($sizeInBytes, 1024)) : 0; + return number_format($sizeInBytes / (1024 ** $power), 2) . ' ' . $units[$power]; } } @@ -37,7 +40,7 @@ function isDirectoryEmpty(string $directoryPath): bool { $isLocalDirectory = !PathHelper::hasScheme($directoryPath) && is_dir($directoryPath); if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { - throw new InvalidArgumentException("The provided path is not a directory."); + throw new InvalidArgumentException('The provided path is not a directory.'); } $contents = FlysystemHelper::listContents($directoryPath, false); @@ -76,7 +79,7 @@ function getDirectorySize(string $directoryPath): int { $isLocalDirectory = !PathHelper::hasScheme($directoryPath) && is_dir($directoryPath); if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { - throw new InvalidArgumentException("The provided path is not a directory."); + throw new InvalidArgumentException('The provided path is not a directory.'); } $size = 0; @@ -87,11 +90,17 @@ function getDirectorySize(string $directoryPath): int if ($item instanceof FileAttributes && is_int($item->fileSize())) { $size += $item->fileSize(); + continue; } $extra = $item->extraMetadata(); - $size += (int) ($extra['file_size'] ?? $extra['filesize'] ?? 0); + $extraSize = $extra['file_size'] ?? $extra['filesize'] ?? 0; + if (is_int($extraSize)) { + $size += $extraSize; + } elseif (is_numeric($extraSize)) { + $size += (int) $extraSize; + } } return $size; @@ -115,7 +124,13 @@ function createDirectory(string $directoryPath, int $permissions = 0755): bool FlysystemHelper::createDirectory($directoryPath); if (!PathHelper::hasScheme($directoryPath)) { - @chmod($directoryPath, $permissions); + set_error_handler(static fn(): bool => true); + + try { + chmod($directoryPath, $permissions); + } finally { + restore_error_handler(); + } } return true; @@ -127,24 +142,32 @@ function createDirectory(string $directoryPath, int $permissions = 0755): bool * List all files in a directory. * * @param string $directoryPath The directory path. - * @return array List of files (excluding directories). + * @return list List of files (excluding directories). */ function listFiles(string $directoryPath): array { $isLocalDirectory = !PathHelper::hasScheme($directoryPath) && is_dir($directoryPath); if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { - throw new InvalidArgumentException("The provided path is not a directory."); + throw new InvalidArgumentException('The provided path is not a directory.'); } $items = FlysystemHelper::listContents($directoryPath, false); $files = []; foreach ($items as $item) { - if (($item['type'] ?? null) === 'file') { - $files[] = basename((string) ($item['path'] ?? '')); + $type = $item['type'] ?? null; + if (!is_string($type) || $type !== 'file') { + continue; } + + $path = $item['path'] ?? null; + if (!is_string($path) || $path === '') { + continue; + } + + $files[] = basename($path); } - return array_values($files); + return $files; } } diff --git a/tests/Feature/FileWatcherTest.php b/tests/Feature/FileWatcherTest.php index 1d61ff9..8f1aa5c 100644 --- a/tests/Feature/FileWatcherTest.php +++ b/tests/Feature/FileWatcherTest.php @@ -65,7 +65,8 @@ if ($pid === 0) { usleep(300000); file_put_contents($targetFile, 'changed'); - exit(0); + pcntl_exec(PHP_BINARY, ['-r', 'exit(0);']); + throw new RuntimeException('Failed to terminate child process.'); } FileWatcher::watch($this->watchDir, function (array $diff) use (&$events) { diff --git a/tests/Feature/PolicyEngineTest.php b/tests/Feature/PolicyEngineTest.php index ae25ce4..6d9f480 100644 --- a/tests/Feature/PolicyEngineTest.php +++ b/tests/Feature/PolicyEngineTest.php @@ -15,7 +15,11 @@ test('it supports conditional policy rules', function () { $policy = new PolicyEngine(); $policy->deny('delete', '*'); - $policy->allow('delete', '*', fn (string $_operation, string $_path, array $context): bool => ($context['force'] ?? false) === true); + $policy->allow('delete', '*', function (string $operation, string $path, array $context): bool { + unset($operation, $path); + + return ($context['force'] ?? false) === true; + }); expect($policy->isAllowed('delete', '/tmp/file.txt', ['force' => false]))->toBeFalse() ->and($policy->isAllowed('delete', '/tmp/file.txt', ['force' => true]))->toBeTrue(); @@ -26,4 +30,3 @@ expect(fn () => $policy->assertAllowed('delete', '/tmp/file.txt'))->toThrow(PolicyViolationException::class); }); - diff --git a/tests/Feature/UploadProcessorTest.php b/tests/Feature/UploadProcessorTest.php index d239ca7..6d92dc6 100644 --- a/tests/Feature/UploadProcessorTest.php +++ b/tests/Feature/UploadProcessorTest.php @@ -130,7 +130,11 @@ test('it exposes malware scanner state in info', function () { $this->uploadProcessor->setDirectorySettings($this->uploadDir); - $this->uploadProcessor->setMalwareScanner(fn(string $_path, string $_mime): bool => true); + $this->uploadProcessor->setMalwareScanner(function (string $path, string $mime): bool { + unset($path, $mime); + + return true; + }); expect($this->uploadProcessor->getInfo()['hasMalwareScanner'])->toBeTrue(); }); @@ -150,7 +154,11 @@ 'name' => 'chunk.part', ], $uploadId, 0, 1, 'merged.txt'); - $this->uploadProcessor->setMalwareScanner(fn(string $_path, string $_mime): bool => false); + $this->uploadProcessor->setMalwareScanner(function (string $path, string $mime): bool { + unset($path, $mime); + + return false; + }); expect(fn() => $this->uploadProcessor->finalizeChunkUpload($uploadId)) ->toThrow(UploadException::class, 'Malware scan failed');