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