From b7745992a7445da2900a73ae10d07fc4e7181e66 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 13 May 2026 11:13:47 +0600 Subject: [PATCH 1/3] updated ci --- .github/scripts/composer-audit-guard.php | 85 ------- .github/scripts/phpstan-sarif.php | 178 --------------- .github/scripts/syntax.php | 109 --------- .github/workflows/build.yml | 128 ----------- .github/workflows/security-standards.yml | 39 ++++ captainhook.json | 8 +- composer.json | 115 +++------- pest.xml | 22 -- phpbench.json | 26 --- phpcs.xml.dist | 66 ------ phpstan.neon.dist | 16 -- phpunit.xml | 22 -- pint.json | 129 ----------- psalm.xml | 39 ---- .../DirectoryOperationsZipConcern.php | 16 +- src/DirectoryManager/DirectoryOperations.php | 103 ++++----- src/FileManager/Concerns/FsConcern.php | 100 ++------ .../Concerns/SafeFileWriterWriteConcern.php | 34 ++- src/FileManager/FileCompression.php | 17 +- src/FileManager/FileOperations.php | 68 +++--- src/FileManager/SafeFileWriter.php | 81 +++---- src/Indexing/ChecksumIndexer.php | 47 +--- src/Native/NativeOperationsAdapter.php | 215 ++++++++++-------- src/Retention/RetentionManager.php | 53 +---- src/Storage/StorageFactory.php | 36 +-- .../UploadProcessorValidationConcern.php | 53 +++-- src/StreamHandler/DownloadProcessor.php | 22 +- src/StreamHandler/UploadProcessor.php | 11 +- src/Utils/ExtensionPolicy.php | 50 ++++ src/Utils/FileWatcher.php | 46 +--- src/Utils/FlysystemHelper.php | 67 +++--- src/Utils/FlysystemPathResolver.php | 59 +++++ src/Utils/LocalFileIterator.php | 36 +++ src/Utils/PathHelper.php | 37 +-- src/Utils/StreamTransferHelper.php | 49 ++++ src/functions.php | 9 +- 36 files changed, 691 insertions(+), 1500 deletions(-) delete mode 100644 .github/scripts/composer-audit-guard.php delete mode 100644 .github/scripts/phpstan-sarif.php delete mode 100644 .github/scripts/syntax.php delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/security-standards.yml delete mode 100644 pest.xml delete mode 100644 phpbench.json delete mode 100644 phpcs.xml.dist delete mode 100644 phpstan.neon.dist delete mode 100644 phpunit.xml delete mode 100644 pint.json delete mode 100644 psalm.xml create mode 100644 src/Utils/ExtensionPolicy.php create mode 100644 src/Utils/FlysystemPathResolver.php create mode 100644 src/Utils/LocalFileIterator.php create mode 100644 src/Utils/StreamTransferHelper.php diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php deleted file mode 100644 index a1b1cdb..0000000 --- a/.github/scripts/composer-audit-guard.php +++ /dev/null @@ -1,85 +0,0 @@ - ['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 deleted file mode 100644 index 2b01b26..0000000 --- a/.github/scripts/phpstan-sarif.php +++ /dev/null @@ -1,178 +0,0 @@ - [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 deleted file mode 100644 index 043bf53..0000000 --- a/.github/scripts/syntax.php +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index 001aea0..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: "Security & Standards" - -on: - schedule: - - cron: '0 0 * * 0' - push: - branches: [ "main", "master" ] - pull_request: - branches: [ "main", "master", "develop", "development" ] - -jobs: - prepare: - name: Prepare CI matrix - runs-on: ubuntu-latest - outputs: - php_versions: ${{ steps.matrix.outputs.php_versions }} - dependency_versions: ${{ steps.matrix.outputs.dependency_versions }} - steps: - - name: Define shared matrix values - id: matrix - run: | - echo 'php_versions=["8.4","8.5"]' >> "$GITHUB_OUTPUT" - echo 'dependency_versions=["prefer-lowest","prefer-stable"]' >> "$GITHUB_OUTPUT" - - run: - needs: prepare - runs-on: ${{ matrix.operating-system }} - strategy: - matrix: - operating-system: [ ubuntu-latest ] - php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} - dependency-version: ${{ fromJson(needs.prepare.outputs.dependency_versions) }} - - name: Code Analysis - PHP ${{ matrix.php-versions }} - ${{ matrix.dependency-version }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer:v2 - coverage: xdebug - - - name: Check PHP Version - run: php -v - - - name: Validate Composer - run: composer validate --strict - - - name: Resolve dependencies (${{ matrix.dependency-version }}) - run: composer update --no-interaction --prefer-dist --no-progress --${{ matrix.dependency-version }} - - - 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 - fi - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:security - fi - - analyze: - needs: prepare - name: Security Analysis - PHP ${{ matrix.php-versions }} - runs-on: ubuntu-latest - strategy: - matrix: - php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} - permissions: - security-events: write - actions: read - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - 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 (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 - run: | - php ./vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --report=psalm-results.sarif || true - continue-on-error: true - - - name: Upload Psalm Results - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: psalm-results.sarif - category: "psalm-${{ matrix.php-versions }}" - if: always() && hashFiles('psalm-results.sarif') != '' diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..f2b00ee --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,39 @@ +name: "Security & Standards" + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + phpforge: + uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + permissions: + security-events: write + actions: read + contents: read + with: + php_versions: '["8.4","8.5"]' + dependency_versions: '["prefer-lowest","prefer-stable"]' + php_extensions: "fileinfo, pcntl, posix, simplexml, xmlreader, zip" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true + run_svg_report: true + fail_on_skipped_tests: false + enable_redis_service: false + enable_valkey_service: false + enable_memcached_service: false + enable_postgres_service: false + enable_mysql_service: false + enable_scylladb_service: false + enable_elasticsearch_service: false + enable_mongodb_service: false + service_db_name: "phpforge" + service_db_user: "phpforge" + service_db_password: "phpforge" + artifact_retention_days: 61 diff --git a/captainhook.json b/captainhook.json index fa19900..782a292 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,11 +15,15 @@ "options": [] }, { - "action": "composer release:audit", + "action": "composer normalize --dry-run", "options": [] }, { - "action": "composer tests", + "action": "composer ic:release:audit", + "options": [] + }, + { + "action": "composer ic:ci", "options": [] } ] diff --git a/composer.json b/composer.json index a60af16..c709174 100644 --- a/composer.json +++ b/composer.json @@ -1,117 +1,60 @@ { "name": "infocyph/pathwise", "description": "File management made simple.", - "type": "library", "license": "MIT", + "type": "library", "authors": [ { "name": "abmmhasan", "email": "abmmhasan@gmail.com" } ], - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Infocyph\\Pathwise\\": "src/" - } - }, "require": { + "php": ">=8.4", "ext-fileinfo": "*", - "league/flysystem": "^3.33", - "php": ">=8.4" + "league/flysystem": "^3.33" + }, + "require-dev": { + "infocyph/phpforge": "dev-main" }, "suggest": { - "ext-zip": "required if you want to use compression.", "ext-pcntl": "required if you want to use long-running watch loops.", "ext-posix": "required if you want to use permissions.", - "ext-xmlreader": "required if you want to use XML parsing.", "ext-simplexml": "required if you want to use XML parsing.", - "league/flysystem-aws-s3-v3": "required for AWS S3 adapter support.", + "ext-xmlreader": "required if you want to use XML parsing.", + "ext-zip": "required if you want to use compression.", "league/flysystem-async-aws-s3": "required for AsyncAWS S3 adapter support.", + "league/flysystem-aws-s3-v3": "required for AWS S3 adapter support.", "league/flysystem-azure-blob-storage": "required for Azure Blob Storage adapter support.", + "league/flysystem-ftp": "required for FTP adapter support.", "league/flysystem-google-cloud-storage": "required for Google Cloud Storage adapter support.", "league/flysystem-gridfs": "required for MongoDB GridFS adapter support.", - "league/flysystem-sftp-v3": "required for SFTP (v3) adapter support.", - "league/flysystem-sftp-v2": "required for SFTP (v2) adapter support.", - "league/flysystem-ftp": "required for FTP adapter support.", - "league/flysystem-webdav": "required for WebDAV adapter support.", - "league/flysystem-ziparchive": "required for ZipArchive adapter support.", "league/flysystem-memory": "required for in-memory adapter support.", + "league/flysystem-path-prefixing": "required for path-prefixing adapter wrapper support.", "league/flysystem-read-only": "required for read-only adapter wrapper support.", - "league/flysystem-path-prefixing": "required for path-prefixing adapter wrapper support." - }, - "require-dev": { - "captainhook/captainhook": "^5.29.2", - "laravel/pint": "^1.29", - "pestphp/pest": "^4.6.2", - "pestphp/pest-plugin-drift": "^4.1", - "phpbench/phpbench": "^1.6.1", - "phpstan/phpstan": "^2.1.50", - "rector/rector": "^2.4.2", - "squizlabs/php_codesniffer": "^4.0.1", - "symfony/var-dumper": "^7.3 || ^8.0.8", - "tomasvotruba/cognitive-complexity": "^1.1", - "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" + "league/flysystem-sftp-v2": "required for SFTP (v2) adapter support.", + "league/flysystem-sftp-v3": "required for SFTP (v3) adapter support.", + "league/flysystem-webdav": "required for WebDAV adapter support.", + "league/flysystem-ziparchive": "required for ZipArchive adapter support." }, "minimum-stability": "stable", "prefer-stable": true, + "autoload": { + "psr-4": { + "Infocyph\\Pathwise\\": "src/" + }, + "files": [ + "src/functions.php" + ] + }, "config": { - "sort-packages": true, - "optimize-autoloader": true, - "classmap-authoritative": true, "allow-plugins": { + "ergebnis/composer-normalize": true, + "infocyph/phpforge": true, "pestphp/pest-plugin": true - } + }, + "classmap-authoritative": true, + "optimize-autoloader": true, + "sort-packages": true } } diff --git a/pest.xml b/pest.xml deleted file mode 100644 index d5d12d8..0000000 --- a/pest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index fff7e05..0000000 --- a/phpbench.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$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 deleted file mode 100644 index 1cf0c6c..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,66 +0,0 @@ - - - Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. - - - - - - - ./src - ./tests - - */vendor/* - */.git/* - */.idea/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 650fbdf..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,16 +0,0 @@ -includes: - - vendor/tomasvotruba/cognitive-complexity/config/extension.neon - -parameters: - customRulesetUsed: true - level: max - paths: - - src - parallel: - maximumNumberOfProcesses: 2 - cognitive_complexity: - class: 80 - function: 12 - dependency_tree: 80 - dependency_tree_types: [] - reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index d5d12d8..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/pint.json b/pint.json deleted file mode 100644 index 6f546dc..0000000 --- a/pint.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "preset": "per", - "exclude": [ - "tests" - ], - "notPath": [ - "rector.php" - ], - "rules": { - "ordered_imports": { - "imports_order": [ - "class", - "function", - "const" - ], - "sort_algorithm": "alpha" - }, - "no_unused_imports": true, - "class_attributes_separation": { - "elements": { - "trait_import": "none", - "case": "one", - "const": "one", - "property": "one", - "method": "one" - } - }, - "ordered_class_elements": { - "order": [ - "use_trait", - "case", - "constant_public", - "constant_protected", - "constant_private", - "constant", - "property_public_static", - "property_protected_static", - "property_private_static", - "property_static", - "property_public_readonly", - "property_protected_readonly", - "property_private_readonly", - "property_public_abstract", - "property_protected_abstract", - "property_public", - "property_protected", - "property_private", - "property", - "construct", - "destruct", - "magic", - "phpunit", - "method_public_abstract_static", - "method_protected_abstract_static", - "method_private_abstract_static", - "method_public_abstract", - "method_protected_abstract", - "method_private_abstract", - "method_abstract", - "method_public_static", - "method_public", - "method_protected_static", - "method_protected", - "method_private_static", - "method_private", - "method_static", - "method" - ], - "sort_algorithm": "alpha" - }, - "blank_line_after_opening_tag": true, - "no_alias_functions": true, - "multiline_whitespace_before_semicolons": true, - "no_trailing_whitespace": true, - "blank_line_before_statement": { - "statements": [ - "break", - "continue", - "declare", - "return", - "throw", - "try" - ] - }, - "phpdoc_align": { - "align": "left" - }, - "binary_operator_spaces": { - "default": "single_space" - }, - "concat_space": { - "spacing": "one" - }, - "cast_spaces": true, - "unary_operator_spaces": true, - "ternary_operator_spaces": true, - "array_indentation": true, - "trim_array_spaces": true, - "method_argument_space": { - "on_multiline": "ensure_fully_multiline" - }, - "trailing_comma_in_multiline": { - "elements": [ - "arrays", - "arguments", - "parameters", - "match" - ] - }, - "single_quote": true, - "single_line_empty_body": true, - "no_multiple_statements_per_line": true, - "no_extra_blank_lines": true, - "no_whitespace_in_blank_line": true, - "single_blank_line_at_eof": true, - "statement_indentation": true, - "control_structure_braces": true, - "control_structure_continuation_position": true, - "declare_parentheses": true, - "declare_strict_types": true, - "lowercase_keywords": true, - "constant_case": true, - "lowercase_static_reference": true, - "native_function_casing": true, - "nullable_type_declaration_for_default_null_value": true, - "no_superfluous_phpdoc_tags": true, - "phpdoc_trim": true - } -} diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 0cbbcd3..0000000 --- a/psalm.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php index a38a323..c5e1d52 100644 --- a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php +++ b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php @@ -215,24 +215,14 @@ private function prepareLocalZipSource(string $source): array throw new DirectoryOperationException('Unable to create temporary ZIP source.'); } - $sourceStream = FlysystemHelper::readStream($source); - $targetStream = fopen($tempSource, 'wb'); - if (!is_resource($sourceStream) || !is_resource($targetStream)) { - if (is_resource($sourceStream)) { - fclose($sourceStream); - } - if (is_resource($targetStream)) { - fclose($targetStream); - } + try { + FlysystemHelper::copy($source, $tempSource); + } catch (\Throwable) { $this->unlinkFileSilently($tempSource); throw new DirectoryOperationException("Unable to read ZIP source: {$source}"); } - stream_copy_to_stream($sourceStream, $targetStream); - fclose($sourceStream); - fclose($targetStream); - return [$tempSource, true]; } diff --git a/src/DirectoryManager/DirectoryOperations.php b/src/DirectoryManager/DirectoryOperations.php index 2252d26..ffeecc9 100644 --- a/src/DirectoryManager/DirectoryOperations.php +++ b/src/DirectoryManager/DirectoryOperations.php @@ -173,20 +173,11 @@ public function delete(bool $recursive = false): bool public function find(array $criteria = []): array { $results = []; - $sourceLocation = $this->storageLocation($this->path); $isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; - foreach ($this->listStorageEntries($this->path, true) as $item) { - if ($this->entryType($item) !== 'file') { - continue; - } - - $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); - if ($relative === '') { - continue; - } - - $resolvedPath = $this->buildPath($this->path, $relative); + foreach ($this->iterateResolvedEntries(true, true) as $entry) { + $resolvedPath = $entry['path']; + $item = $entry['item']; $size = $this->entrySize($item); if (!$this->matchesFindCriteria($criteria, $resolvedPath, $size, $isWindows)) { @@ -209,19 +200,9 @@ public function find(array $criteria = []): array public function flatten(?callable $filter = null): array { $flattened = []; - $sourceLocation = $this->storageLocation($this->path); - - foreach ($this->listStorageEntries($this->path, true) as $item) { - if ($this->entryType($item) !== 'file') { - continue; - } - - $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); - if ($relative === '') { - continue; - } - - $resolvedPath = $this->buildPath($this->path, $relative); + foreach ($this->iterateResolvedEntries(true, true) as $entry) { + $resolvedPath = $entry['path']; + $item = $entry['item']; if (!$this->invokeFilter($filter, $resolvedPath, $item)) { continue; } @@ -240,14 +221,8 @@ public function flatten(?callable $filter = null): array public function getDepth(): int { $maxDepth = 0; - $sourceLocation = $this->storageLocation($this->path); - - foreach ($this->listStorageEntries($this->path, true) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); - if ($relative === '') { - continue; - } - + foreach ($this->iterateResolvedEntries(true, false) as $entry) { + $relative = $entry['relative']; $depth = substr_count(trim(str_replace('\\', '/', $relative), '/'), '/'); $maxDepth = max($maxDepth, $depth); } @@ -301,15 +276,9 @@ public function getPermissions(): int public function listContents(bool $detailed = false, ?callable $filter = null): array { $contents = []; - $sourceLocation = $this->storageLocation($this->path); - - foreach ($this->listStorageEntries($this->path, true) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); - if ($relative === '') { - continue; - } - - $resolvedPath = $this->buildPath($this->path, $relative); + foreach ($this->iterateResolvedEntries(true, false) as $entry) { + $resolvedPath = $entry['path']; + $item = $entry['item']; if (!$this->invokeFilter($filter, $resolvedPath, $item)) { continue; } @@ -355,15 +324,10 @@ public function listPermissions(): string */ public function listSortedContents(string $sortOrder = 'asc'): array { - $sourceLocation = $this->storageLocation($this->path); $contents = []; - foreach ($this->listStorageEntries($this->path, false) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); - if ($relative === '') { - continue; - } - + foreach ($this->iterateResolvedEntries(false, false) as $entry) { + $relative = $entry['relative']; $contents[] = basename(str_replace('\\', '/', $relative)); } @@ -443,19 +407,9 @@ public function setVisibility(string $visibility): self public function size(?callable $filter = null): int { $size = 0; - $sourceLocation = $this->storageLocation($this->path); - - foreach ($this->listStorageEntries($this->path, true) as $item) { - if ($this->entryType($item) !== 'file') { - continue; - } - - $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); - if ($relative === '') { - continue; - } - - $resolvedPath = $this->buildPath($this->path, $relative); + foreach ($this->iterateResolvedEntries(true, true) as $entry) { + $resolvedPath = $entry['path']; + $item = $entry['item']; if (!$this->invokeFilter($filter, $resolvedPath, $item)) { continue; } @@ -573,4 +527,29 @@ public function zip(string $destination): bool return true; } + + /** + * @return \Generator + */ + private function iterateResolvedEntries(bool $deep, bool $filesOnly): \Generator + { + $sourceLocation = $this->storageLocation($this->path); + + foreach ($this->listStorageEntries($this->path, $deep) as $item) { + if ($filesOnly && $this->entryType($item) !== 'file') { + continue; + } + + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); + if ($relative === '') { + continue; + } + + yield [ + 'path' => $this->buildPath($this->path, $relative), + 'relative' => $relative, + 'item' => $item, + ]; + } + } } diff --git a/src/FileManager/Concerns/FsConcern.php b/src/FileManager/Concerns/FsConcern.php index c46d709..8ba1191 100644 --- a/src/FileManager/Concerns/FsConcern.php +++ b/src/FileManager/Concerns/FsConcern.php @@ -6,7 +6,9 @@ use Infocyph\Pathwise\Exceptions\CompressionException; use Infocyph\Pathwise\Utils\FlysystemHelper; +use Infocyph\Pathwise\Utils\FlysystemPathResolver; use Infocyph\Pathwise\Utils\PathHelper; +use Infocyph\Pathwise\Utils\StreamTransferHelper; trait FsConcern { @@ -14,22 +16,11 @@ private function doCopyFlysystemFileToLocal(string $sourcePath, string $localTar { $this->doEnsureLocalDirectoryExists(dirname($localTarget)); - $stream = FlysystemHelper::readStream($sourcePath); - $target = fopen($localTarget, 'wb'); - if (!is_resource($stream) || !is_resource($target)) { - if (is_resource($stream)) { - fclose($stream); - } - if (is_resource($target)) { - fclose($target); - } - + try { + FlysystemHelper::copy($sourcePath, $localTarget); + } catch (\Throwable) { throw new CompressionException("Unable to read source path: {$sourcePath}"); } - - stream_copy_to_stream($stream, $target); - fclose($stream); - fclose($target); } private function doEnsureLocalDirectoryExists(string $path): void @@ -110,24 +101,14 @@ private function doLocalizeRemoteFileSource(string $normalizedSource, string $or throw new CompressionException("Unable to localize source path: {$originalSource}"); } - $stream = FlysystemHelper::readStream($normalizedSource); - $target = fopen($tempFile, 'wb'); - if (!is_resource($stream) || !is_resource($target)) { - if (is_resource($stream)) { - fclose($stream); - } - if (is_resource($target)) { - fclose($target); - } + try { + $this->doCopyFlysystemFileToLocal($normalizedSource, $tempFile); + } catch (\Throwable) { $this->doUnlinkFileSilently($tempFile); throw new CompressionException("Unable to localize source path: {$originalSource}"); } - stream_copy_to_stream($stream, $target); - fclose($stream); - fclose($target); - return PathHelper::normalize($tempFile); } @@ -155,36 +136,12 @@ private function doMaterializeDirectoryToLocal(string $sourcePath, string $local private function doResolveMaterializationBase(string $sourcePath): string { - [, $baseLocation] = FlysystemHelper::resolveDirectory($sourcePath); - - return trim(str_replace('\\', '/', $baseLocation), '/'); + return FlysystemPathResolver::resolveDirectoryBase($sourcePath); } private function doResolveMaterializedRelativePath(mixed $item, string $base): ?string { - if (!is_array($item)) { - return null; - } - - $itemPathRaw = $item['path'] ?? null; - if (!is_string($itemPathRaw)) { - return null; - } - - $itemPath = trim($itemPathRaw, '/'); - if ($itemPath === '') { - return null; - } - - if ($base !== '' && str_starts_with($itemPath, $base . '/')) { - return substr($itemPath, strlen($base) + 1); - } - - if ($itemPath === $base) { - return null; - } - - return $itemPath; + return FlysystemPathResolver::relativePathFromItem($item, $base); } private function doResolveWorkingZipPath(bool $create): string @@ -203,22 +160,11 @@ private function doResolveWorkingZipPath(bool $create): string $normalizedTemp = PathHelper::normalize($tempFile); if (FlysystemHelper::fileExists($this->zipFilePath)) { - $source = FlysystemHelper::readStream($this->zipFilePath); - $target = fopen($normalizedTemp, 'wb'); - if (!is_resource($source) || !is_resource($target)) { - if (is_resource($source)) { - fclose($source); - } - if (is_resource($target)) { - fclose($target); - } - + try { + $this->doCopyFlysystemFileToLocal($this->zipFilePath, $normalizedTemp); + } catch (\Throwable) { throw new CompressionException("Unable to read ZIP archive: {$this->zipFilePath}"); } - - stream_copy_to_stream($source, $target); - fclose($source); - fclose($target); } elseif (!$create) { $this->doUnlinkFileSilently($normalizedTemp); @@ -290,20 +236,12 @@ private function doShouldTraverseDirectory(string $relativePath): bool private function doSyncWorkingZipIfNeeded(): void { - if (!$this->syncWorkingZipOnClose || !is_file($this->workingZipPath)) { - return; - } - - $stream = fopen($this->workingZipPath, 'rb'); - if (!is_resource($stream)) { - throw new CompressionException("Unable to stream ZIP archive: {$this->workingZipPath}"); - } - - try { - FlysystemHelper::writeStream($this->zipFilePath, $stream); - } finally { - fclose($stream); - } + StreamTransferHelper::syncLocalFileToPathOrThrow( + $this->syncWorkingZipOnClose, + $this->workingZipPath, + $this->zipFilePath, + fn(): \Throwable => new CompressionException("Unable to stream ZIP archive: {$this->workingZipPath}"), + ); } private function doUnlinkFileSilently(string $path): void diff --git a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php index ca1b2df..56e7db5 100644 --- a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php +++ b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php @@ -16,11 +16,7 @@ trait SafeFileWriterWriteConcern */ private function optionalBoolParam(array $params, int $index, bool $default): bool { - $value = $params[$index] ?? null; - if ($value === null) { - return $default; - } - + $value = $this->optionalParamValue($params, $index, $default); if (!is_bool($value)) { throw new Exception("Expected bool parameter at index {$index}."); } @@ -31,13 +27,17 @@ private function optionalBoolParam(array $params, int $index, bool $default): bo /** * @param list $params */ - private function optionalStringParam(array $params, int $index, string $default): string + private function optionalParamValue(array $params, int $index, mixed $default): mixed { - $value = $params[$index] ?? null; - if ($value === null) { - return $default; - } + return $params[$index] ?? $default; + } + /** + * @param list $params + */ + private function optionalStringParam(array $params, int $index, string $default): string + { + $value = $this->optionalParamValue($params, $index, $default); if (!is_string($value)) { throw new Exception("Expected string parameter at index {$index}."); } @@ -254,14 +254,7 @@ private function writeFixedWidth(array $data, array $widths): int|false */ private function writeJSON(mixed $data, bool $prettyPrint = false): int|false { - $jsonOptions = $prettyPrint ? JSON_PRETTY_PRINT : 0; - $jsonData = json_encode($data, $jsonOptions); - if ($jsonData === false) { - throw new Exception('JSON encoding failed: ' . json_last_error_msg()); - } - $this->writeCount++; - - return $this->requireFileHandle()->fwrite($jsonData . PHP_EOL); + return $this->writeJsonEncodedLine($data, $prettyPrint); } /** @@ -274,6 +267,11 @@ private function writeJSON(mixed $data, bool $prettyPrint = false): int|false * @throws Exception If the JSON encoding fails. */ private function writeJSONArray(array $data, bool $prettyPrint = false): int|false + { + return $this->writeJsonEncodedLine($data, $prettyPrint); + } + + private function writeJsonEncodedLine(mixed $data, bool $prettyPrint): int|false { $jsonOptions = $prettyPrint ? JSON_PRETTY_PRINT : 0; $jsonData = json_encode($data, $jsonOptions); diff --git a/src/FileManager/FileCompression.php b/src/FileManager/FileCompression.php index 8006419..c15aa4c 100644 --- a/src/FileManager/FileCompression.php +++ b/src/FileManager/FileCompression.php @@ -243,9 +243,7 @@ public function checkIntegrity(): bool public function compress(string $source): self { $this->reopenIfNeeded(); - $cleanupPath = null; - $resolvedSource = $this->localizeCompressionSource($source, $cleanupPath); - $this->deferLocalizedCleanupPath($cleanupPath); + $resolvedSource = $this->prepareCompressionSource($source); if ($this->shouldAttemptNativeCompression() && NativeOperationsAdapter::canUseNativeCompression()) { $this->closeZip(); @@ -290,9 +288,7 @@ public function compress(string $source): self public function compressWithFilter(string $source, array $extensions = []): self { $this->reopenIfNeeded(); - $cleanupPath = null; - $resolvedSource = $this->localizeCompressionSource($source, $cleanupPath); - $this->deferLocalizedCleanupPath($cleanupPath); + $resolvedSource = $this->prepareCompressionSource($source); $this->loadIgnorePatterns($resolvedSource); $this->initializeProgress($resolvedSource, $extensions); @@ -561,4 +557,13 @@ public function setProgressCallback(callable $progressCallback): self return $this; } + + private function prepareCompressionSource(string $source): string + { + $cleanupPath = null; + $resolvedSource = $this->localizeCompressionSource($source, $cleanupPath); + $this->deferLocalizedCleanupPath($cleanupPath); + + return $resolvedSource; + } } diff --git a/src/FileManager/FileOperations.php b/src/FileManager/FileOperations.php index 0193372..c31798a 100644 --- a/src/FileManager/FileOperations.php +++ b/src/FileManager/FileOperations.php @@ -45,13 +45,7 @@ public function __construct(protected string $filePath) public function append(string $content): self { $this->assertPolicy('append', $this->filePath); - $previousContent = $this->exists() ? $this->read() : null; - $this->recordRollback(function () use ($previousContent): void { - if ($previousContent === null) { - return; - } - FlysystemHelper::write($this->filePath, $previousContent); - }); + $previousContent = $this->snapshotRollbackContent(); $newContent = ($previousContent ?? '') . $content; FlysystemHelper::write($this->filePath, $newContent); $this->audit('append', ['path' => $this->filePath, 'bytes' => strlen($content)]); @@ -427,13 +421,12 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self */ public function setGroup(int $groupId): self { - $this->assertPolicy('set-group', $this->filePath); - if (!chgrp($this->filePath, $groupId)) { - throw new FileAccessException("Unable to change group for file: {$this->filePath}."); - } - $this->audit('set-group', ['path' => $this->filePath, 'group' => $groupId]); - - return $this; + return $this->applyOwnershipChange( + 'set-group', + $groupId, + static fn(string $path, int $id): bool => chgrp($path, $id), + 'group', + ); } /** @@ -441,13 +434,12 @@ public function setGroup(int $groupId): self */ public function setOwner(int $ownerId): self { - $this->assertPolicy('set-owner', $this->filePath); - if (!chown($this->filePath, $ownerId)) { - throw new FileAccessException("Unable to change owner for file: {$this->filePath}."); - } - $this->audit('set-owner', ['path' => $this->filePath, 'owner' => $ownerId]); - - return $this; + return $this->applyOwnershipChange( + 'set-owner', + $ownerId, + static fn(string $path, int $id): bool => chown($path, $id), + 'owner', + ); } /** @@ -557,13 +549,7 @@ public function unlock(): self public function update(string $content): self { $this->assertPolicy('update', $this->filePath); - $previous = $this->exists() ? $this->read() : null; - $this->recordRollback(function () use ($previous): void { - if ($previous === null) { - return; - } - FlysystemHelper::write($this->filePath, $previous); - }); + $this->snapshotRollbackContent(); FlysystemHelper::write($this->filePath, $content); $this->audit('update', ['path' => $this->filePath, 'bytes' => strlen($content)]); @@ -652,6 +638,18 @@ protected function initFile(string $mode = 'r'): self return $this; } + private function applyOwnershipChange(string $action, int $value, callable $updater, string $label): self + { + $this->assertPolicy($action, $this->filePath); + if (!$updater($this->filePath, $value)) { + throw new FileAccessException("Unable to change {$label} for file: {$this->filePath}."); + } + + $this->audit($action, ['path' => $this->filePath, $label => $value]); + + return $this; + } + /** * @param array $context */ @@ -716,4 +714,18 @@ private function requireFile(string $mode = 'r'): SplFileObject return $this->file; } + + private function snapshotRollbackContent(): ?string + { + $previousContent = $this->exists() ? $this->read() : null; + $this->recordRollback(function () use ($previousContent): void { + if ($previousContent === null) { + return; + } + + FlysystemHelper::write($this->filePath, $previousContent); + }); + + return $previousContent; + } } diff --git a/src/FileManager/SafeFileWriter.php b/src/FileManager/SafeFileWriter.php index 97e3156..541813d 100644 --- a/src/FileManager/SafeFileWriter.php +++ b/src/FileManager/SafeFileWriter.php @@ -12,6 +12,7 @@ use Infocyph\Pathwise\FileManager\Concerns\SafeFileWriterWriteConcern; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; +use Infocyph\Pathwise\Utils\StreamTransferHelper; use JsonSerializable; use SplFileObject; use Stringable; @@ -210,16 +211,9 @@ public function flush(): void */ public function getCreationDate(): DateTime { - $target = $this->getActiveOrFinalPath(); - if (is_file($target)) { - return new DateTime('@' . filectime($target)); - } - - if (FlysystemHelper::fileExists($this->filename)) { - return new DateTime('@' . FlysystemHelper::lastModified($this->filename)); - } - - return new DateTime(); + return $this->resolveFileDate( + static fn(string $path): int => (int) filectime($path), + ); } /** @@ -229,16 +223,9 @@ public function getCreationDate(): DateTime */ public function getModificationDate(): DateTime { - $target = $this->getActiveOrFinalPath(); - if (is_file($target)) { - return new DateTime('@' . filemtime($target)); - } - - if (FlysystemHelper::fileExists($this->filename)) { - return new DateTime('@' . FlysystemHelper::lastModified($this->filename)); - } - - return new DateTime(); + return $this->resolveFileDate( + static fn(string $path): int => (int) filemtime($path), + ); } /** @@ -520,22 +507,30 @@ private function preloadRemoteAppendSourceIfNeeded(): void return; } - $source = FlysystemHelper::readStream($this->filename); - $target = fopen($this->localWorkingPath, 'wb'); - if (!is_resource($source) || !is_resource($target)) { - if (is_resource($source)) { - fclose($source); - } - if (is_resource($target)) { - fclose($target); - } - + try { + FlysystemHelper::copy($this->filename, $this->localWorkingPath); + } catch (\Throwable) { throw new FileAccessException("Cannot write to file: {$this->filename}"); } + } + + /** + * @param callable(string): int $localDateResolver + */ + private function resolveFileDate(callable $localDateResolver): DateTime + { + $target = $this->getActiveOrFinalPath(); + if (is_file($target)) { + $timestamp = $localDateResolver($target); + + return new DateTime('@' . $timestamp); + } + + if (FlysystemHelper::fileExists($this->filename)) { + return new DateTime('@' . FlysystemHelper::lastModified($this->filename)); + } - stream_copy_to_stream($source, $target); - fclose($source); - fclose($target); + return new DateTime(); } private function resolveNonAtomicTargetFilePath(): string @@ -579,20 +574,12 @@ private function runSilently(callable $operation): mixed private function syncWorkingCopyBack(): void { - if (!$this->syncBackOnClose || !is_string($this->localWorkingPath) || !is_file($this->localWorkingPath)) { - return; - } - - $stream = fopen($this->localWorkingPath, 'rb'); - if (!is_resource($stream)) { - throw new FileAccessException("Cannot write to file: {$this->filename}"); - } - - try { - FlysystemHelper::writeStream($this->filename, $stream); - } finally { - fclose($stream); - } + StreamTransferHelper::syncLocalFileToPathOrThrow( + $this->syncBackOnClose, + $this->localWorkingPath, + $this->filename, + fn(): \Throwable => new FileAccessException("Cannot write to file: {$this->filename}"), + ); } private function unlinkPathSilently(string $path): void diff --git a/src/Indexing/ChecksumIndexer.php b/src/Indexing/ChecksumIndexer.php index 1bc4f8f..f715083 100644 --- a/src/Indexing/ChecksumIndexer.php +++ b/src/Indexing/ChecksumIndexer.php @@ -4,11 +4,10 @@ namespace Infocyph\Pathwise\Indexing; -use FilesystemIterator; use Infocyph\Pathwise\Utils\FlysystemHelper; +use Infocyph\Pathwise\Utils\FlysystemPathResolver; +use Infocyph\Pathwise\Utils\LocalFileIterator; use Infocyph\Pathwise\Utils\PathHelper; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; final class ChecksumIndexer { @@ -145,25 +144,8 @@ private static function iterFiles(string $directory): array */ private static function iterFilesLocal(string $directory): array { - if (!is_dir($directory)) { - return []; - } - $paths = []; - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST, - ); - - foreach ($iterator as $item) { - if (!$item instanceof \SplFileInfo) { - continue; - } - - if ($item->isDir()) { - continue; - } - + foreach (LocalFileIterator::files($directory) as $item) { $paths[] = $item->getPathname(); } @@ -176,28 +158,11 @@ private static function iterFilesLocal(string $directory): array private static function iterFilesViaFlysystem(string $directory): array { $paths = []; - [, $baseLocation] = FlysystemHelper::resolveDirectory($directory); - $base = trim(str_replace('\\', '/', $baseLocation), '/'); + $base = FlysystemPathResolver::resolveDirectoryBase($directory); foreach (FlysystemHelper::listContents($directory, true) as $item) { - if (($item['type'] ?? null) !== 'file') { - continue; - } - - $itemPathRaw = $item['path'] ?? null; - if (!is_string($itemPathRaw)) { - continue; - } - - $itemPath = trim($itemPathRaw, '/'); - if ($itemPath === '') { - continue; - } - - $relative = $base !== '' && str_starts_with($itemPath, $base . '/') - ? substr($itemPath, strlen($base) + 1) - : ($itemPath === $base ? '' : $itemPath); - if ($relative === '') { + $relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file'); + if ($relative === null) { continue; } diff --git a/src/Native/NativeOperationsAdapter.php b/src/Native/NativeOperationsAdapter.php index e013d59..4872058 100644 --- a/src/Native/NativeOperationsAdapter.php +++ b/src/Native/NativeOperationsAdapter.php @@ -118,48 +118,38 @@ public static function copyDirectory(string $source, string $destination, bool $ $source = PathHelper::normalize($source); $destination = PathHelper::normalize($destination); - if (PHP_OS_FAMILY === 'Windows' && NativeCommandRunner::commandExists('robocopy')) { + if (PHP_OS_FAMILY === 'Windows') { $flags = $mirror ? '/MIR' : '/E'; - $command = sprintf( - 'robocopy %s %s %s /R:1 /W:1 /NFL /NDL /NJH /NJS /NP', - escapeshellarg($source), - escapeshellarg($destination), - $flags, + $result = self::runCommandIfAvailable( + 'robocopy', + static fn(): string => sprintf( + 'robocopy %s %s %s /R:1 /W:1 /NFL /NDL /NJH /NJS /NP', + escapeshellarg($source), + escapeshellarg($destination), + $flags, + ), + static fn(array $result): bool => $result['code'] <= 7, ); - $result = NativeCommandRunner::run($command); - - // Robocopy exit codes 0-7 are considered successful copies. - $success = $result['code'] <= 7; - - return [ - 'success' => $success, - 'command' => $command, - 'code' => $result['code'], - ]; + if ($result !== null) { + return $result; + } } - if (NativeCommandRunner::commandExists('rsync')) { - $deleteFlag = $mirror ? ' --delete' : ''; - $command = sprintf( + $deleteFlag = $mirror ? ' --delete' : ''; + $result = self::runCommandIfAvailable( + 'rsync', + static fn(): string => sprintf( 'rsync -a%s %s/ %s/', $deleteFlag, escapeshellarg($source), escapeshellarg($destination), - ); - $result = NativeCommandRunner::run($command); - - return [ - 'success' => $result['success'], - 'command' => $command, - 'code' => $result['code'], - ]; + ), + ); + if ($result !== null) { + return $result; } - return [ - 'success' => false, - 'command' => '', - 'code' => 127, - ]; + return self::unsupportedResult(); } /** @@ -167,44 +157,22 @@ public static function copyDirectory(string $source, string $destination, bool $ */ public static function copyFile(string $source, string $destination): array { - $source = PathHelper::normalize($source); - $destination = PathHelper::normalize($destination); - - if (PHP_OS_FAMILY === 'Windows' && NativeCommandRunner::commandExists('cmd')) { - $command = sprintf( + return self::runDualPathOperation( + $source, + $destination, + 'cmd', + static fn(string $normalizedSource, string $normalizedDestination): string => sprintf( 'cmd /C copy /Y %s %s >NUL', - escapeshellarg($source), - escapeshellarg($destination), - ); - $result = NativeCommandRunner::run($command); - - return [ - 'success' => $result['success'], - 'command' => $command, - 'code' => $result['code'], - ]; - } - - if (NativeCommandRunner::commandExists('cp')) { - $command = sprintf( + escapeshellarg($normalizedSource), + escapeshellarg($normalizedDestination), + ), + 'cp', + static fn(string $normalizedSource, string $normalizedDestination): string => sprintf( 'cp -f %s %s', - escapeshellarg($source), - escapeshellarg($destination), - ); - $result = NativeCommandRunner::run($command); - - return [ - 'success' => $result['success'], - 'command' => $command, - 'code' => $result['code'], - ]; - } - - return [ - 'success' => false, - 'command' => '', - 'code' => 127, - ]; + escapeshellarg($normalizedSource), + escapeshellarg($normalizedDestination), + ), + ); } /** @@ -212,39 +180,98 @@ public static function copyFile(string $source, string $destination): array */ public static function decompressZip(string $zipPath, string $destination): array { - $zipPath = PathHelper::normalize($zipPath); - $destination = PathHelper::normalize($destination); - - if (PHP_OS_FAMILY === 'Windows' && NativeCommandRunner::commandExists('powershell')) { - $command = sprintf( + return self::runDualPathOperation( + $zipPath, + $destination, + 'powershell', + static fn(string $normalizedZipPath, string $normalizedDestination): string => sprintf( 'powershell -NoProfile -Command "Expand-Archive -Path %s -DestinationPath %s -Force"', - escapeshellarg($zipPath), - escapeshellarg($destination), - ); - $result = NativeCommandRunner::run($command); + escapeshellarg($normalizedZipPath), + escapeshellarg($normalizedDestination), + ), + 'unzip', + static fn(string $normalizedZipPath, string $normalizedDestination): string => sprintf( + 'unzip -o %s -d %s', + escapeshellarg($normalizedZipPath), + escapeshellarg($normalizedDestination), + ), + ); + } - return [ - 'success' => $result['success'], - 'command' => $command, - 'code' => $result['code'], - ]; + /** + * @param callable(): string $commandBuilder + * @param callable(array{success: bool, output: array, code: int}): bool|null $successResolver + * @return array{success: bool, command: string, code: int}|null + */ + private static function runCommandIfAvailable( + string $command, + callable $commandBuilder, + ?callable $successResolver = null, + ): ?array { + if (!NativeCommandRunner::commandExists($command)) { + return null; } - if (NativeCommandRunner::commandExists('unzip')) { - $command = sprintf( - 'unzip -o %s -d %s', - escapeshellarg($zipPath), - escapeshellarg($destination), - ); - $result = NativeCommandRunner::run($command); + $builtCommand = $commandBuilder(); + $result = NativeCommandRunner::run($builtCommand); - return [ - 'success' => $result['success'], - 'command' => $command, - 'code' => $result['code'], - ]; + return [ + 'success' => $successResolver !== null ? (bool) $successResolver($result) : $result['success'], + 'command' => $builtCommand, + 'code' => $result['code'], + ]; + } + + /** + * @param callable(string, string): string $windowsCommandBuilder + * @param callable(string, string): string $unixCommandBuilder + * @return array{success: bool, command: string, code: int} + */ + private static function runDualPathOperation( + string $sourcePath, + string $destinationPath, + string $windowsCommand, + callable $windowsCommandBuilder, + string $unixCommand, + callable $unixCommandBuilder, + ): array { + $normalizedSourcePath = PathHelper::normalize($sourcePath); + $normalizedDestinationPath = PathHelper::normalize($destinationPath); + + return self::runWindowsThenUnix( + $windowsCommand, + static fn(): string => $windowsCommandBuilder($normalizedSourcePath, $normalizedDestinationPath), + $unixCommand, + static fn(): string => $unixCommandBuilder($normalizedSourcePath, $normalizedDestinationPath), + ); + } + + /** + * @param callable(): string $windowsCommandBuilder + * @param callable(): string $unixCommandBuilder + * @return array{success: bool, command: string, code: int} + */ + private static function runWindowsThenUnix( + string $windowsCommand, + callable $windowsCommandBuilder, + string $unixCommand, + callable $unixCommandBuilder, + ): array { + if (PHP_OS_FAMILY === 'Windows') { + $windowsResult = self::runCommandIfAvailable($windowsCommand, $windowsCommandBuilder); + if ($windowsResult !== null) { + return $windowsResult; + } } + return self::runCommandIfAvailable($unixCommand, $unixCommandBuilder) ?? self::unsupportedResult(); + } + + /** + * @return array{success: bool, command: string, code: int} + */ + private static function unsupportedResult(): array + { return [ 'success' => false, 'command' => '', diff --git a/src/Retention/RetentionManager.php b/src/Retention/RetentionManager.php index 48c8376..4a65c6c 100644 --- a/src/Retention/RetentionManager.php +++ b/src/Retention/RetentionManager.php @@ -4,11 +4,10 @@ namespace Infocyph\Pathwise\Retention; -use FilesystemIterator; use Infocyph\Pathwise\Utils\FlysystemHelper; +use Infocyph\Pathwise\Utils\FlysystemPathResolver; +use Infocyph\Pathwise\Utils\LocalFileIterator; use Infocyph\Pathwise\Utils\PathHelper; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; final class RetentionManager { @@ -75,25 +74,8 @@ private static function collectFiles(string $directory): array */ private static function collectFilesLocal(string $directory): array { - if (!is_dir($directory)) { - return []; - } - $files = []; - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST, - ); - - foreach ($iterator as $item) { - if (!$item instanceof \SplFileInfo) { - continue; - } - - if ($item->isDir()) { - continue; - } - + foreach (LocalFileIterator::files($directory) as $item) { $files[] = [ 'path' => $item->getPathname(), 'mtime' => (int) $item->getMTime(), @@ -110,8 +92,7 @@ private static function collectFilesLocal(string $directory): array private static function collectFilesViaFlysystem(string $directory): array { $files = []; - [, $baseLocation] = FlysystemHelper::resolveDirectory($directory); - $base = trim(str_replace('\\', '/', $baseLocation), '/'); + $base = FlysystemPathResolver::resolveDirectoryBase($directory); foreach (FlysystemHelper::listContents($directory, true) as $item) { $entry = self::normalizeFlysystemEntry($directory, $base, $item); @@ -131,32 +112,12 @@ private static function collectFilesViaFlysystem(string $directory): array */ private static function normalizeFlysystemEntry(string $directory, string $base, array $item): ?array { - $type = $item['type'] ?? null; - if (!is_string($type) || $type !== 'file') { - return null; - } - - $itemPathRaw = $item['path'] ?? null; - if (!is_string($itemPathRaw)) { - return null; - } - - $itemPath = trim($itemPathRaw, '/'); - if ($itemPath === '') { - return null; - } - - $relative = $base !== '' && str_starts_with($itemPath, $base . '/') - ? substr($itemPath, strlen($base) + 1) - : ($itemPath === $base ? '' : $itemPath); - if ($relative === '') { + $relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file'); + if ($relative === null) { return null; } - $lastModified = $item['last_modified'] ?? 0; - $mtime = is_int($lastModified) - ? $lastModified - : (is_numeric($lastModified) ? (int) $lastModified : 0); + $mtime = FlysystemPathResolver::intFromMixed($item['last_modified'] ?? 0); return [ 'path' => PathHelper::join($directory, $relative), diff --git a/src/Storage/StorageFactory.php b/src/Storage/StorageFactory.php index 921dd2d..ead1398 100644 --- a/src/Storage/StorageFactory.php +++ b/src/Storage/StorageFactory.php @@ -361,14 +361,8 @@ private static function normalizeDriverName(string $name): string */ private static function resolveAdapter(array $config): ?FilesystemAdapter { - if (!array_key_exists('adapter', $config)) { - return null; - } - - $adapter = $config['adapter']; - if (!$adapter instanceof FilesystemAdapter) { - throw new \InvalidArgumentException('The "adapter" config value must implement FilesystemAdapter.'); - } + /** @var FilesystemAdapter|null $adapter */ + $adapter = self::resolveTypedConfigObject($config, 'adapter', FilesystemAdapter::class); return $adapter; } @@ -419,15 +413,31 @@ private static function resolveOptions(array $config): array */ private static function resolveProvidedFilesystem(array $config): ?FilesystemOperator { - if (!array_key_exists('filesystem', $config)) { + /** @var FilesystemOperator|null $filesystem */ + $filesystem = self::resolveTypedConfigObject($config, 'filesystem', FilesystemOperator::class); + + return $filesystem; + } + + /** + * @template T of object + * @param array $config + * @param class-string $expectedClass + * @return T|null + */ + private static function resolveTypedConfigObject(array $config, string $key, string $expectedClass): ?object + { + if (!array_key_exists($key, $config)) { return null; } - $filesystem = $config['filesystem']; - if (!$filesystem instanceof FilesystemOperator) { - throw new \InvalidArgumentException('The "filesystem" config value must implement FilesystemOperator.'); + $value = $config[$key]; + if (!$value instanceof $expectedClass) { + throw new \InvalidArgumentException( + sprintf('The "%s" config value must implement %s.', $key, $expectedClass), + ); } - return $filesystem; + return $value; } } diff --git a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php index 17286d1..3454c29 100644 --- a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php +++ b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php @@ -6,6 +6,7 @@ use Infocyph\Pathwise\Exceptions\FileSizeExceededException; use Infocyph\Pathwise\Exceptions\UploadException; +use Infocyph\Pathwise\Utils\ExtensionPolicy; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\MetadataHelper; use Infocyph\Pathwise\Utils\PathHelper; @@ -316,21 +317,17 @@ private function validateFile(array $file): void private function validateFileExtension(string $extension): void { $normalized = $this->normalizeExtension($extension); - if ($normalized === '') { - if ($this->allowedExtensions !== []) { - throw new UploadException('File extension is required.'); - } - + $error = ExtensionPolicy::validate($normalized, $this->allowedExtensions, $this->blockedExtensions); + if ($error === null) { 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.'); - } + throw new UploadException(ExtensionPolicy::messageFor( + $error, + 'File extension is required.', + 'Blocked file extension.', + 'File extension is not allowed.', + )); } /** @@ -358,19 +355,8 @@ private function validateFileType(string $fileType): void private function validateFinalizedUpload(string $destination): void { - $finalSize = FlysystemHelper::size($destination); - $this->validateFileSize($finalSize); - - $fileType = $this->getFileMimeType($destination); $extension = pathinfo($destination, PATHINFO_EXTENSION); - $this->validateFileExtension($extension); - $this->validateFileType($fileType); - $this->validateContentTypeIntegrity($destination, $fileType, $extension); - if ($this->isImage($fileType)) { - $this->validateImageDimensions($destination); - } - - $this->scanForMalware($destination, $fileType); + $this->validateUploadedPayload($destination, $extension, true); } /** @@ -453,4 +439,23 @@ private function validateMimeTypeMatchesExtension(string $fileType, string $exte throw new UploadException('File content type does not match extension.'); } } + + private function validateUploadedPayload(string $filePath, string $extension, bool $validateSize): string + { + if ($validateSize) { + $this->validateFileSize(FlysystemHelper::size($filePath)); + } + + $fileType = $this->getFileMimeType($filePath); + $this->validateFileExtension($extension); + $this->validateFileType($fileType); + $this->validateContentTypeIntegrity($filePath, $fileType, $extension); + if ($this->isImage($fileType)) { + $this->validateImageDimensions($filePath); + } + + $this->scanForMalware($filePath, $fileType); + + return $fileType; + } } diff --git a/src/StreamHandler/DownloadProcessor.php b/src/StreamHandler/DownloadProcessor.php index 4977664..495f4aa 100644 --- a/src/StreamHandler/DownloadProcessor.php +++ b/src/StreamHandler/DownloadProcessor.php @@ -7,6 +7,7 @@ use Infocyph\Pathwise\Exceptions\DownloadException; use Infocyph\Pathwise\Exceptions\FileNotFoundException; use Infocyph\Pathwise\Exceptions\FileSizeExceededException; +use Infocyph\Pathwise\Utils\ExtensionPolicy; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\MetadataHelper; use Infocyph\Pathwise\Utils\PathHelper; @@ -564,21 +565,18 @@ private function validateDownloadPath(string $path): void private function validateExtension(string $extension): void { $normalized = $this->normalizeExtension($extension); - if ($normalized === '') { - if ($this->allowedExtensions !== []) { - throw new DownloadException('File extension is required for download.'); - } - + $error = ExtensionPolicy::validate($normalized, $this->allowedExtensions, $this->blockedExtensions); + if ($error === null) { return; } - if (in_array($normalized, $this->blockedExtensions, true)) { - throw new DownloadException('Blocked file extension for download.'); - } - - if ($this->allowedExtensions !== [] && !in_array($normalized, $this->allowedExtensions, true)) { - throw new DownloadException('File extension is not allowed for download.'); - } + throw new DownloadException(ExtensionPolicy::messageFor( + $error, + 'File extension is required for download.', + 'Blocked file extension for download.', + 'File extension is not allowed for download.', + 'Invalid file extension for download.', + )); } private function writeFully(mixed $stream, string $payload): int diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 83ed54b..90c665e 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -242,16 +242,7 @@ public function processUpload(array $file): string $this->validateFile($file); $tmpName = $file['tmp_name']; $extension = pathinfo($file['name'], PATHINFO_EXTENSION); - $this->validateFileExtension($extension); - - $fileType = $this->getFileMimeType($tmpName); - $this->validateFileType($fileType); - $this->validateContentTypeIntegrity($tmpName, $fileType, $extension); - - if ($this->isImage($fileType)) { - $this->validateImageDimensions($tmpName); - } - $this->scanForMalware($tmpName, $fileType); + $fileType = $this->validateUploadedPayload($tmpName, $extension, false); $fileName = $this->generateFileName($tmpName, $extension); $destination = $this->getUniqueDestination($fileName); diff --git a/src/Utils/ExtensionPolicy.php b/src/Utils/ExtensionPolicy.php new file mode 100644 index 0000000..6acd772 --- /dev/null +++ b/src/Utils/ExtensionPolicy.php @@ -0,0 +1,50 @@ + $requiredMessage, + self::ERROR_BLOCKED => $blockedMessage, + self::ERROR_DISALLOWED => $disallowedMessage, + default => $fallbackMessage, + }; + } + + /** + * @param list $allowedExtensions + * @param list $blockedExtensions + */ + public static function validate(string $extension, array $allowedExtensions, array $blockedExtensions): ?string + { + if ($extension === '') { + return $allowedExtensions !== [] ? self::ERROR_REQUIRED : null; + } + + if (in_array($extension, $blockedExtensions, true)) { + return self::ERROR_BLOCKED; + } + + if ($allowedExtensions !== [] && !in_array($extension, $allowedExtensions, true)) { + return self::ERROR_DISALLOWED; + } + + return null; + } +} diff --git a/src/Utils/FileWatcher.php b/src/Utils/FileWatcher.php index 45e3a95..80912f7 100644 --- a/src/Utils/FileWatcher.php +++ b/src/Utils/FileWatcher.php @@ -154,61 +154,23 @@ public static function watch( return $snapshot; } - private static function intFromMixed(mixed $value): int - { - if (is_int($value)) { - return $value; - } - - return is_numeric($value) ? (int) $value : 0; - } - - private static function resolveFlysystemRelativePath(mixed $item, string $base): ?string - { - if (!is_array($item)) { - return null; - } - - $type = $item['type'] ?? null; - if (!is_string($type) || $type !== 'file') { - return null; - } - - $itemPathRaw = $item['path'] ?? null; - if (!is_string($itemPathRaw)) { - return null; - } - - $itemPath = trim($itemPathRaw, '/'); - if ($itemPath === '') { - return null; - } - - if ($base !== '' && str_starts_with($itemPath, $base . '/')) { - return substr($itemPath, strlen($base) + 1); - } - - return $itemPath === $base ? null : $itemPath; - } - /** * @return SnapshotMap */ private static function snapshotViaFlysystem(string $path, bool $recursive): array { $entries = []; - [, $baseLocation] = FlysystemHelper::resolveDirectory($path); - $base = trim(str_replace('\\', '/', $baseLocation), '/'); + $base = FlysystemPathResolver::resolveDirectoryBase($path); foreach (FlysystemHelper::listContents($path, $recursive) as $item) { - $relative = self::resolveFlysystemRelativePath($item, $base); + $relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file'); if ($relative === null) { continue; } $resolved = PathHelper::join($path, $relative); - $lastModified = self::intFromMixed($item['last_modified'] ?? 0); - $fileSize = self::intFromMixed($item['file_size'] ?? 0); + $lastModified = FlysystemPathResolver::intFromMixed($item['last_modified'] ?? 0); + $fileSize = FlysystemPathResolver::intFromMixed($item['file_size'] ?? 0); $entries[$resolved] = [ 'mtime' => $lastModified, diff --git a/src/Utils/FlysystemHelper.php b/src/Utils/FlysystemHelper.php index fc7946a..15d39c0 100644 --- a/src/Utils/FlysystemHelper.php +++ b/src/Utils/FlysystemHelper.php @@ -521,19 +521,49 @@ public static function writeStream(string $path, mixed $stream, array $config = /** * @return array{FilesystemOperator, string} */ - private static function filesystemForDirectory(string $path): array + private static function filesystemFor(string $path, bool $directory): array { [$mountedFilesystem, $mountedLocation] = self::resolveMountedFilesystem($path); if ($mountedFilesystem !== null) { - return [$mountedFilesystem, rtrim($mountedLocation, '/')]; + $location = $directory ? rtrim($mountedLocation, '/') : ltrim($mountedLocation, '/'); + + return [$mountedFilesystem, $location]; } if (self::$defaultFilesystem !== null && !PathHelper::isAbsolute($path)) { - return [self::$defaultFilesystem, trim(str_replace('\\', '/', $path), '/')]; + $normalizedPath = str_replace('\\', '/', $path); + $location = $directory ? trim($normalizedPath, '/') : ltrim($normalizedPath, '/'); + + return [self::$defaultFilesystem, $location]; } - $path = PathHelper::normalize(rtrim($path, '/\\')); + return $directory + ? self::filesystemForLocalDirectory($path) + : self::filesystemForLocalFile($path); + } + + /** + * @return array{FilesystemOperator, string} + */ + private static function filesystemForDirectory(string $path): array + { + return self::filesystemFor($path, true); + } + /** + * @return array{FilesystemOperator, string} + */ + private static function filesystemForFile(string $path): array + { + return self::filesystemFor($path, false); + } + + /** + * @return array{FilesystemOperator, string} + */ + private static function filesystemForLocalDirectory(string $path): array + { + $path = PathHelper::normalize(rtrim($path, '/\\')); if ($path === '' || $path === DIRECTORY_SEPARATOR) { return [new Filesystem(new LocalFilesystemAdapter(DIRECTORY_SEPARATOR)), '']; } @@ -550,17 +580,8 @@ private static function filesystemForDirectory(string $path): array /** * @return array{FilesystemOperator, string} */ - private static function filesystemForFile(string $path): array + private static function filesystemForLocalFile(string $path): array { - [$mountedFilesystem, $mountedLocation] = self::resolveMountedFilesystem($path); - if ($mountedFilesystem !== null) { - return [$mountedFilesystem, ltrim($mountedLocation, '/')]; - } - - if (self::$defaultFilesystem !== null && !PathHelper::isAbsolute($path)) { - return [self::$defaultFilesystem, ltrim(str_replace('\\', '/', $path), '/')]; - } - $path = PathHelper::normalize($path); $directory = dirname($path); $location = basename($path); @@ -576,23 +597,7 @@ private static function filesystemForFile(string $path): array */ private static function filesystemForPath(string $path): array { - [$mountedFilesystem, $mountedLocation] = self::resolveMountedFilesystem($path); - if ($mountedFilesystem !== null) { - return [$mountedFilesystem, ltrim($mountedLocation, '/')]; - } - - if (self::$defaultFilesystem !== null && !PathHelper::isAbsolute($path)) { - return [self::$defaultFilesystem, ltrim(str_replace('\\', '/', $path), '/')]; - } - - $normalized = PathHelper::normalize($path); - $directory = dirname($normalized); - $location = basename($normalized); - - return [ - new Filesystem(new LocalFilesystemAdapter($directory)), - str_replace('\\', '/', $location), - ]; + return self::filesystemFor($path, false); } private static function normalizeMountName(string $name): string diff --git a/src/Utils/FlysystemPathResolver.php b/src/Utils/FlysystemPathResolver.php new file mode 100644 index 0000000..0b670d1 --- /dev/null +++ b/src/Utils/FlysystemPathResolver.php @@ -0,0 +1,59 @@ + + */ + public static function files(string $directory): \Generator + { + if (!is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo || $item->isDir()) { + continue; + } + + yield $item; + } + } +} diff --git a/src/Utils/PathHelper.php b/src/Utils/PathHelper.php index e3434dd..81da0ca 100644 --- a/src/Utils/PathHelper.php +++ b/src/Utils/PathHelper.php @@ -94,14 +94,7 @@ public static function createTempFile(string $prefix = 'temp_'): string|false */ public static function deleteDirectory(string $directory): bool { - $isLocalDirectory = !self::hasScheme($directory) && is_dir($directory); - if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directory)) { - return false; - } - - FlysystemHelper::deleteDirectory($directory); - - return !FlysystemHelper::directoryExists($directory); + return self::deletePath($directory, true); } /** @@ -112,14 +105,7 @@ public static function deleteDirectory(string $directory): bool */ public static function deleteFile(string $file): bool { - $isLocalFile = !self::hasScheme($file) && is_file($file); - if (!$isLocalFile && !FlysystemHelper::fileExists($file)) { - return false; - } - - FlysystemHelper::delete($file); - - return !FlysystemHelper::fileExists($file); + return self::deletePath($file, false); } /** @@ -367,6 +353,25 @@ private static function buildNormalizedPath(string $prefix, array $stack): strin return $normalized; } + private static function deletePath(string $path, bool $directory): bool + { + $isLocalPath = !self::hasScheme($path) && ($directory ? is_dir($path) : is_file($path)); + $exists = $directory + ? FlysystemHelper::directoryExists(...) + : FlysystemHelper::fileExists(...); + $delete = $directory + ? FlysystemHelper::deleteDirectory(...) + : FlysystemHelper::delete(...); + + if (!$isLocalPath && !$exists($path)) { + return false; + } + + $delete($path); + + return !$exists($path); + } + /** * @return array{string, string} */ diff --git a/src/Utils/StreamTransferHelper.php b/src/Utils/StreamTransferHelper.php new file mode 100644 index 0000000..26e92b0 --- /dev/null +++ b/src/Utils/StreamTransferHelper.php @@ -0,0 +1,49 @@ + Date: Wed, 13 May 2026 11:28:49 +0600 Subject: [PATCH 2/3] updated ci --- tests/ArchTest.php | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 tests/ArchTest.php diff --git a/tests/ArchTest.php b/tests/ArchTest.php deleted file mode 100644 index 609b6b6..0000000 --- a/tests/ArchTest.php +++ /dev/null @@ -1,9 +0,0 @@ -each->not()->toBeUsed(); -}); - -test('No echo statements', function () { - expect(['echo', 'print'])->each->not()->toBeUsed(); -}); From 2f62956fb4dd682bf13cbf245db91f3a6fff6f88 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 13 May 2026 13:11:10 +0600 Subject: [PATCH 3/3] purge unnecessary --- rector.php | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 rector.php diff --git a/rector.php b/rector.php deleted file mode 100644 index e30ddf8..0000000 --- a/rector.php +++ /dev/null @@ -1,14 +0,0 @@ -withPaths([__DIR__ . '/src']) - ->withPreparedSets(deadCode: true) - ->withPhpVersion( - constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), - ) - ->withPhpSets();