From d9d2c68495e0eb2e91239c1474759252aba5c11b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 5 Apr 2026 11:04:58 +0600 Subject: [PATCH 1/8] readme --- README.md | 101 ++++++++++++++++-------------------------------------- 1 file changed, 30 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index ac0675f..858beab 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,40 @@ # Pathwise: File Management Made Simple -[![Codacy Badge](https://app.codacy.com/project/badge/Grade/5028848d26e34f5e883aa248a8885811)](https://app.codacy.com/gh/infocyph/Pathwise/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) -![Libraries.io dependency status for GitHub repo](https://img.shields.io/librariesio/github/infocyph/pathwise) -![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/pathwise) +[![Security & Standards](https://github.com/infocyph/Pathwise/actions/workflows/build.yml/badge.svg)](https://github.com/infocyph/Pathwise/actions/workflows/build.yml) +[![Documentation](https://img.shields.io/badge/Documentation-Pathwise-blue?logo=readthedocs&logoColor=white)](https://docs.infocyph.com/projects/pathwise/) +![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/pathwise?color=green&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2Fpathwise) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) ![Packagist Version](https://img.shields.io/packagist/v/infocyph/pathwise) -![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/infocyph/pathwise) -![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/infocyph/pathwise) +![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/pathwise/php) +![GitHub Code Size](https://img.shields.io/github/languages/code-size/infocyph/pathwise) -Pathwise is a robust PHP library designed as **Flysystem + more** for streamlined file and directory management. It combines Flysystem-backed storage operations with higher-level workflows like safe reading/writing, metadata extraction, compression, upload pipelines, policy enforcement, and observability. +Pathwise is a robust PHP library designed as streamlined file and directory management. It combines storage operations with higher-level workflows like safe reading/writing, metadata extraction, compression, upload pipelines, policy enforcement and observability. ## **Table of Contents** 1. [Introduction](#pathwise-file-management-made-simple) 2. [Prerequisites](#prerequisites) 3. [Installation](#installation) 4. [Features Overview](#features-overview) -5. [Documentation Map](#documentation-map) -6. [Quality Gates](#quality-gates) -7. [FileManager](#filemanager) +5. [Quality Gates](#quality-gates) +6. [FileManager](#filemanager) - [SafeFileReader](#safefilereader) - [SafeFileWriter](#safefilewriter) - [FileOperations](#fileoperations) - [FileCompression](#filecompression) -8. [DirectoryManager](#directorymanager) +7. [DirectoryManager](#directorymanager) - [DirectoryOperations](#directoryoperations) -9. [Utils](#utils) +8. [Utils](#utils) - [PathHelper](#pathhelper) - [PermissionsHelper](#permissionshelper) - [MetadataHelper](#metadatahelper) -10. [Handy Functions](#handy-functions) - - [File and Directory Utilities](#file-and-directory-utilities) -11. [Support](#support) -12. [License](#license) +9. [Handy Functions](#handy-functions) + - [File and Directory Utilities](#file-and-directory-utilities) +10. [Support](#support) +11. [License](#license) ## **Prerequisites** - Language: PHP 8.4/+ - - ## **Installation** Pathwise is available via Composer: @@ -47,7 +44,6 @@ composer require infocyph/pathwise Requirements: - PHP 8.4 or higher -- `league/flysystem` 3.x - `ext-fileinfo` - Optional Extensions: - `ext-zip`: Required for compression features. @@ -59,54 +55,17 @@ Requirements: ## **Features Overview** -- Flysystem-first filesystem operations across core modules. +- Filesystem operations across core modules. - Mount support with scheme paths (`name://path`) and default filesystem support for relative paths. - Advanced file APIs: checksum verification, visibility controls, URL passthrough (`publicUrl`, `temporaryUrl`). - Directory automation: sync with diff report, recursive copy/move/delete, mounted-path ZIP/unzip bridging. - Upload pipelines: chunked/resumable uploads, validation profiles (image/video/document), malware-scan hook. - Compression workflows: include/exclude glob patterns, ignore files, progress callbacks, hooks, optional native acceleration. -- Operational tooling: `AuditTrail`, `FileJobQueue`, `FileWatcher`, `RetentionManager`, and policy engine support. - -## **Documentation Map** - -If you are new to Pathwise, read in this order: - -1. [Overview](docs/overview.rst) -2. [Capabilities](docs/capabilities.rst) -3. [Quickstart](docs/quickstart.rst) -4. [Recipes](docs/recipes.rst) - -Then use module pages for details: - -- [File Manager](docs/file-manager.rst) -- [Directory Manager](docs/directory-manager.rst) -- [Upload Processing](docs/upload-processing.rst) -- [Security](docs/security.rst) -- [Queue](docs/queue.rst) -- [Observability](docs/observability.rst) -- [Indexing](docs/indexing.rst) -- [Retention](docs/retention.rst) -- [Utilities](docs/utilities.rst) - -## **Quality Gates** - -Pathwise includes a cognitive complexity gate powered by `tomasvotruba/cognitive-complexity` on top of PHPStan. - -Run it locally: - -```bash -composer test:phpstan -``` - -Current thresholds (see `phpstan.neon.dist`): - -- Class cognitive complexity: `250` -- Function/method cognitive complexity: `9` -- Dependency-tree complexity: `400` +- Operational tooling: `AuditTrail`, `FileJobQueue`, `FileWatcher`, `RetentionManager` and policy engine support. ## **FileManager** -The `FileManager` module provides classes for handling files, including reading, writing, compressing, and general file operations. +The `FileManager` module provides classes for handling files, including reading, writing, compressing and general file operations. ### **SafeFileReader** @@ -115,7 +74,7 @@ A memory-safe file reader supporting various reading modes (line-by-line, binary #### **Key Features** - Supports multiple reading modes. - Provides locking to prevent concurrent access issues. -- Implements `Countable`, `Iterator`, and `SeekableIterator`. +- Implements `Countable`, `Iterator` and `SeekableIterator`. #### **Usage Example** @@ -137,7 +96,7 @@ foreach ($reader->json() as $data) { ### **SafeFileWriter** -A memory-safe file writer with support for various writing modes, including CSV, JSON, binary, and more. +A memory-safe file writer with support for various writing modes, including CSV, JSON, binary and more. #### **Key Features** - Supports multiple writing modes. @@ -160,12 +119,12 @@ $writer->json(['key' => 'value']); ### **FileOperations** -General-purpose file handling class for creating, deleting, copying, renaming, and manipulating files. +General-purpose file handling class for creating, deleting, copying, renaming and manipulating files. #### **Key Features** - File creation and deletion. - Append and update content. -- Rename, copy, and metadata retrieval. +- Rename, copy and metadata retrieval. #### **Usage Example** @@ -210,16 +169,16 @@ $compression->decompress('/path/to/extract/'); ## **DirectoryManager** -The `DirectoryManager` module offers tools for handling directory creation, deletion, and traversal. +The `DirectoryManager` module offers tools for handling directory creation, deletion and traversal. ### **DirectoryOperations** -Provides comprehensive tools for managing directories, including creation, deletion, copying, and listing contents. +Provides comprehensive tools for managing directories, including creation, deletion, copying and listing contents. #### **Key Features** -- Create, delete, and copy directories. -- Retrieve directory size, depth, and contents. +- Create, delete and copy directories. +- Retrieve directory size, depth and contents. - Supports recursive operations and filtering. #### **Usage Example** @@ -241,12 +200,12 @@ print_r($contents); ## **Utils** -Utility classes for managing paths, permissions, and metadata. +Utility classes for managing paths, permissions and metadata. ### **PathHelper** -Provides utilities for working with file paths, including joining, normalizing, and converting between relative and absolute paths. +Provides utilities for working with file paths, including joining, normalizing and converting between relative and absolute paths. #### **Key Features** - Path joining and normalization. @@ -267,11 +226,11 @@ echo $joinedPath; ### **PermissionsHelper** -Handles file and directory permissions, ownership, and access control. +Handles file and directory permissions, ownership and access control. #### **Key Features** - Retrieve and set permissions. -- Check read, write, and execute access. +- Check read, write and execute access. - Retrieve and set ownership details. #### **Usage Example** @@ -290,7 +249,7 @@ if (PermissionsHelper::canWrite('/path/to/file')) { ### **MetadataHelper** -Extracts metadata for files and directories, such as size, timestamps, MIME type, and more. +Extracts metadata for files and directories, such as size, timestamps, MIME type and more. #### **Key Features** - Retrieve file size and type. From d053f4dcee4f41be3d43615a106c9ff9a6134376 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 5 Apr 2026 22:13:16 +0600 Subject: [PATCH 2/8] chores --- composer.json | 1 + pest.xml | 22 ++++++++++++---------- phpunit.xml | 4 ++-- pint.json | 3 +-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index c9bd701..598f8c9 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "test": "@php vendor/bin/pest", "refactor": "@php vendor/bin/rector process", "lint": "@php vendor/bin/pint", + "security:scan": "@test:security", "post-autoload-dump": "@php vendor/bin/captainhook install --only-enabled -nf" } } diff --git a/pest.xml b/pest.xml index 8f88bf3..f504e3d 100644 --- a/pest.xml +++ b/pest.xml @@ -1,14 +1,16 @@ - + - - tests/Unit - - - tests/Feature - - - tests/Integration + + ./tests - \ No newline at end of file + + + ./src + + + diff --git a/phpunit.xml b/phpunit.xml index f504e3d..2dbe577 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,12 +5,12 @@ colors="true"> - ./tests + tests - ./src + src diff --git a/pint.json b/pint.json index 69de9d0..e6b7949 100644 --- a/pint.json +++ b/pint.json @@ -1,8 +1,7 @@ { "preset": "psr12", "exclude": [ - "tests", - "var" + "tests" ], "notPath": [ "rector.php" From 1fd57ea0b6de3512a79ba34f9c98b1b096f77aab Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 6 Apr 2026 20:17:05 +0600 Subject: [PATCH 3/8] chores --- .github/workflows/build.yml | 10 ++-------- composer.json | 4 ++-- pest.xml | 34 ++++++++++++++++++++-------------- phpunit.xml | 34 ++++++++++++++++++++-------------- 4 files changed, 44 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9bc2885..25bc6b2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,15 +56,9 @@ jobs: run: | composer test:code composer test:lint - # Skip cognitive complexity gate on prefer-lowest to keep matrix runtime stable. - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:static - fi + composer test:static composer test:refactor - # Skip Psalm on prefer-lowest: older transitive amphp versions can trigger PHP 8.4+ deprecations at startup. - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:security - fi + composer test:security analyze: needs: prepare diff --git a/composer.json b/composer.json index 598f8c9..fe4c648 100644 --- a/composer.json +++ b/composer.json @@ -50,8 +50,8 @@ }, "scripts": { "test:code": "@php vendor/bin/pest --parallel --processes=10", - "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", - "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", + "test:security": "@php -d error_reporting=24575 vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", + "test:static": "@php -d error_reporting=24575 vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", "test:refactor": "@php vendor/bin/rector process --dry-run", "test:lint": "@php vendor/bin/pint --test", "tests": [ diff --git a/pest.xml b/pest.xml index f504e3d..d5d12d8 100644 --- a/pest.xml +++ b/pest.xml @@ -1,16 +1,22 @@ - - - - ./tests - - - - - ./src - - + + + + ./tests + + + + + ./src + + + + + + diff --git a/phpunit.xml b/phpunit.xml index 2dbe577..d5d12d8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,16 +1,22 @@ - - - - tests - - - - - src - - + + + + ./tests + + + + + ./src + + + + + + From f2b804248ca3a403e3e6a378eeb271343a92d3ea Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 6 Apr 2026 20:21:02 +0600 Subject: [PATCH 4/8] chores --- .github/workflows/build.yml | 8 ++++++-- composer.json | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25bc6b2..bfb3be9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,8 +56,12 @@ jobs: run: | composer test:code composer test:lint - composer test:static - composer test:refactor + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer test:static + fi + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer test:security + fi composer test:security analyze: diff --git a/composer.json b/composer.json index fe4c648..598f8c9 100644 --- a/composer.json +++ b/composer.json @@ -50,8 +50,8 @@ }, "scripts": { "test:code": "@php vendor/bin/pest --parallel --processes=10", - "test:security": "@php -d error_reporting=24575 vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", - "test:static": "@php -d error_reporting=24575 vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", + "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", + "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", "test:refactor": "@php vendor/bin/rector process --dry-run", "test:lint": "@php vendor/bin/pint --test", "tests": [ From c6fd3a44dcfc5eee129b83c55e133287df493d26 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 6 Apr 2026 20:22:54 +0600 Subject: [PATCH 5/8] chores --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 598f8c9..96dec65 100644 --- a/composer.json +++ b/composer.json @@ -49,11 +49,11 @@ } }, "scripts": { - "test:code": "@php vendor/bin/pest --parallel --processes=10", - "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", - "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", - "test:refactor": "@php vendor/bin/rector process --dry-run", - "test:lint": "@php vendor/bin/pint --test", + "test:code": "@php -d error_reporting=24575 vendor/bin/pest --parallel --processes=10", + "test:security": "@php -d error_reporting=24575 vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", + "test:static": "@php -d error_reporting=24575 vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", + "test:refactor": "@php -d error_reporting=24575 vendor/bin/rector process --dry-run", + "test:lint": "@php -d error_reporting=24575 vendor/bin/pint --test", "tests": [ "@test:code", "@test:lint", From 1212c797272afae223530e2574a2b02185b8ef9a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 6 Apr 2026 20:30:04 +0600 Subject: [PATCH 6/8] chores --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfb3be9..0261ce7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,13 +56,13 @@ jobs: run: | composer test:code composer test:lint + composer test:refactor if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then composer test:static fi if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then composer test:security fi - composer test:security analyze: needs: prepare From 3d855aec12181739e88ade8faef838a75c850650 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 13 Apr 2026 16:01:56 +0600 Subject: [PATCH 7/8] standardize --- .gitattributes | 21 +- .github/scripts/composer-audit-guard.php | 85 ++++++ .github/scripts/phpstan-sarif.php | 178 ++++++++++++ .github/scripts/syntax.php | 109 ++++++++ .github/workflows/build.yml | 26 +- .gitignore | 26 +- captainhook.json | 2 +- composer.json | 77 ++++-- phpbench.json | 26 ++ phpcs.xml.dist | 52 ++++ phpstan.neon.dist | 2 +- pint.json | 2 +- psalm.xml | 9 +- rector.php | 23 +- src/DirectoryManager/DirectoryOperations.php | 172 +++++++----- src/Exceptions/CompressionException.php | 4 +- .../DirectoryOperationException.php | 4 +- src/Exceptions/FileAccessException.php | 4 +- src/Exceptions/FileNotFoundException.php | 4 +- src/Exceptions/FileSizeExceededException.php | 4 +- src/Exceptions/PolicyViolationException.php | 4 +- src/Exceptions/UploadException.php | 4 +- src/FileManager/Concerns/FsConcern.php | 4 +- src/FileManager/FileCompression.php | 40 +-- src/FileManager/FileOperations.php | 47 ---- src/FileManager/SafeFileReader.php | 39 ++- src/FileManager/SafeFileWriter.php | 4 +- src/Indexing/ChecksumIndexer.php | 2 +- src/Observability/AuditTrail.php | 4 +- src/Queue/FileJobQueue.php | 8 +- src/Retention/RetentionManager.php | 6 +- src/StreamHandler/UploadProcessor.php | 255 +++++++++++------- src/Utils/FileWatcher.php | 6 +- src/Utils/FlysystemHelper.php | 7 +- src/Utils/MetadataHelper.php | 2 +- src/Utils/PathHelper.php | 5 +- src/Utils/PermissionsHelper.php | 2 +- tests/Feature/DirectoryOperationsTest.php | 31 +++ tests/Feature/SafeFileReaderTest.php | 9 + 39 files changed, 936 insertions(+), 373 deletions(-) create mode 100644 .github/scripts/composer-audit-guard.php create mode 100644 .github/scripts/phpstan-sarif.php create mode 100644 .github/scripts/syntax.php create mode 100644 phpbench.json create mode 100644 phpcs.xml.dist diff --git a/.gitattributes b/.gitattributes index 72b5c2e..2755315 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,21 @@ +.github export-ignore +benchmarks export-ignore +docs export-ignore +examples export-ignore +tests export-ignore + .editorconfig export-ignore +.gitattributes export-ignore .gitignore export-ignore -tests export-ignore -docs export-ignore -.github export-ignore .readthedocs.yaml export-ignore captainhook.json export-ignore +pest.xml export-ignore +phpbench.json export-ignore +phpcs.xml.dist export-ignore +phpstan.neon.dist export-ignore phpunit.xml export-ignore pint.json export-ignore -rector.php export-ignore -rector-dead-code.php export-ignore -.gitattributes export-ignore psalm.xml export-ignore -pest.xml export-ignore +rector.php export-ignore -* text eol=lf +* text eol=lf diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php new file mode 100644 index 0000000..a1b1cdb --- /dev/null +++ b/.github/scripts/composer-audit-guard.php @@ -0,0 +1,85 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], +]; + +$process = proc_open($command, $descriptorSpec, $pipes); + +if (! \is_resource($process)) { + fwrite(STDERR, "Failed to start composer audit process.\n"); + exit(1); +} + +fclose($pipes[0]); +$stdout = stream_get_contents($pipes[1]) ?: ''; +$stderr = stream_get_contents($pipes[2]) ?: ''; +fclose($pipes[1]); +fclose($pipes[2]); + +$exitCode = proc_close($process); + +/** @var array|null $decoded */ +$decoded = json_decode($stdout, true); + +if (! \is_array($decoded)) { + fwrite(STDERR, "Unable to parse composer audit JSON output.\n"); + if (trim($stdout) !== '') { + fwrite(STDERR, $stdout . "\n"); + } + if (trim($stderr) !== '') { + fwrite(STDERR, $stderr . "\n"); + } + + exit($exitCode !== 0 ? $exitCode : 1); +} + +$advisories = $decoded['advisories'] ?? []; +$abandoned = $decoded['abandoned'] ?? []; + +$advisoryCount = 0; + +if (\is_array($advisories)) { + foreach ($advisories as $entries) { + if (\is_array($entries)) { + $advisoryCount += \count($entries); + } + } +} + +$abandonedPackages = []; + +if (\is_array($abandoned)) { + foreach ($abandoned as $package => $replacement) { + if (\is_string($package) && $package !== '') { + $abandonedPackages[$package] = $replacement; + } + } +} + +echo sprintf( + "Composer audit summary: %d advisories, %d abandoned packages.\n", + $advisoryCount, + \count($abandonedPackages), +); + +if ($abandonedPackages !== []) { + fwrite(STDERR, "Warning: abandoned packages detected (non-blocking):\n"); + foreach ($abandonedPackages as $package => $replacement) { + $target = \is_string($replacement) && $replacement !== '' ? $replacement : 'none'; + fwrite(STDERR, sprintf(" - %s (replacement: %s)\n", $package, $target)); + } +} + +if ($advisoryCount > 0) { + fwrite(STDERR, "Security vulnerabilities detected by composer audit.\n"); + exit(1); +} + +exit(0); diff --git a/.github/scripts/phpstan-sarif.php b/.github/scripts/phpstan-sarif.php new file mode 100644 index 0000000..2b01b26 --- /dev/null +++ b/.github/scripts/phpstan-sarif.php @@ -0,0 +1,178 @@ + [sarif-output] + */ + +$argv = $_SERVER['argv'] ?? []; +$input = $argv[1] ?? ''; +$output = $argv[2] ?? 'phpstan-results.sarif'; + +if (! is_string($input) || $input === '') { + fwrite(STDERR, "Error: missing input file.\n"); + fwrite(STDERR, "Usage: php .github/scripts/phpstan-sarif.php [sarif-output]\n"); + exit(2); +} + +if (! is_file($input) || ! is_readable($input)) { + fwrite(STDERR, "Error: input file not found or unreadable: {$input}\n"); + exit(2); +} + +$raw = file_get_contents($input); +if ($raw === false) { + fwrite(STDERR, "Error: failed to read input file: {$input}\n"); + exit(2); +} + +$decoded = json_decode($raw, true); +if (! is_array($decoded)) { + fwrite(STDERR, "Error: input is not valid JSON.\n"); + exit(2); +} + +/** + * @return non-empty-string + */ +function normalizeUri(string $path): string +{ + $normalized = str_replace('\\', '/', $path); + $cwd = getcwd(); + + if (is_string($cwd) && $cwd !== '') { + $cwd = rtrim(str_replace('\\', '/', $cwd), '/'); + + if (preg_match('/^[A-Za-z]:\//', $normalized) === 1) { + if (stripos($normalized, $cwd . '/') === 0) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } elseif (str_starts_with($normalized, '/')) { + if (str_starts_with($normalized, $cwd . '/')) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } + } + + $normalized = ltrim($normalized, './'); + + return $normalized === '' ? 'unknown.php' : $normalized; +} + +$results = []; +$rules = []; + +$globalErrors = $decoded['errors'] ?? []; +if (is_array($globalErrors)) { + foreach ($globalErrors as $error) { + if (! is_string($error) || $error === '') { + continue; + } + + $ruleId = 'phpstan.internal'; + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $error, + ], + ]; + } +} + +$files = $decoded['files'] ?? []; +if (is_array($files)) { + foreach ($files as $filePath => $fileData) { + if (! is_string($filePath) || ! is_array($fileData)) { + continue; + } + + $messages = $fileData['messages'] ?? []; + if (! is_array($messages)) { + continue; + } + + foreach ($messages as $messageData) { + if (! is_array($messageData)) { + continue; + } + + $messageText = (string) ($messageData['message'] ?? 'PHPStan issue'); + $line = (int) ($messageData['line'] ?? 1); + $identifier = (string) ($messageData['identifier'] ?? ''); + $ruleId = $identifier !== '' ? $identifier : 'phpstan.issue'; + + if ($line < 1) { + $line = 1; + } + + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $messageText, + ], + 'locations' => [[ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => normalizeUri($filePath), + ], + 'region' => [ + 'startLine' => $line, + ], + ], + ]], + ]; + } + } +} + +$ruleDescriptors = []; +$ruleIds = array_keys($rules); +sort($ruleIds); + +foreach ($ruleIds as $ruleId) { + $ruleDescriptors[] = [ + 'id' => $ruleId, + 'name' => $ruleId, + 'shortDescription' => [ + 'text' => $ruleId, + ], + ]; +} + +$sarif = [ + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'version' => '2.1.0', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPStan', + 'informationUri' => 'https://phpstan.org/', + 'rules' => $ruleDescriptors, + ], + ], + 'results' => $results, + ]], +]; + +$encoded = json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +if (! is_string($encoded)) { + fwrite(STDERR, "Error: failed to encode SARIF JSON.\n"); + exit(2); +} + +$written = file_put_contents($output, $encoded . PHP_EOL); +if ($written === false) { + fwrite(STDERR, "Error: failed to write output file: {$output}\n"); + exit(2); +} + +fwrite(STDOUT, sprintf("SARIF generated: %s (%d findings)\n", $output, count($results))); +exit(0); diff --git a/.github/scripts/syntax.php b/.github/scripts/syntax.php new file mode 100644 index 0000000..043bf53 --- /dev/null +++ b/.github/scripts/syntax.php @@ -0,0 +1,109 @@ +isFile()) { + continue; + } + + $filename = $entry->getFilename(); + if (! str_ends_with($filename, '.php')) { + continue; + } + + $files[] = $entry->getPathname(); + } +} + +$files = array_values(array_unique($files)); +sort($files); + +if ($files === []) { + fwrite(STDOUT, "No PHP files found.\n"); + exit(0); +} + +$failed = []; + +foreach ($files as $file) { + $command = [PHP_BINARY, '-d', 'display_errors=1', '-l', $file]; + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes); + + if (! is_resource($process)) { + $failed[] = [$file, 'Could not start PHP lint process']; + continue; + } + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + $output = trim((string) $stdout . "\n" . (string) $stderr); + $failed[] = [$file, $output !== '' ? $output : 'Unknown lint failure']; + } +} + +if ($failed === []) { + fwrite(STDOUT, sprintf("Syntax OK: %d PHP files checked.\n", count($files))); + exit(0); +} + +fwrite(STDERR, sprintf("Syntax errors in %d file(s):\n", count($failed))); + +foreach ($failed as [$file, $error]) { + fwrite(STDERR, "- {$file}\n{$error}\n"); +} + +exit(1); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0261ce7..001aea0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,8 +54,10 @@ jobs: - name: Test run: | + composer test:syntax composer test:code composer test:lint + composer test:sniff composer test:refactor if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then composer test:static @@ -85,12 +87,32 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: composer:v2 + coverage: xdebug - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Composer Audit (CVE check) - run: composer audit --no-interaction + - name: Composer Audit (Release Guard) + run: composer release:audit + + - name: Quality Gate (PHPStan) + run: composer test:static + + - name: Security Gate (Psalm) + run: composer test:security + + - name: Run PHPStan (Code Scanning) + run: | + php ./vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true + php .github/scripts/phpstan-sarif.php phpstan-results.json phpstan-results.sarif + continue-on-error: true + + - name: Upload PHPStan Results + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: phpstan-results.sarif + category: "phpstan-${{ matrix.php-versions }}" + if: always() && hashFiles('phpstan-results.sarif') != '' # Run Psalm (Deep Taint Analysis) - name: Run Psalm Security Scan diff --git a/.gitignore b/.gitignore index 5cba62c..961285c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,18 @@ -vendor -example .idea -example.php -test.php -composer.lock -git-story_media +.psalm-cache +.phpunit.cache +.vscode +.windsurf *~ +*.patch *.txt !docs/requirements.txt -.windsurf -.vscode -.phpunit.cache -var -*.patch -patch.php -.psalm-cache AI_CONTEXT.md +composer.lock +example +example.php +git-story_media +patch.php +test.php +var +vendor diff --git a/captainhook.json b/captainhook.json index 8ef4f97..fa19900 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,7 +15,7 @@ "options": [] }, { - "action": "composer audit", + "action": "composer release:audit", "options": [] }, { diff --git a/composer.json b/composer.json index 96dec65..aeb47e3 100644 --- a/composer.json +++ b/composer.json @@ -32,40 +32,73 @@ "require-dev": { "captainhook/captainhook": "^5.29.2", "laravel/pint": "^1.29", - "pestphp/pest": "^4.4.3", + "pestphp/pest": "^4.5", "pestphp/pest-plugin-drift": "^4.1", - "rector/rector": "^2.3.9", + "phpbench/phpbench": "^1.6", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.4.1", + "squizlabs/php_codesniffer": "^4.0.1", "symfony/var-dumper": "^7.3 || ^8.0.8", "tomasvotruba/cognitive-complexity": "^1.1", "vimeo/psalm": "^6.16.1" }, + "scripts": { + "test:syntax": "@php .github/scripts/syntax.php src tests benchmarks examples", + "test:code": "@php vendor/bin/pest", + "test:lint": "@php vendor/bin/pint --test", + "test:sniff": "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=full", + "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", + "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --no-cache", + "test:refactor": "@php vendor/bin/rector process --dry-run --debug", + "test:bench": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "test:details": [ + "@test:syntax", + "@test:code", + "@test:lint", + "@test:sniff", + "@test:static", + "@test:security", + "@test:refactor" + ], + "test:all": [ + "@test:syntax", + "@php vendor/bin/pest --parallel --processes=10", + "@php vendor/bin/pint --test", + "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", + "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", + "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --threads=1 --no-progress --no-cache", + "@php vendor/bin/rector process --dry-run --debug" + ], + "release:audit": "@php .github/scripts/composer-audit-guard.php", + "release:guard": [ + "@composer validate --strict", + "@release:audit", + "@tests" + ], + "process:lint": "@php vendor/bin/pint", + "process:sniff:fix": "@php vendor/bin/phpcbf --standard=phpcs.xml.dist --runtime-set ignore_errors_on_exit 1", + "process:refactor": "@php vendor/bin/rector process", + "process:all": [ + "@process:refactor", + "@process:lint", + "@process:sniff:fix" + ], + "bench:run": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "bench:quick": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate --revs=10 --iterations=3 --warmup=1", + "bench:chart": "@php vendor/bin/phpbench run --config=phpbench.json --report=chart", + "tests": "@test:all", + "process": "@process:all", + "benchmark": "@bench:run", + "post-autoload-dump": "captainhook install --only-enabled -nf" + }, "minimum-stability": "stable", "prefer-stable": true, "config": { "sort-packages": true, "optimize-autoloader": true, + "classmap-authoritative": true, "allow-plugins": { "pestphp/pest-plugin": true } - }, - "scripts": { - "test:code": "@php -d error_reporting=24575 vendor/bin/pest --parallel --processes=10", - "test:security": "@php -d error_reporting=24575 vendor/bin/psalm --config=psalm.xml --security-analysis --show-info=false --no-progress --threads=1", - "test:static": "@php -d error_reporting=24575 vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", - "test:refactor": "@php -d error_reporting=24575 vendor/bin/rector process --dry-run", - "test:lint": "@php -d error_reporting=24575 vendor/bin/pint --test", - "tests": [ - "@test:code", - "@test:lint", - "@test:static", - "@test:refactor", - "@test:security" - ], - "git:hook": "@php vendor/bin/captainhook install --only-enabled -nf", - "test": "@php vendor/bin/pest", - "refactor": "@php vendor/bin/rector process", - "lint": "@php vendor/bin/pint", - "security:scan": "@test:security", - "post-autoload-dump": "@php vendor/bin/captainhook install --only-enabled -nf" } } diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..fff7e05 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,26 @@ +{ + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.file_pattern": "*Bench.php", + "runner.attributes": true, + "runner.annotations": false, + "runner.progress": "dots", + "runner.retry_threshold": 8, + "report.generators": { + "chart": { + "title": "Benchmark Chart", + "description": "Console bar chart grouped by benchmark subject", + "generator": "component", + "components": [ + { + "component": "bar_chart_aggregate", + "x_partition": ["subject_name"], + "bar_partition": ["benchmark_name"], + "y_expr": "mode(partition['result_time_avg'])", + "y_axes_label": "yValue as time precision 1" + } + ] + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..666c061 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,52 @@ + + + Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. + + + + + + + ./src + ./tests + + */vendor/* + */.git/* + */.idea/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 068e2a6..0adc1df 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,6 +9,6 @@ parameters: maximumNumberOfProcesses: 1 cognitive_complexity: class: 150 - function: 15 + function: 14 dependency_tree: 150 dependency_tree_types: [] diff --git a/pint.json b/pint.json index e6b7949..46529c3 100644 --- a/pint.json +++ b/pint.json @@ -1,5 +1,5 @@ { - "preset": "psr12", + "preset": "per", "exclude": [ "tests" ], diff --git a/psalm.xml b/psalm.xml index 37695ab..49a4a35 100644 --- a/psalm.xml +++ b/psalm.xml @@ -3,7 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" - errorLevel="3" + errorLevel="2" > @@ -28,6 +28,13 @@ + + + + + + + diff --git a/rector.php b/rector.php index 83ea877..e30ddf8 100644 --- a/rector.php +++ b/rector.php @@ -3,17 +3,12 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Set\ValueObject\SetList; - -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->paths([__DIR__ . '/src']); - - $setConstant = SetList::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION; - if (!defined($setConstant)) { - $setConstant = SetList::class . '::PHP_84'; - } - - $rectorConfig->sets([ - constant($setConstant), - ]); -}; +use Rector\ValueObject\PhpVersion; + +return RectorConfig::configure() + ->withPaths([__DIR__ . '/src']) + ->withPreparedSets(deadCode: true) + ->withPhpVersion( + constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), + ) + ->withPhpSets(); diff --git a/src/DirectoryManager/DirectoryOperations.php b/src/DirectoryManager/DirectoryOperations.php index cd002b0..94560ba 100644 --- a/src/DirectoryManager/DirectoryOperations.php +++ b/src/DirectoryManager/DirectoryOperations.php @@ -48,59 +48,15 @@ public function copy(string $destination, ?callable $progress = null): bool FlysystemHelper::createDirectory($destination); } - if ( - $this->executionStrategy !== ExecutionStrategy::PHP - && NativeOperationsAdapter::canUseNativeDirectoryCopy() - && $this->isLocalPath($this->path) - && $this->isLocalPath($destination) - ) { - if (is_callable($progress)) { - $progress([ - 'operation' => 'copy', - 'path' => $this->path, - 'current' => 0, - 'total' => 1, - ]); - } - - $native = NativeOperationsAdapter::copyDirectory($this->path, $destination, false); - if ($native['success']) { - if (is_callable($progress)) { - $progress([ - 'operation' => 'copy', - 'path' => $this->path, - 'current' => 1, - 'total' => 1, - ]); - } - - return true; - } - - if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new DirectoryOperationException("Native directory copy failed for '{$this->path}' to '{$destination}'."); - } + if ($this->attemptNativeCopy($destination, $progress)) { + return true; } - if (is_callable($progress)) { - $progress([ - 'operation' => 'copy', - 'path' => $this->path, - 'current' => 0, - 'total' => 1, - ]); - } + $this->emitCopyProgress($progress, 0); FlysystemHelper::copyDirectory($this->path, $destination); - if (is_callable($progress)) { - $progress([ - 'operation' => 'copy', - 'path' => $this->path, - 'current' => 1, - 'total' => 1, - ]); - } + $this->emitCopyProgress($progress, 1); return true; } @@ -198,26 +154,7 @@ public function find(array $criteria = []): array $resolvedPath = $this->buildPath($this->path, $relative); $size = (int) ($item['file_size'] ?? 0); - $permissionsMatch = true; - if (!empty($criteria['permissions']) && !$isWindows) { - $permissionsMatch = false; - if ($this->isLocalPath($resolvedPath) && file_exists($resolvedPath)) { - $permissions = fileperms($resolvedPath); - if (is_int($permissions)) { - $permissionsMatch = ($permissions & 0777) === $criteria['permissions']; - } - } - } - - $conditions = [ - empty($criteria['name']) || str_contains(basename($resolvedPath), (string) $criteria['name']), - empty($criteria['extension']) || pathinfo($resolvedPath, PATHINFO_EXTENSION) === $criteria['extension'], - $permissionsMatch, - empty($criteria['minSize']) || $size >= $criteria['minSize'], - empty($criteria['maxSize']) || $size <= $criteria['maxSize'], - ]; - - if (in_array(false, $conditions, true)) { + if (!$this->matchesFindCriteria($criteria, $resolvedPath, $size, $isWindows)) { continue; } @@ -681,6 +618,27 @@ private function assertZipSourceExists(string $source): void } } + 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), '/'); @@ -695,6 +653,14 @@ private function buildPath(string $basePath, string $relativePath): string 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)) { @@ -728,7 +694,7 @@ private function deleteSyncOrphans(string $destination, array $sourceEntries, ar usort( $destinationItems, - static fn (array $a, array $b): int => strlen((string) ($b['path'] ?? '')) <=> strlen((string) ($a['path'] ?? '')), + static fn(array $a, array $b): int => strlen((string) ($b['path'] ?? '')) <=> strlen((string) ($a['path'] ?? '')), ); foreach ($destinationItems as $item) { @@ -749,6 +715,20 @@ private function deleteSyncOrphans(string $destination, array $sourceEntries, ar } } + 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)) { @@ -788,8 +768,7 @@ private function ensureZipEntryDirectory(string $entry): void private function extractSingleZipEntry(ZipArchive $zip, int $index): void { - $entry = (string) $zip->getNameIndex($index); - $entry = ltrim(str_replace('\\', '/', $entry), '/'); + $entry = $this->sanitizeZipEntryPath((string) $zip->getNameIndex($index)); if ($entry === '') { return; } @@ -843,6 +822,30 @@ 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} */ @@ -965,6 +968,33 @@ private function relativeStoragePath(string $baseLocation, string $itemPath): st 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); diff --git a/src/Exceptions/CompressionException.php b/src/Exceptions/CompressionException.php index 2252fae..216cf8c 100644 --- a/src/Exceptions/CompressionException.php +++ b/src/Exceptions/CompressionException.php @@ -2,6 +2,4 @@ namespace Infocyph\Pathwise\Exceptions; -class CompressionException extends \RuntimeException -{ -} +class CompressionException extends \RuntimeException {} diff --git a/src/Exceptions/DirectoryOperationException.php b/src/Exceptions/DirectoryOperationException.php index 1add6f2..b1533d6 100644 --- a/src/Exceptions/DirectoryOperationException.php +++ b/src/Exceptions/DirectoryOperationException.php @@ -2,6 +2,4 @@ namespace Infocyph\Pathwise\Exceptions; -class DirectoryOperationException extends \RuntimeException -{ -} +class DirectoryOperationException extends \RuntimeException {} diff --git a/src/Exceptions/FileAccessException.php b/src/Exceptions/FileAccessException.php index 71e590e..e6b4296 100644 --- a/src/Exceptions/FileAccessException.php +++ b/src/Exceptions/FileAccessException.php @@ -2,6 +2,4 @@ namespace Infocyph\Pathwise\Exceptions; -class FileAccessException extends \Exception -{ -} +class FileAccessException extends \Exception {} diff --git a/src/Exceptions/FileNotFoundException.php b/src/Exceptions/FileNotFoundException.php index e775ec2..a0f148c 100644 --- a/src/Exceptions/FileNotFoundException.php +++ b/src/Exceptions/FileNotFoundException.php @@ -2,6 +2,4 @@ namespace Infocyph\Pathwise\Exceptions; -class FileNotFoundException extends \Exception -{ -} +class FileNotFoundException extends \Exception {} diff --git a/src/Exceptions/FileSizeExceededException.php b/src/Exceptions/FileSizeExceededException.php index 60ddc9b..99f6657 100644 --- a/src/Exceptions/FileSizeExceededException.php +++ b/src/Exceptions/FileSizeExceededException.php @@ -2,6 +2,4 @@ namespace Infocyph\Pathwise\Exceptions; -class FileSizeExceededException extends \Exception -{ -} +class FileSizeExceededException extends \Exception {} diff --git a/src/Exceptions/PolicyViolationException.php b/src/Exceptions/PolicyViolationException.php index a76195d..ac8d6e2 100644 --- a/src/Exceptions/PolicyViolationException.php +++ b/src/Exceptions/PolicyViolationException.php @@ -2,6 +2,4 @@ namespace Infocyph\Pathwise\Exceptions; -class PolicyViolationException extends \RuntimeException -{ -} +class PolicyViolationException extends \RuntimeException {} diff --git a/src/Exceptions/UploadException.php b/src/Exceptions/UploadException.php index d2e1b3d..bf7c475 100644 --- a/src/Exceptions/UploadException.php +++ b/src/Exceptions/UploadException.php @@ -2,6 +2,4 @@ namespace Infocyph\Pathwise\Exceptions; -class UploadException extends \Exception -{ -} +class UploadException extends \Exception {} diff --git a/src/FileManager/Concerns/FsConcern.php b/src/FileManager/Concerns/FsConcern.php index 08f3b80..d5ca93d 100644 --- a/src/FileManager/Concerns/FsConcern.php +++ b/src/FileManager/Concerns/FsConcern.php @@ -229,7 +229,7 @@ private function doShouldIncludePath(string $relativePath): bool return array_all( array_merge($this->excludePatterns, $this->ignorePatterns), - fn ($pattern) => !fnmatch($pattern, $relativePath) + fn($pattern) => !fnmatch($pattern, $relativePath), ); } @@ -241,7 +241,7 @@ private function doShouldTraverseDirectory(string $relativePath): bool } foreach (array_merge($this->excludePatterns, $this->ignorePatterns) as $pattern) { - $pattern = trim($pattern); + $pattern = trim((string) $pattern); if ($pattern === '') { continue; } diff --git a/src/FileManager/FileCompression.php b/src/FileManager/FileCompression.php index 95b5ba8..3aaa41a 100644 --- a/src/FileManager/FileCompression.php +++ b/src/FileManager/FileCompression.php @@ -129,7 +129,6 @@ public function batchAddFiles(array $files): self * @param array $files An associative array mapping ZIP paths to local paths. * @param string $destination The destination directory to extract to. * - * @return self * * @throws Exception If any of the files fail to extract. */ @@ -379,8 +378,6 @@ public function registerHook(string $event, callable $callback): self * Close the ZIP archive. * * This method is a no-op if the archive is already closed. - * - * @return self */ public function save(): self { @@ -413,7 +410,6 @@ public function setDefaultDecompressionPath(string $path): self * * @param int $algorithm The encryption algorithm to set. Must be one of * ZipArchive::EM_AES_256 or ZipArchive::EM_AES_128. - * @return self * @throws CompressionException If an invalid encryption algorithm is specified. */ public function setEncryptionAlgorithm(int $algorithm): self @@ -438,8 +434,8 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self */ 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 = array_values(array_filter(array_map(trim(...), $includePatterns), fn($v) => $v !== '')); + $this->excludePatterns = array_values(array_filter(array_map(trim(...), $excludePatterns), fn($v) => $v !== '')); return $this; } @@ -449,7 +445,7 @@ public function setGlobPatterns(array $includePatterns = [], array $excludePatte */ public function setIgnoreFileNames(array $ignoreFileNames): self { - $this->ignoreFileNames = array_values(array_filter(array_map('trim', $ignoreFileNames), fn ($v) => $v !== '')); + $this->ignoreFileNames = array_values(array_filter(array_map(trim(...), $ignoreFileNames), fn($v) => $v !== '')); return $this; } @@ -464,8 +460,6 @@ public function setIgnoreFileNames(array $ignoreFileNames): self * @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. - * - * @return self */ public function setLogger(callable $logger): self { @@ -478,7 +472,6 @@ public function setLogger(callable $logger): self * Set the password for the ZIP archive. * * @param string $password The password to encrypt the ZIP archive with. - * @return self */ public function setPassword(string $password): self { @@ -751,11 +744,6 @@ private function closeZip(): void $this->cleanupDeferredLocalizedPaths(); } - private function copyFlysystemFileToLocal(string $sourcePath, string $localTarget): void - { - $this->doCopyFlysystemFileToLocal($sourcePath, $localTarget); - } - private function copyLocalDirectoryToFlysystem(string $localSource, string $destination): void { $iterator = new \RecursiveIteratorIterator( @@ -815,7 +803,7 @@ private function countFilesForCompression(string $source, array $extensions = [] } $relative = $this->getRelativePath($item->getPathname(), $source); - if (!empty($extensions) && !in_array(pathinfo($item->getPathname(), PATHINFO_EXTENSION), $extensions, true)) { + if (!empty($extensions) && !in_array(pathinfo((string) $item->getPathname(), PATHINFO_EXTENSION), $extensions, true)) { continue; } if ($this->shouldIncludePath($relative)) { @@ -862,11 +850,6 @@ private function emitDecompressionProgress(): void } } - private function ensureLocalDirectoryExists(string $path): void - { - $this->doEnsureLocalDirectoryExists($path); - } - private function extractArchive(string $extractDestination, string $destination, bool $isRemoteDestination): void { if (!$this->zip->extractTo($extractDestination)) { @@ -939,11 +922,6 @@ private function log(string $message): void } } - private function materializeDirectoryToLocal(string $sourcePath, string $localDirectory): void - { - $this->doMaterializeDirectoryToLocal($sourcePath, $localDirectory); - } - private function normalizeZipPath(string $path): string { $hadTrailingSlash = str_ends_with(str_replace('\\', '/', $path), '/'); @@ -1028,16 +1006,6 @@ private function resolveDecompressionDestination(?string $destination): string return PathHelper::normalize($destination); } - private function resolveMaterializationBase(string $sourcePath): string - { - return $this->doResolveMaterializationBase($sourcePath); - } - - private function resolveMaterializedRelativePath(array $item, string $base): ?string - { - return $this->doResolveMaterializedRelativePath($item, $base); - } - private function resolveWorkingZipPath(bool $create): string { return $this->doResolveWorkingZipPath($create); diff --git a/src/FileManager/FileOperations.php b/src/FileManager/FileOperations.php index 41f21cb..607e255 100644 --- a/src/FileManager/FileOperations.php +++ b/src/FileManager/FileOperations.php @@ -26,8 +26,6 @@ class FileOperations /** * Constructor to initialize the file path. - * - * @param string $filePath */ public function __construct(protected string $filePath) { @@ -36,9 +34,6 @@ public function __construct(protected string $filePath) /** * Append content to the file. - * - * @param string $content - * @return self */ public function append(string $content): self { @@ -74,9 +69,6 @@ public function commitTransaction(): self /** * Copy the file to a new location. - * - * @param string $destination - * @return self */ public function copy(string $destination, ?callable $progress = null): self { @@ -148,9 +140,6 @@ public function copyWithVerification(string $destination, string $algorithm = 's /** * Create or overwrite the file with optional content. - * - * @param string|null $content - * @return self */ public function create(?string $content = ''): self { @@ -171,8 +160,6 @@ public function create(?string $content = ''): self /** * Delete the file. - * - * @return self */ public function delete(): self { @@ -195,8 +182,6 @@ public function delete(): self /** * Check if a file exists at the given path. - * - * @return bool */ public function exists(): bool { @@ -205,8 +190,6 @@ public function exists(): bool /** * Get the line count of the file using SplFileObject. - * - * @return int */ public function getLineCount(): int { @@ -217,8 +200,6 @@ public function getLineCount(): int /** * Get all metadata for the file. - * - * @return array */ public function getMetadata(): array { @@ -239,7 +220,6 @@ public function getMetadata(): array /** * Check if a file is readable. * - * @return bool * @throws FileNotFoundException */ public function isReadable(): bool @@ -253,9 +233,6 @@ public function isReadable(): bool /** * Open the file with a lock, optionally with a timeout. * - * @param bool $exclusive - * @param int $timeout - * @return self * @throws FileAccessException */ public function openWithLock(bool $exclusive = true, int $timeout = 0): self @@ -288,7 +265,6 @@ public function publicUrl(array $config = []): string /** * Read content from the file. * - * @return string * @throws FileNotFoundException */ public function read(): string @@ -306,9 +282,6 @@ public function readStream(): mixed /** * Rename or move the file to a new location. - * - * @param string $newPath - * @return self */ public function rename(string $newPath): self { @@ -344,9 +317,6 @@ public function rollbackTransaction(): self /** * Search for a term in the file using OS-native commands and return matching lines. - * - * @param string $searchTerm - * @return array */ public function searchContent(string $searchTerm): array { @@ -385,9 +355,6 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self /** * Set file group. - * - * @param int $groupId - * @return self */ public function setGroup(int $groupId): self { @@ -401,9 +368,6 @@ public function setGroup(int $groupId): self /** * Set file owner. - * - * @param int $ownerId - * @return self */ public function setOwner(int $ownerId): self { @@ -417,9 +381,6 @@ public function setOwner(int $ownerId): self /** * Set file permissions. - * - * @param int $permissions - * @return self */ public function setPermissions(int $permissions): self { @@ -482,8 +443,6 @@ public function transaction(callable $callback): mixed /** * Unlock the file. - * - * @return self */ public function unlock(): self { @@ -493,9 +452,6 @@ public function unlock(): self /** * Overwrite the file with new content. - * - * @param string $content - * @return self */ public function update(string $content): self { @@ -564,9 +520,6 @@ public function writeStream(mixed $stream, array $config = []): self /** * Initialize the SplFileObject. - * - * @param string $mode - * @return self */ protected function initFile(string $mode = 'r'): self { diff --git a/src/FileManager/SafeFileReader.php b/src/FileManager/SafeFileReader.php index 75eaf59..4cddf2a 100644 --- a/src/FileManager/SafeFileReader.php +++ b/src/FileManager/SafeFileReader.php @@ -52,8 +52,7 @@ public function __construct( private readonly string $filename, private readonly string $mode = 'r', private readonly bool $exclusiveLock = false, - ) { - } + ) {} /** * Destructor for the SafeFileReader class. @@ -273,6 +272,23 @@ private function characterIterator(): Generator } } + private function containsObjectValue(mixed $value, int $depth = 0): bool + { + if ($depth > 256) { + return true; + } + + if (is_object($value)) { + return true; + } + + if (!is_array($value)) { + return false; + } + + return array_any($value, fn($item) => $this->containsObjectValue($item, $depth + 1)); + } + /** * Iterates over the file line by line, splitting each line into an array using the given CSV settings. * @@ -297,6 +313,20 @@ private function csvIterator(string $separator = ',', string $enclosure = '"', s } } + private function deserializeValue(string $serializedLine): mixed + { + $result = unserialize($serializedLine, ['allowed_classes' => false]); + if ($result === false && $serializedLine !== 'b:0;') { + throw new Exception('Failed to unserialize data.'); + } + + if ($this->containsObjectValue($result)) { + throw new Exception('Serialized objects are not allowed.'); + } + + return $result; + } + /** * Yields an array of fields for each line of the file, where each field is of a fixed width. * @@ -529,10 +559,7 @@ private function serializedIterator(): Generator while (!$this->file->eof()) { $serializedLine = trim($this->file->fgets()); if ($serializedLine) { - $result = unserialize($serializedLine); - if ($result === false && $serializedLine !== 'b:0;') { - throw new Exception('Failed to unserialize data.'); - } + $result = $this->deserializeValue($serializedLine); yield $result; $this->position++; $this->count++; diff --git a/src/FileManager/SafeFileWriter.php b/src/FileManager/SafeFileWriter.php index e0a7c19..4af7af1 100644 --- a/src/FileManager/SafeFileWriter.php +++ b/src/FileManager/SafeFileWriter.php @@ -44,9 +44,7 @@ class SafeFileWriter implements Countable, Stringable, JsonSerializable * @param string $filename The name of the file to write to. * @param bool $append Whether to append to the existing file or truncate it. */ - public function __construct(private readonly string $filename, private readonly bool $append = false) - { - } + public function __construct(private readonly string $filename, private readonly bool $append = false) {} /** * Closes the file and releases any system resources associated with it. diff --git a/src/Indexing/ChecksumIndexer.php b/src/Indexing/ChecksumIndexer.php index fcf9a2c..7574382 100644 --- a/src/Indexing/ChecksumIndexer.php +++ b/src/Indexing/ChecksumIndexer.php @@ -83,7 +83,7 @@ public static function findDuplicates(string $directory, string $algorithm = 'sh { $index = self::buildIndex($directory, $algorithm); - return array_filter($index, static fn (array $paths): bool => count($paths) > 1); + return array_filter($index, static fn(array $paths): bool => count($paths) > 1); } private static function hashPath(string $path, string $algorithm): ?string diff --git a/src/Observability/AuditTrail.php b/src/Observability/AuditTrail.php index b134200..b52d9bc 100644 --- a/src/Observability/AuditTrail.php +++ b/src/Observability/AuditTrail.php @@ -6,9 +6,9 @@ use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; -final class AuditTrail +final readonly class AuditTrail { - public function __construct(private readonly string $logFilePath) + public function __construct(private string $logFilePath) { $directory = dirname($this->logFilePath); if (!FlysystemHelper::directoryExists($directory)) { diff --git a/src/Queue/FileJobQueue.php b/src/Queue/FileJobQueue.php index 78baf0f..abff493 100644 --- a/src/Queue/FileJobQueue.php +++ b/src/Queue/FileJobQueue.php @@ -5,9 +5,9 @@ use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; -final class FileJobQueue +final readonly class FileJobQueue { - public function __construct(private readonly string $queueFilePath) + public function __construct(private string $queueFilePath) { $directory = dirname($this->queueFilePath); if (!FlysystemHelper::directoryExists($directory)) { @@ -30,7 +30,7 @@ public function enqueue(string $type, array $payload = [], int $priority = 0): s 'createdAt' => time(), ]; - usort($data['pending'], static fn (array $a, array $b): int => $b['priority'] <=> $a['priority']); + usort($data['pending'], static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']); $this->writeQueueData($data); return $jobId; @@ -69,7 +69,7 @@ public function process(callable $handler, int $maxJobs = 0): array $data = $this->readQueueData(); $data['processing'] = array_values(array_filter( $data['processing'], - static fn (array $processingJob): bool => $processingJob['id'] !== $job['id'], + static fn(array $processingJob): bool => $processingJob['id'] !== $job['id'], )); if (isset($job['error'])) { diff --git a/src/Retention/RetentionManager.php b/src/Retention/RetentionManager.php index 9ee0a04..9df7281 100644 --- a/src/Retention/RetentionManager.php +++ b/src/Retention/RetentionManager.php @@ -19,7 +19,7 @@ public static function apply( string $directory, ?int $keepLast = null, ?int $maxAgeDays = null, - string $sortBy = 'mtime' + string $sortBy = 'mtime', ): array { $directory = PathHelper::normalize($directory); if (!FlysystemHelper::directoryExists($directory)) { @@ -27,9 +27,7 @@ public static function apply( } $files = self::collectFiles($directory); - usort($files, function (array $a, array $b) use ($sortBy): int { - return ($b[$sortBy] ?? 0) <=> ($a[$sortBy] ?? 0); - }); + usort($files, fn(array $a, array $b): int => ($b[$sortBy] ?? 0) <=> ($a[$sortBy] ?? 0)); $kept = []; $deleted = []; diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 9ebadcf..0946bc0 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -11,7 +11,7 @@ class UploadProcessor { - private const VALIDATION_PROFILES = [ + private const array VALIDATION_PROFILES = [ 'image' => [ 'allowedFileTypes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], 'maxFileSize' => 10 * 1024 * 1024, @@ -59,78 +59,16 @@ public function finalizeChunkUpload(string $uploadId): string throw new UploadException('Upload directory is not set.'); } - $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.'); - } - + [$manifest, $totalChunks, $received] = $this->resolveCompleteChunkState($uploadId); $originalFilename = (string) ($manifest['originalFilename'] ?? 'upload.bin'); - $extension = (string) pathinfo($originalFilename, PATHINFO_EXTENSION); + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); $fileName = $this->generateFileName(null, $extension); $destination = $this->getUniqueDestination($fileName); $chunkDirectory = $this->getChunkDirectory($uploadId); - $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++) { - $chunkName = $received[(string) $i] ?? null; - if (!is_string($chunkName)) { - throw new UploadException("Missing chunk index {$i}."); - } - - $chunkPath = PathHelper::join($chunkDirectory, $chunkName); - if (!FlysystemHelper::fileExists($chunkPath)) { - throw new UploadException("Missing chunk file for index {$i}."); - } - - $input = FlysystemHelper::readStream($chunkPath); - if (!is_resource($input)) { - throw new UploadException("Failed to read chunk index {$i}."); - } - - stream_copy_to_stream($input, $output); - fclose($input); - } - - rewind($output); - FlysystemHelper::writeStream($destination, $output); - } finally { - fclose($output); - } - - $finalSize = FlysystemHelper::size($destination); - $this->validateFileSize($finalSize); - - $fileType = $this->getFileMimeType($destination); - $this->validateFileType($fileType); - if ($this->isImage($fileType)) { - $this->validateImageDimensions($destination); - } - $this->scanForMalware($destination, $fileType); - - 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); - } + $this->mergeChunksToDestination($chunkDirectory, $received, $totalChunks, $destination); + $this->validateFinalizedUpload($destination); + $this->cleanupChunkUploadArtifacts($uploadId, $chunkDirectory, $received); return $destination; } @@ -225,7 +163,7 @@ public function processUpload(array $file): string } $this->scanForMalware($file['tmp_name'], $fileType); - $fileName = $this->generateFileName($file['tmp_name'], pathinfo($file['name'], PATHINFO_EXTENSION)); + $fileName = $this->generateFileName($file['tmp_name'], pathinfo((string) $file['name'], PATHINFO_EXTENSION)); $destination = $this->getUniqueDestination($fileName); if (!move_uploaded_file($file['tmp_name'], $destination)) { @@ -348,6 +286,58 @@ 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. */ @@ -368,7 +358,7 @@ private function generateFileName(?string $dataSource, string $extension): strin $callingMethod = $backtrace[1]['function'] ?? null; $shortClass = is_string($callingClass) - ? (strrchr($callingClass, '\\') !== false ? substr((string) strrchr($callingClass, '\\'), 1) : $callingClass) + ? (strrchr($callingClass, '\\') !== false ? substr(strrchr($callingClass, '\\'), 1) : $callingClass) : 'Upload'; $classPrefix = strtolower(preg_replace('/[^A-Za-z0-9]/', '', $shortClass) ?: 'upload'); @@ -461,6 +451,26 @@ private function loadChunkManifest(string $uploadId): ?array 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)) { @@ -494,6 +504,61 @@ private function moveIncomingFile(string $source, string $destination): void } } + /** + * @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 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.'); + } + + ksort($received); + + return [$manifest, $totalChunks, $received]; + } + /** * Sanitize a path to remove invalid characters. */ @@ -578,45 +643,35 @@ private function validateFileType(string $fileType): void } } + private function validateFinalizedUpload(string $destination): void + { + $finalSize = FlysystemHelper::size($destination); + $this->validateFileSize($finalSize); + + $fileType = $this->getFileMimeType($destination); + $this->validateFileType($fileType); + if ($this->isImage($fileType)) { + $this->validateImageDimensions($destination); + } + + $this->scanForMalware($destination, $fileType); + } + /** * Validate image dimensions. */ private function validateImageDimensions(string $filePath): void { - $pathForInspection = $filePath; - $cleanup = false; + [$pathForInspection, $cleanup] = $this->prepareImagePathForInspection($filePath); - if (PathHelper::hasScheme($filePath) || !is_file($filePath)) { - $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_img_'); - if ($tempFile === false) { - throw new UploadException('Unable to create temporary file for image validation.'); - } - - $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.'); + try { + $dimensions = getimagesize($pathForInspection); + } finally { + if ($cleanup && is_file($pathForInspection)) { + @unlink($pathForInspection); } - - stream_copy_to_stream($stream, $target); - fclose($stream); - fclose($target); - - $pathForInspection = $tempFile; - $cleanup = true; } - $dimensions = getimagesize($pathForInspection); - if ($cleanup && is_file($pathForInspection)) { - @unlink($pathForInspection); - } if (!is_array($dimensions) || count($dimensions) < 2) { throw new UploadException('Unable to inspect image dimensions.'); } diff --git a/src/Utils/FileWatcher.php b/src/Utils/FileWatcher.php index 6f93de6..fb3d7db 100644 --- a/src/Utils/FileWatcher.php +++ b/src/Utils/FileWatcher.php @@ -61,8 +61,8 @@ public static function snapshot(string $path, bool $recursive = true): array return [ $normalized => [ - 'mtime' => (int) $mtime, - 'size' => (int) (FlysystemHelper::size($normalized) ?: 0), + 'mtime' => $mtime, + 'size' => FlysystemHelper::size($normalized), ], ]; } @@ -107,7 +107,7 @@ public static function watch( callable $onChange, int $durationSeconds = 5, int $intervalMilliseconds = 500, - bool $recursive = true + bool $recursive = true, ): array { $snapshot = self::snapshot($path, $recursive); $endAt = microtime(true) + max(1, $durationSeconds); diff --git a/src/Utils/FlysystemHelper.php b/src/Utils/FlysystemHelper.php index 83da46f..5d67c26 100644 --- a/src/Utils/FlysystemHelper.php +++ b/src/Utils/FlysystemHelper.php @@ -234,7 +234,7 @@ public static function publicUrl(string $path, array $config = []): string } /** @var callable $callable */ - $callable = [$filesystem, 'publicUrl']; + $callable = $filesystem->publicUrl(...); return $callable($location, $config); } @@ -305,7 +305,7 @@ public static function temporaryUrl(string $path, DateTimeInterface $expiresAt, } /** @var callable $callable */ - $callable = [$filesystem, 'temporaryUrl']; + $callable = $filesystem->temporaryUrl(...); return $callable($location, $expiresAt, $config); } @@ -447,9 +447,8 @@ private static function normalizeStorageAttributes(StorageAttributes $item): arr } catch (\Throwable) { $normalized['visibility'] = null; } - $normalized = array_merge($normalized, $item->extraMetadata()); - return $normalized; + return array_merge($normalized, $item->extraMetadata()); } /** diff --git a/src/Utils/MetadataHelper.php b/src/Utils/MetadataHelper.php index be1c8c3..4851aaf 100644 --- a/src/Utils/MetadataHelper.php +++ b/src/Utils/MetadataHelper.php @@ -181,7 +181,7 @@ public static function getHumanReadableTimestamps(string $path): ?array return null; } - return array_map(fn ($time) => date('Y-m-d H:i:s', $time), $timestamps); + return array_map(fn($time) => date('Y-m-d H:i:s', $time), $timestamps); } /** diff --git a/src/Utils/PathHelper.php b/src/Utils/PathHelper.php index f972250..4687323 100644 --- a/src/Utils/PathHelper.php +++ b/src/Utils/PathHelper.php @@ -269,11 +269,8 @@ public static function join(string ...$segments): string public static function normalize(string $path): string { $originalPath = $path; - if (isset(self::$cache[$originalPath])) { - return self::$cache[$originalPath]; - } - return self::$cache[$originalPath] = self::normalizeUncached($path); + return self::$cache[$originalPath] ?? self::$cache[$originalPath] = self::normalizeUncached($path); } /** diff --git a/src/Utils/PermissionsHelper.php b/src/Utils/PermissionsHelper.php index 3f4cd3e..c4ed9e0 100644 --- a/src/Utils/PermissionsHelper.php +++ b/src/Utils/PermissionsHelper.php @@ -82,7 +82,7 @@ public static function formatPermissions(int $permissions): string 0x0001 => ($permissions & 0x0200) ? 't' : 'x', ]; - return array_reduce(array_keys($flags), fn ($info, $flag) => $info . (($permissions & $flag) ? $flags[$flag] : '-'), ''); + return array_reduce(array_keys($flags), fn($info, $flag) => $info . (($permissions & $flag) ? $flags[$flag] : '-'), ''); } diff --git a/tests/Feature/DirectoryOperationsTest.php b/tests/Feature/DirectoryOperationsTest.php index 762e48f..8d98d2a 100644 --- a/tests/Feature/DirectoryOperationsTest.php +++ b/tests/Feature/DirectoryOperationsTest.php @@ -231,3 +231,34 @@ function createTempDirectory(): string expect(fn () => $this->directoryOperations->unzip($this->tempDir . DIRECTORY_SEPARATOR . 'missing.zip')) ->toThrow(DirectoryOperationException::class); }); + +test('unzip rejects zip-slip traversal entries', function () { + $zipPath = $this->tempDir . DIRECTORY_SEPARATOR . uniqid('archive_', true) . '.zip'; + $zip = new ZipArchive; + $zip->open($zipPath, ZipArchive::CREATE); + $zip->addFromString('../outside_zip_slip.txt', 'malicious'); + $zip->close(); + + $unzipDir = createTempDirectory(); + $outsidePath = dirname($unzipDir) . DIRECTORY_SEPARATOR . 'outside_zip_slip.txt'; + + if (file_exists($outsidePath)) { + unlink($outsidePath); + } + + try { + $dirOps = new DirectoryOperations($unzipDir); + + expect(fn () => $dirOps->unzip($zipPath)) + ->toThrow(DirectoryOperationException::class, 'Unsafe ZIP entry path'); + + expect(file_exists($outsidePath))->toBeFalse(); + } finally { + if (is_dir($unzipDir)) { + (new DirectoryOperations($unzipDir))->delete(true); + } + if (file_exists($outsidePath)) { + unlink($outsidePath); + } + } +}); diff --git a/tests/Feature/SafeFileReaderTest.php b/tests/Feature/SafeFileReaderTest.php index 3cafa0a..3a59478 100644 --- a/tests/Feature/SafeFileReaderTest.php +++ b/tests/Feature/SafeFileReaderTest.php @@ -98,6 +98,15 @@ expect($serializedObjects)->toBe([['key' => 'value'], ['key' => 'value']]); }); +test('it rejects serialized objects for safety', function () { + $data = serialize((object) ['key' => 'value']); + file_put_contents($this->tempFilePath, $data); + $reader = new SafeFileReader($this->tempFilePath); + + expect(fn() => iterator_to_array($reader->serialized(), false)) + ->toThrow(Exception::class, 'Serialized objects are not allowed'); +}); + test('it reads fixed-width data', function () { file_put_contents($this->tempFilePath, '1234JohnDoe '); $reader = new SafeFileReader($this->tempFilePath); From e6c64d2195d1e242393bd26a7abd24925e6fe149 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 13 Apr 2026 16:28:28 +0600 Subject: [PATCH 8/8] docs update --- README.md | 2 +- docs/capabilities.rst | 5 +- docs/upload-processing.rst | 50 +++++- src/StreamHandler/UploadProcessor.php | 237 +++++++++++++++++++++++++- tests/Feature/UploadProcessorTest.php | 152 ++++++++++++++++- 5 files changed, 428 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 858beab..709ab95 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Requirements: - Mount support with scheme paths (`name://path`) and default filesystem support for relative paths. - Advanced file APIs: checksum verification, visibility controls, URL passthrough (`publicUrl`, `temporaryUrl`). - Directory automation: sync with diff report, recursive copy/move/delete, mounted-path ZIP/unzip bridging. -- Upload pipelines: chunked/resumable uploads, validation profiles (image/video/document), malware-scan hook. +- Upload pipelines: chunked/resumable uploads, validation profiles (image/video/document), extension allow/deny controls, strict MIME/signature checks, upload-id safety validation, malware-scan hook. - Compression workflows: include/exclude glob patterns, ignore files, progress callbacks, hooks, optional native acceleration. - Operational tooling: `AuditTrail`, `FileJobQueue`, `FileWatcher`, `RetentionManager` and policy engine support. diff --git a/docs/capabilities.rst b/docs/capabilities.rst index 10c54c5..1a4d6b8 100644 --- a/docs/capabilities.rst +++ b/docs/capabilities.rst @@ -56,7 +56,10 @@ What you get: * Standard upload handling and destination strategy. * Validation presets (image/video/document), MIME and size rules. * Chunked/resumable upload flow. -* Optional malware scan callback. +* Extension allowlist/blocklist controls. +* Upload ID validation for chunk/session identifiers. +* Strict content checks (MIME-extension agreement and file signature checks). +* Optional or required malware scan callback. Security and Operations ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/upload-processing.rst b/docs/upload-processing.rst index 8225165..481db90 100644 --- a/docs/upload-processing.rst +++ b/docs/upload-processing.rst @@ -6,24 +6,50 @@ Namespace: ``Infocyph\Pathwise\StreamHandler`` Where it fits: * Use this module for HTTP uploads that need validation, deterministic naming, - resumable chunk flow, and optional malware checks. + resumable chunk flow, and layered upload hardening. ``UploadProcessor`` supports: * Standard upload handling with configurable destination strategy. * Validation profiles: ``image``, ``video``, ``document``. * MIME and size validation with optional image dimension validation. +* Extension allowlist/blocklist policy. * Naming strategies (hash/timestamp). * Chunked/resumable uploads: * ``processChunkUpload()`` * ``finalizeChunkUpload()`` -* Optional malware scanner callback hook. +* Upload ID safety validation for chunk/session identifiers. +* Strict content checks: + * extension <> MIME agreement + * lightweight file signature verification for common formats +* Malware scanner callback hook (optional or required). Storage notes: * Uses Flysystem operations for chunk manifests and destination writes. * Supports mounted/default filesystem routing through helper resolution. +Security Hardening Controls +--------------------------- + +``UploadProcessor`` exposes explicit controls for upload policy: + +* ``setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = [])`` + to enforce extension allow/deny policies. +* ``setChunkLimits(int $maxChunkCount = 0, int $maxChunkSize = 0)`` + to cap chunk count and per-chunk size. +* ``setRequireMalwareScan(bool $required = true)`` + to reject uploads if scanner execution is required but unavailable. +* ``setStrictContentTypeValidation(bool $enabled = true)`` + to enforce extension-to-MIME agreement and signature checks. + +Chunk upload IDs are validated and must contain only: + +* letters/numbers +* ``-`` and ``_`` + +Identifiers with separators such as ``/`` or traversal patterns are rejected. + Examples -------- @@ -36,6 +62,8 @@ Basic single upload: $uploader = new UploadProcessor(); $uploader->setDirectorySettings('/tmp/uploads'); $uploader->setValidationProfile('document'); + $uploader->setExtensionPolicy(['pdf', 'doc', 'docx'], ['php', 'phtml', 'phar']); + $uploader->setStrictContentTypeValidation(true); $finalPath = $uploader->processUpload($_FILES['file']); @@ -54,3 +82,21 @@ Resumable chunk flow: if ($state['isComplete']) { $finalPath = $uploader->finalizeChunkUpload('session-42'); } + +Hardened chunk upload: + +.. code-block:: php + + $uploader->setChunkLimits(maxChunkCount: 20, maxChunkSize: 2 * 1024 * 1024); // 2MB + $uploader->setRequireMalwareScan(true); + $uploader->setMalwareScanner( + fn (string $path, string $type): bool => true // return false to block + ); + + $uploader->processChunkUpload( + chunkFile: $_FILES['chunk'], + uploadId: 'session_42', + chunkIndex: 0, + totalChunks: 4, + originalFilename: 'video.mp4', + ); diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 0946bc0..52f672f 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -37,13 +37,19 @@ class UploadProcessor 'maxImageHeight' => 0, ], ]; + private array $allowedExtensions = []; private array $allowedFileTypes = []; + 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; @@ -59,9 +65,11 @@ public function finalizeChunkUpload(string $uploadId): string throw new UploadException('Upload directory is not set.'); } + $this->validateUploadId($uploadId); [$manifest, $totalChunks, $received] = $this->resolveCompleteChunkState($uploadId); $originalFilename = (string) ($manifest['originalFilename'] ?? 'upload.bin'); $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $this->validateFileExtension($extension); $fileName = $this->generateFileName(null, $extension); $destination = $this->getUniqueDestination($fileName); $chunkDirectory = $this->getChunkDirectory($uploadId); @@ -83,10 +91,16 @@ public function getInfo(): array 'useDateDirectories' => $this->useDateDirectories, 'tempDir' => $this->tempDir ?? sys_get_temp_dir(), 'allowedFileTypes' => $this->allowedFileTypes, + 'allowedExtensions' => $this->allowedExtensions, + 'blockedExtensions' => $this->blockedExtensions, 'maxFileSize' => $this->maxFileSize, + 'maxChunkCount' => $this->maxChunkCount, + 'maxChunkSize' => $this->maxChunkSize, 'namingStrategy' => $this->namingStrategy, 'validationProfile' => $this->validationProfile, 'hasMalwareScanner' => is_callable($this->malwareScanner), + 'requireMalwareScan' => $this->requireMalwareScan, + 'strictContentTypeValidation' => $this->strictContentTypeValidation, ]; } @@ -108,10 +122,7 @@ public function processChunkUpload(array $chunkFile, string $uploadId, int $chun if (empty($this->uploadDir)) { throw new UploadException('Upload directory is not set.'); } - if ($chunkIndex < 0 || $totalChunks < 1 || $chunkIndex >= $totalChunks) { - throw new UploadException('Invalid chunk metadata.'); - } - + $this->validateChunkUploadRequest($chunkFile, $uploadId, $chunkIndex, $totalChunks, $originalFilename); $this->validateFile($chunkFile); $chunkDirectory = $this->getChunkDirectory($uploadId); @@ -155,15 +166,19 @@ public function processUpload(array $file): string } $this->validateFile($file); - $fileType = $this->getFileMimeType($file['tmp_name']); + $extension = pathinfo((string) ($file['name'] ?? ''), PATHINFO_EXTENSION); + $this->validateFileExtension($extension); + + $fileType = $this->getFileMimeType((string) $file['tmp_name']); $this->validateFileType($fileType); + $this->validateContentTypeIntegrity((string) $file['tmp_name'], $fileType, $extension); if ($this->isImage($fileType)) { $this->validateImageDimensions($file['tmp_name']); } $this->scanForMalware($file['tmp_name'], $fileType); - $fileName = $this->generateFileName($file['tmp_name'], pathinfo((string) $file['name'], PATHINFO_EXTENSION)); + $fileName = $this->generateFileName($file['tmp_name'], $extension); $destination = $this->getUniqueDestination($fileName); if (!move_uploaded_file($file['tmp_name'], $destination)) { @@ -211,6 +226,15 @@ public function processUpload(array $file): string } } + /** + * Configure chunk upload constraints. + */ + public function setChunkLimits(int $maxChunkCount = 0, int $maxChunkSize = 0): void + { + $this->maxChunkCount = max(0, $maxChunkCount); + $this->maxChunkSize = max(0, $maxChunkSize); + } + /** * Configure directory and path settings. */ @@ -224,6 +248,20 @@ public function setDirectorySettings(string $uploadDir, bool $useDateDirectories $this->ensureUploadDirectoryExists(); } + /** + * Configure extension allow/block policy. + * + * @param array $allowedExtensions + * @param array $blockedExtensions + */ + public function setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = []): void + { + $this->allowedExtensions = $this->normalizeExtensions($allowedExtensions); + $this->blockedExtensions = $blockedExtensions === [] + ? ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com'] + : $this->normalizeExtensions($blockedExtensions); + } + /** * Configure optional image dimension validation. */ @@ -259,6 +297,22 @@ public function setNamingStrategy(string $namingStrategy): void $this->namingStrategy = $namingStrategy; } + /** + * Require malware scanning before upload acceptance. + */ + public function setRequireMalwareScan(bool $required = true): void + { + $this->requireMalwareScan = $required; + } + + /** + * Enable strict content checks (MIME-extension agreement + magic signature). + */ + public function setStrictContentTypeValidation(bool $enabled = true): void + { + $this->strictContentTypeValidation = $enabled; + } + /** * Configure validation using a predefined profile. */ @@ -504,6 +558,29 @@ private function moveIncomingFile(string $source, string $destination): void } } + 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} */ @@ -523,6 +600,22 @@ private function prepareImagePathForInspection(string $filePath): array 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; @@ -553,6 +646,9 @@ private function resolveCompleteChunkState(string $uploadId): array 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); @@ -582,6 +678,10 @@ private function saveChunkManifest(string $uploadId, array $manifest): void 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; } @@ -596,6 +696,48 @@ private function scanForMalware(string $filePath, string $fileType): void } } + 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. */ @@ -620,6 +762,26 @@ private function validateFile(array $file): void $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. */ @@ -649,7 +811,10 @@ private function validateFinalizedUpload(string $destination): void $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); } @@ -684,4 +849,64 @@ private function validateImageDimensions(string $filePath): void 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/tests/Feature/UploadProcessorTest.php b/tests/Feature/UploadProcessorTest.php index 487c764..e274ca2 100644 --- a/tests/Feature/UploadProcessorTest.php +++ b/tests/Feature/UploadProcessorTest.php @@ -23,12 +23,12 @@ }); test('it throws when naming strategy is invalid', function () { - expect(fn () => $this->uploadProcessor->setNamingStrategy('uuid')) + expect(fn() => $this->uploadProcessor->setNamingStrategy('uuid')) ->toThrow(UploadException::class, 'Invalid naming strategy'); }); test('it throws when upload directory is not configured', function () { - expect(fn () => $this->uploadProcessor->processUpload(['error' => UPLOAD_ERR_OK])) + expect(fn() => $this->uploadProcessor->processUpload(['error' => UPLOAD_ERR_OK])) ->toThrow(UploadException::class, 'Upload directory is not set'); }); @@ -44,7 +44,7 @@ test('it throws for invalid upload parameters', function () { $this->uploadProcessor->setDirectorySettings($this->uploadDir); - expect(fn () => $this->uploadProcessor->processUpload(['error' => [UPLOAD_ERR_OK]])) + expect(fn() => $this->uploadProcessor->processUpload(['error' => [UPLOAD_ERR_OK]])) ->toThrow(UploadException::class, 'Invalid file upload parameters'); }); @@ -59,7 +59,7 @@ 'name' => 'oversized.txt', ]; - expect(fn () => $this->uploadProcessor->processUpload($file)) + expect(fn() => $this->uploadProcessor->processUpload($file)) ->toThrow(FileSizeExceededException::class, 'Exceeded file size limit'); }); @@ -78,7 +78,7 @@ 'name' => 'plain.txt', ]; - expect(fn () => $this->uploadProcessor->processUpload($file)) + expect(fn() => $this->uploadProcessor->processUpload($file)) ->toThrow(UploadException::class, 'Invalid file format'); } finally { if (file_exists($tmpFile)) { @@ -124,7 +124,7 @@ 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(fn(string $_path, string $_mime): bool => true); expect($this->uploadProcessor->getInfo()['hasMalwareScanner'])->toBeTrue(); }); @@ -144,8 +144,144 @@ 'name' => 'chunk.part', ], $uploadId, 0, 1, 'merged.txt'); - $this->uploadProcessor->setMalwareScanner(fn (string $_path, string $_mime): bool => false); + $this->uploadProcessor->setMalwareScanner(fn(string $_path, string $_mime): bool => false); - expect(fn () => $this->uploadProcessor->finalizeChunkUpload($uploadId)) + expect(fn() => $this->uploadProcessor->finalizeChunkUpload($uploadId)) ->toThrow(UploadException::class, 'Malware scan failed'); }); + +test('it blocks upload when extension is blocked', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir); + + $tmpFile = $this->uploadDir . DIRECTORY_SEPARATOR . uniqid('upload_blocked_', true) . '.tmp'; + file_put_contents($tmpFile, 'content'); + + try { + $file = [ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpFile), + 'tmp_name' => $tmpFile, + 'name' => 'payload.php', + ]; + + expect(fn() => $this->uploadProcessor->processUpload($file)) + ->toThrow(UploadException::class, 'Blocked file extension'); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } +}); + +test('it requires malware scanner when configured', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir); + $this->uploadProcessor->setRequireMalwareScan(true); + + $tmpFile = $this->uploadDir . DIRECTORY_SEPARATOR . uniqid('upload_scan_', true) . '.txt'; + file_put_contents($tmpFile, 'plain text'); + + try { + $file = [ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpFile), + 'tmp_name' => $tmpFile, + 'name' => 'sample.txt', + ]; + + expect(fn() => $this->uploadProcessor->processUpload($file)) + ->toThrow(UploadException::class, 'Malware scanner is required but not configured'); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } +}); + +test('it enforces strict content validation for extension and mime agreement', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir); + $this->uploadProcessor->setStrictContentTypeValidation(true); + + $tmpFile = $this->uploadDir . DIRECTORY_SEPARATOR . uniqid('upload_strict_', true) . '.tmp'; + file_put_contents($tmpFile, 'not-a-real-image'); + + try { + $file = [ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpFile), + 'tmp_name' => $tmpFile, + 'name' => 'avatar.png', + ]; + + expect(fn() => $this->uploadProcessor->processUpload($file)) + ->toThrow(UploadException::class, 'File content type does not match extension'); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } +}); + +test('it enforces configured chunk count limit', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir, false, $this->uploadDir); + $this->uploadProcessor->setChunkLimits(maxChunkCount: 2, maxChunkSize: 0); + + $tempChunkPath = $this->uploadDir . DIRECTORY_SEPARATOR . 'limit_count.part'; + file_put_contents($tempChunkPath, 'abc'); + + try { + expect(fn() => $this->uploadProcessor->processChunkUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tempChunkPath), + 'tmp_name' => $tempChunkPath, + 'name' => 'limit_count.part', + ], 'session_limit_count', 0, 3, 'merged.txt')) + ->toThrow(UploadException::class, 'Total chunks exceed configured limit'); + } finally { + if (file_exists($tempChunkPath)) { + unlink($tempChunkPath); + } + } +}); + +test('it enforces configured chunk size limit', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir, false, $this->uploadDir); + $this->uploadProcessor->setChunkLimits(maxChunkCount: 0, maxChunkSize: 2); + + $tempChunkPath = $this->uploadDir . DIRECTORY_SEPARATOR . 'limit_size.part'; + file_put_contents($tempChunkPath, 'abcd'); + + try { + expect(fn() => $this->uploadProcessor->processChunkUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tempChunkPath), + 'tmp_name' => $tempChunkPath, + 'name' => 'limit_size.part', + ], 'session_limit_size', 0, 1, 'merged.txt')) + ->toThrow(FileSizeExceededException::class, 'Chunk exceeds configured size limit'); + } finally { + if (file_exists($tempChunkPath)) { + unlink($tempChunkPath); + } + } +}); + +test('it rejects unsafe upload ids in chunk flow', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir, false, $this->uploadDir); + + $tempChunkPath = $this->uploadDir . DIRECTORY_SEPARATOR . 'unsafe_id.part'; + file_put_contents($tempChunkPath, 'ok'); + + try { + expect(fn() => $this->uploadProcessor->processChunkUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tempChunkPath), + 'tmp_name' => $tempChunkPath, + 'name' => 'unsafe_id.part', + ], '../unsafe', 0, 1, 'merged.txt')) + ->toThrow(UploadException::class, 'Invalid upload session id'); + } finally { + if (file_exists($tempChunkPath)) { + unlink($tempChunkPath); + } + } +});