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/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(); 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 @@ +each->not()->toBeUsed(); -}); - -test('No echo statements', function () { - expect(['echo', 'print'])->each->not()->toBeUsed(); -});