From 7d8d256976f3303f8776f97fa4409a653ae7355c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sat, 8 Aug 2026 14:17:47 +0600 Subject: [PATCH 1/4] updated doc+fixing code issues --- .github/ISSUE_TEMPLATE/bug_report.yml | 84 +++- .github/ISSUE_TEMPLATE/ci_failure.yml | 85 +++- .github/ISSUE_TEMPLATE/docs_improvement.yml | 52 +- .github/ISSUE_TEMPLATE/feature_request.yml | 46 +- .github/ISSUE_TEMPLATE/question.yml | 42 +- .github/ISSUE_TEMPLATE/regression_report.yml | 51 -- .github/PULL_REQUEST_TEMPLATE.md | 126 ++++- .github/PULL_REQUEST_TEMPLATE/bug_fix.md | 94 ++++ .../PULL_REQUEST_TEMPLATE/documentation.md | 51 ++ .github/PULL_REQUEST_TEMPLATE/maintenance.md | 91 ++++ .github/PULL_REQUEST_TEMPLATE/performance.md | 99 ++++ .github/PULL_REQUEST_TEMPLATE/refactor.md | 108 ++++ .../security_reliability.md | 92 ++++ .github/workflows/security-standards.yml | 46 ++ .gitignore | 3 + CODE_OF_CONDUCT.md | 74 ++- CONTRIBUTING.md | 183 ++++++- README.md | 460 +++--------------- SECURITY.md | 65 +-- benchmarks/WorkflowContractsBench.php | 116 +++++ composer.json | 11 +- docs/capabilities.rst | 3 +- docs/directory-manager.rst | 5 +- docs/file-facade.rst | 4 +- docs/file-manager.rst | 16 +- docs/helper-functions.rst | 42 -- docs/index.rst | 3 +- docs/installation.rst | 1 - docs/native-execution.rst | 10 +- docs/observability.rst | 10 +- docs/release-3.0.rst | 25 + docs/storage-adapters.rst | 25 +- docs/storage-contracts.rst | 133 +++++ src/Core/SyncComparison.php | 16 + .../DirectoryOperationsEntryConcern.php | 38 +- .../DirectoryOperationsSyncConcern.php | 95 ++-- .../DirectoryOperationsZipConcern.php | 73 ++- src/DirectoryManager/DirectoryOperations.php | 98 ++-- src/Exceptions/CompressionException.php | 2 +- .../DirectoryOperationException.php | 2 +- src/Exceptions/DownloadException.php | 2 +- src/Exceptions/FileAccessException.php | 2 +- src/Exceptions/FileNotFoundException.php | 2 +- src/Exceptions/FileSizeExceededException.php | 2 +- src/Exceptions/InvalidPathException.php | 7 + src/Exceptions/MissingExtensionException.php | 7 + src/Exceptions/NativeExecutionException.php | 7 + src/Exceptions/PathwiseException.php | 7 + src/Exceptions/PolicyViolationException.php | 2 +- src/Exceptions/StorageCapabilityException.php | 7 + src/Exceptions/TransactionStateException.php | 7 + .../UnsafeArchiveEntryException.php | 7 + .../UnsupportedStorageOperationException.php | 7 + src/Exceptions/UploadException.php | 2 +- .../FileCompressionArchiveConcern.php | 73 ++- .../Concerns/SafeFileWriterWriteConcern.php | 75 ++- src/FileManager/FileCompression.php | 78 ++- src/FileManager/FileOperations.php | 246 ++++++---- src/FileManager/FileTransactionJournal.php | 98 ++++ src/FileManager/SafeFileReader.php | 311 +++++------- src/FileManager/SafeFileWriter.php | 157 +++--- src/Indexing/ChecksumIndexer.php | 14 +- src/Native/NativeOperationsAdapter.php | 75 +-- src/Observability/AuditSink.php | 11 + src/Observability/AuditTrail.php | 35 +- src/Observability/CallbackAuditSink.php | 22 + src/Observability/LocalJsonlAuditSink.php | 39 ++ src/Observability/PartitionedAuditSink.php | 21 + src/PathwiseFacade.php | 58 +-- src/Queue/FileJobQueue.php | 10 +- src/Results/ChunkUploadState.php | 15 + src/Results/DeduplicationResult.php | 14 + src/Results/DownloadPreparation.php | 21 + src/Results/DownloadStreamResult.php | 10 + src/Results/NativeExecutionResult.php | 16 + src/Results/QueueProcessResult.php | 10 + src/Results/RangeDownloadMetadata.php | 15 + src/Results/RetentionResult.php | 14 + src/Results/SnapshotDiff.php | 20 + src/Results/SyncReport.php | 21 + src/Results/WatchResult.php | 11 + src/Retention/RetentionManager.php | 12 +- src/Security/ZipEntryValidator.php | 115 +++++ src/StreamHandler/DownloadProcessor.php | 75 +-- src/StreamHandler/UploadProcessor.php | 24 +- src/Utils/FileWatcher.php | 21 +- src/Utils/FlysystemHelper.php | 11 + src/Utils/MetadataHelper.php | 7 +- src/Utils/PermissionsHelper.php | 24 +- src/functions.php | 232 --------- tests/Feature/ArchiveSecurityTest.php | 108 ++++ tests/Feature/AuditTrailTest.php | 43 ++ tests/Feature/ChecksumIndexerTest.php | 4 +- .../DirectoryOperationsFlysystemTest.php | 54 +- tests/Feature/DirectoryOperationsTest.php | 22 +- tests/Feature/DownloadProcessorTest.php | 56 +-- tests/Feature/FileCompressionTest.php | 5 +- tests/Feature/FileFacadeTest.php | 12 +- tests/Feature/FileJobQueueTest.php | 11 +- tests/Feature/FileOperationsAdvancedTest.php | 80 +++ tests/Feature/FileOperationsTest.php | 13 + tests/Feature/FileWatcherTest.php | 11 +- tests/Feature/FunctionsFlysystemTest.php | 65 --- tests/Feature/FunctionsTest.php | 68 --- tests/Feature/GlobalHelpersRemovedTest.php | 12 + tests/Feature/NativeExecutionTest.php | 60 +++ tests/Feature/OptionalAdapterContractTest.php | 78 +++ tests/Feature/RetentionManagerTest.php | 12 +- tests/Feature/SafeFileReaderTest.php | 30 +- tests/Feature/SafeFileWriterTest.php | 48 +- tests/Feature/UploadProcessorTest.php | 2 +- 111 files changed, 3473 insertions(+), 1980 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/regression_report.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE/bug_fix.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/documentation.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/maintenance.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/performance.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/refactor.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/security_reliability.md create mode 100644 benchmarks/WorkflowContractsBench.php delete mode 100644 docs/helper-functions.rst create mode 100644 docs/release-3.0.rst create mode 100644 docs/storage-contracts.rst create mode 100644 src/Core/SyncComparison.php create mode 100644 src/Exceptions/InvalidPathException.php create mode 100644 src/Exceptions/MissingExtensionException.php create mode 100644 src/Exceptions/NativeExecutionException.php create mode 100644 src/Exceptions/PathwiseException.php create mode 100644 src/Exceptions/StorageCapabilityException.php create mode 100644 src/Exceptions/TransactionStateException.php create mode 100644 src/Exceptions/UnsafeArchiveEntryException.php create mode 100644 src/Exceptions/UnsupportedStorageOperationException.php create mode 100644 src/FileManager/FileTransactionJournal.php create mode 100644 src/Observability/AuditSink.php create mode 100644 src/Observability/CallbackAuditSink.php create mode 100644 src/Observability/LocalJsonlAuditSink.php create mode 100644 src/Observability/PartitionedAuditSink.php create mode 100644 src/Results/ChunkUploadState.php create mode 100644 src/Results/DeduplicationResult.php create mode 100644 src/Results/DownloadPreparation.php create mode 100644 src/Results/DownloadStreamResult.php create mode 100644 src/Results/NativeExecutionResult.php create mode 100644 src/Results/QueueProcessResult.php create mode 100644 src/Results/RangeDownloadMetadata.php create mode 100644 src/Results/RetentionResult.php create mode 100644 src/Results/SnapshotDiff.php create mode 100644 src/Results/SyncReport.php create mode 100644 src/Results/WatchResult.php create mode 100644 src/Security/ZipEntryValidator.php delete mode 100644 src/functions.php create mode 100644 tests/Feature/ArchiveSecurityTest.php delete mode 100644 tests/Feature/FunctionsFlysystemTest.php delete mode 100644 tests/Feature/FunctionsTest.php create mode 100644 tests/Feature/GlobalHelpersRemovedTest.php create mode 100644 tests/Feature/NativeExecutionTest.php create mode 100644 tests/Feature/OptionalAdapterContractTest.php diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8a5f881..5907267 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,70 +1,120 @@ name: Bug report -description: Report a reproducible problem -title: "[Bug]: " +description: Report reproducible incorrect or regressed behavior labels: - bug body: - type: markdown attributes: value: | - Thanks for reporting a bug. Please include enough detail to reproduce it. + Thanks for reporting a problem. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + + - type: dropdown + id: problem_type + attributes: + label: Problem type + description: Select the option that best describes the problem. + options: + - Bug + - Regression + - Not sure + validations: + required: true + - type: textarea id: summary attributes: label: Summary - description: What is wrong? - placeholder: Clear and short description of the bug. + description: Describe the incorrect behavior and its impact. + placeholder: A clear and concise description of the problem. + validations: + required: true + + - type: input + id: package_version + attributes: + label: Affected package version + placeholder: e.g. 2.4.1 or dev-main@abc1234 validations: required: true + + - type: input + id: last_known_working + attributes: + label: Last known working version or commit + description: Complete this when reporting a regression, if known. + placeholder: e.g. 2.4.0 or abc1234 + + - type: input + id: first_known_broken + attributes: + label: First known broken version or commit + description: Complete this when known. + placeholder: e.g. 2.4.1 or def5678 + - type: textarea id: reproduce attributes: - label: Steps to reproduce - description: Share exact commands, config, and steps. + label: Minimal reproduction + description: Provide the smallest code sample, command, configuration or repository that reproduces the problem. placeholder: | - 1. Run `composer ic:tests` - 2. ... + 1. Install or configure ... + 2. Run ... 3. Observe ... validations: required: true + - type: textarea id: expected attributes: label: Expected behavior - placeholder: What did you expect to happen? + placeholder: Describe what should happen. validations: required: true + - type: textarea id: actual attributes: label: Actual behavior - placeholder: What happened instead? Include full error output if possible. + placeholder: Describe what happens instead. validations: required: true + + - type: textarea + id: error_output + attributes: + label: Relevant output or errors + description: Include only the relevant, sanitized output. + render: shell + - type: input id: php_version attributes: label: PHP version - placeholder: "e.g. 8.3.8" + placeholder: e.g. 8.4.13 validations: required: true + - type: input id: composer_version attributes: label: Composer version - placeholder: "e.g. 2.9.2" + placeholder: e.g. 2.9.2 validations: required: true + - type: textarea id: environment attributes: - label: Environment details - description: OS, CI provider, shell, and anything else relevant. - placeholder: Ubuntu 24.04, GitHub Actions, bash... + label: Environment + description: Include the operating system, relevant extensions, dependency mode, runtime and CI provider when applicable. + placeholder: Ubuntu 24.04, locked dependencies, ext-json enabled, GitHub Actions... validations: required: true + - type: textarea id: additional attributes: label: Additional context - description: Links, screenshots, logs, or related issues. + description: Add related issues, screenshots, logs, workarounds or other useful context. diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml index 3dcbac9..9c3883f 100644 --- a/.github/ISSUE_TEMPLATE/ci_failure.yml +++ b/.github/ISSUE_TEMPLATE/ci_failure.yml @@ -1,48 +1,101 @@ name: CI failure -description: Report a reproducible CI or workflow failure -title: "[CI]: " +description: Report a reproducible PHPForge or workflow failure labels: - ci body: - type: markdown attributes: value: | - Use this form when CI fails unexpectedly and can be reproduced. + Use this form when a CI workflow or PHPForge check fails unexpectedly. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + - type: input id: workflow attributes: - label: Workflow/job name - placeholder: security-standards / phpforge + label: Workflow and job + placeholder: e.g. CI / PHP 8.4 validations: required: true + - type: input id: run_url attributes: label: Failing run URL + description: Provide a link when the run is accessible. placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/... - validations: - required: true + - type: textarea - id: command + id: failing_step attributes: - label: Failing command - description: Exact command or step that failed. + label: Failing step or command + description: Include the exact workflow step or command that failed. placeholder: composer ic:ci + render: shell validations: required: true + - type: textarea id: logs attributes: - label: Error output - description: Paste the relevant error section. + label: Relevant error output + description: Paste the smallest useful, sanitized error section. render: shell validations: required: true - - type: textarea - id: local_check + + - type: dropdown + id: local_result attributes: label: Local reproduction - description: Can you reproduce locally? If yes, include steps. - placeholder: Yes/No + details + description: Does the same failure occur when running the relevant command locally? + options: + - Yes + - No + - Not attempted + validations: + required: true + + - type: textarea + id: local_details + attributes: + label: Local reproduction details + description: Include the command, result and any differences from CI. + placeholder: composer ic:ci fails locally with the same error... + + - type: input + id: php_version + attributes: + label: PHP version + placeholder: e.g. 8.4.13 + validations: + required: true + + - type: input + id: composer_version + attributes: + label: Composer version + placeholder: e.g. 2.9.2 validations: required: true + + - type: textarea + id: environment + attributes: + label: Runner and dependency environment + description: Include the runner OS, dependency mode, relevant extensions, matrix values and PHPForge version when known. + placeholder: ubuntu-latest, prefer-lowest, PHPForge 1.x, ext-json enabled... + validations: + required: true + + - type: textarea + id: recent_changes + attributes: + label: Relevant recent changes + description: Mention dependency, configuration, workflow or source changes that may be related. + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add related issues, screenshots, logs or other useful context. diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml index 80b9607..2ea49e9 100644 --- a/.github/ISSUE_TEMPLATE/docs_improvement.yml +++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml @@ -1,34 +1,58 @@ -name: Docs improvement -description: Report missing, unclear, or incorrect documentation -title: "[Docs]: " +name: Documentation improvement +description: Report missing, outdated, unclear or incorrect documentation labels: - documentation body: - - type: textarea + - type: dropdown + id: problem_type + attributes: + label: Documentation problem + options: + - Incorrect + - Outdated + - Missing + - Unclear + - Example needed + - Other + validations: + required: true + + - type: input id: location attributes: label: Documentation location - description: File path or URL. - placeholder: README.md section "Quick Start" + description: Provide the file path, section, symbol or URL. + placeholder: README.md — Quick Start validations: required: true + - type: textarea - id: issue + id: problem attributes: - label: What is unclear or incorrect? - placeholder: This section says... + label: Problem + description: Explain what is missing, unclear, outdated or incorrect. + placeholder: The current documentation says or omits... validations: required: true + - type: textarea - id: suggestion + id: expected attributes: - label: Suggested improvement - description: Propose revised wording, structure, or examples. - placeholder: It would be clearer if... + label: Expected documentation + description: Describe what readers should be able to understand or accomplish. + placeholder: Readers should be able to... validations: required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Optionally propose wording, structure, examples or references. + placeholder: It may be clearer to... + - type: textarea id: additional attributes: label: Additional context - description: Related links, screenshots, or prior discussions. + description: Add related links, screenshots, discussions or examples. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cc29614..bbee6d0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,44 +1,54 @@ name: Feature request -description: Suggest an improvement or new capability -title: "[Feature]: " +description: Suggest a new capability or improvement labels: - enhancement body: - type: markdown attributes: value: | - Thanks for the idea. Please describe the use case first, then the proposed solution. + Describe the problem or use case before proposing an implementation. + + For substantial public API, architectural or compatibility changes, discussion may be requested before implementation. + - type: textarea id: problem attributes: label: Problem or use case - description: What limitation are you hitting? - placeholder: I need to... + description: Explain the limitation, repeated difficulty or capability you need. + placeholder: I need to... because... validations: required: true + - type: textarea - id: proposal + id: proposed_behavior attributes: - label: Proposed solution - description: What should happen? - placeholder: Add a command/config/workflow option that... + label: Proposed behavior + description: Describe the expected user-facing behavior or outcome. + placeholder: The library should... validations: required: true + + - type: textarea + id: example + attributes: + label: Example usage + description: Optionally show the proposed API, configuration, command or workflow. + render: php + - type: textarea id: alternatives attributes: - label: Alternatives considered - description: Any workaround or alternative approach you evaluated. + label: Alternatives or workarounds + description: Describe existing approaches you considered or currently use. + - type: textarea - id: impact + id: compatibility attributes: - label: Expected impact - description: Who benefits and what changes for users/CI? - placeholder: This would improve... - validations: - required: true + label: Compatibility considerations + description: Mention possible public API, behavior, PHP-version, extension, platform or dependency implications. + - type: textarea id: additional attributes: label: Additional context - description: Related issues, links, examples, or prior art. + description: Add related issues, prior art, links, benchmarks or other supporting information. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 2ca776f..62e91a7 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,40 +1,56 @@ name: Question -description: Ask a usage or integration question -title: "[Question]: " +description: Ask about usage, behavior, integration or configuration labels: - question body: - type: markdown attributes: value: | - Use this form for usage questions. For confirmed defects, use the bug report form. + Use this form for usage and integration questions. Use the bug form for reproducible incorrect behavior. + + Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting. + - type: textarea - id: context + id: goal attributes: label: What are you trying to do? - description: Describe your goal and expected outcome. + description: Describe the goal and expected outcome. placeholder: I want to... validations: required: true + - type: textarea id: attempted attributes: label: What have you tried? - description: Include commands, config snippets, or links you already checked. + description: Include relevant code, commands, configuration, documentation or approaches already checked. placeholder: I tried... validations: required: true + + - type: textarea + id: relevant_code + attributes: + label: Relevant code or configuration + description: Include a minimal sanitized example when applicable. + render: php + - type: textarea id: output attributes: - label: Current output or behavior - description: Include relevant command output, logs, or errors. + label: Relevant output or errors + description: Include sanitized output only when it helps explain the question. render: shell + - type: textarea id: environment attributes: - label: Environment details - description: PHP version, Composer version, OS, CI provider (if relevant). - placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... - validations: - required: true + label: Environment + description: Include package, PHP, Composer, OS, extensions or CI details only when relevant. + placeholder: Package 2.4.1, PHP 8.4, Composer 2.9, Ubuntu 24.04... + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add related links, screenshots or prior discussions. diff --git a/.github/ISSUE_TEMPLATE/regression_report.yml b/.github/ISSUE_TEMPLATE/regression_report.yml deleted file mode 100644 index 36392bc..0000000 --- a/.github/ISSUE_TEMPLATE/regression_report.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Regression report -description: Report behavior that previously worked but now fails -title: "[Regression]: " -labels: - - regression - - bug -body: - - type: textarea - id: summary - attributes: - label: Regression summary - placeholder: This worked before, but now... - validations: - required: true - - type: input - id: last_known_good - attributes: - label: Last known working version/commit - placeholder: v1.2.3 or abc1234 - validations: - required: true - - type: input - id: first_bad - attributes: - label: First broken version/commit - placeholder: v1.2.4 or def5678 - - type: textarea - id: reproduce - attributes: - label: Steps to reproduce - placeholder: | - 1. ... - 2. ... - 3. ... - validations: - required: true - - type: textarea - id: expected_actual - attributes: - label: Expected vs actual behavior - placeholder: Expected ..., but got ... - validations: - required: true - - type: textarea - id: environment - attributes: - label: Environment details - description: PHP version, Composer version, OS, CI provider (if relevant). - placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... - validations: - required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 59ae734..dcab1f5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,33 +1,121 @@ ## Summary -Describe what changed and why. +Describe what changed, why it was needed and the expected behavior. -## Related Issues + -Link issues with `Closes #...` or `Relates #...`. +## Change -## Type of Change +### Type -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor -- [ ] Documentation update -- [ ] CI or tooling update -- [ ] Other (describe in summary) +* [ ] Bug fix +* [ ] New feature +* [ ] Refactor +* [ ] Performance +* [ ] Security or reliability +* [ ] Documentation or examples +* [ ] Dependency, CI or tooling +* [ ] Other + +### Behavior and Compatibility + +* [ ] No observable behavior changed +* [ ] Existing behavior was corrected +* [ ] New behavior was introduced +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + ## Validation -List the commands you ran and their result. +* [ ] `composer ic:ci` + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Implementation Notes + + + +## Review Focus -```bash -composer ic:tests -``` + ## Checklist -- [ ] I followed `CONTRIBUTING.md`. -- [ ] I added or updated tests for behavior changes. -- [ ] I updated docs/config/examples when needed. -- [ ] I confirmed no security-sensitive data is exposed. +* [ ] The change is focused and excludes unrelated modifications. +* [ ] Tests cover new, corrected and regression-prone behavior. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation, examples and type information were updated where required. +* [ ] Performance claims are supported by reproducible benchmarks. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md new file mode 100644 index 0000000..66759b4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md @@ -0,0 +1,94 @@ +## Problem + +Describe the incorrect behavior, its impact and how it can be reproduced. + + + +## Root Cause + + + +## Fix + +Describe how the change corrects the problem and the expected behavior after the fix. + +## Behavior and Compatibility + +* [ ] Existing documented behavior was restored +* [ ] Existing undocumented behavior was corrected +* [ ] Public API remains compatible +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] The original failure no longer reproduces +* [ ] A regression test was added or updated +* [ ] Relevant boundary and failure paths were tested + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The fix is focused and excludes unrelated changes. +* [ ] The fix addresses the root cause rather than only masking symptoms. +* [ ] Regression-prone behavior is covered by tests. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and examples were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/documentation.md b/.github/PULL_REQUEST_TEMPLATE/documentation.md new file mode 100644 index 0000000..f983b85 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/documentation.md @@ -0,0 +1,51 @@ +## Documentation Change + +Describe what is being added, corrected, clarified or removed and why. + + + +## Affected Content + +* [ ] README or getting-started guidance +* [ ] API or reference documentation +* [ ] Configuration documentation +* [ ] Examples or tutorials +* [ ] Contribution or community documentation +* [ ] Changelog or release documentation +* [ ] Other + +## Verification + +* [ ] Links and references were checked +* [ ] Code examples were executed or otherwise verified +* [ ] Commands and configuration examples match current behavior +* [ ] Terminology is consistent with the project +* [ ] `composer ic:ci` +* [ ] No executable behavior changed + + + +## Review Focus + + + +## Checklist + +* [ ] The change is focused and excludes unrelated code changes. +* [ ] Documentation reflects the current public behavior. +* [ ] Examples are minimal, accurate and safe to copy. +* [ ] Sensitive or private information is not included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/maintenance.md b/.github/PULL_REQUEST_TEMPLATE/maintenance.md new file mode 100644 index 0000000..a4cab0a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/maintenance.md @@ -0,0 +1,91 @@ +## Maintenance Change + +Describe what changed, why it was needed and the expected effect on development, CI or releases. + + + +## Category + +* [ ] Dependency update +* [ ] CI or workflow change +* [ ] Build or release tooling +* [ ] PHPForge configuration +* [ ] Development tooling +* [ ] Repository maintenance +* [ ] Other + +## Impact and Compatibility + +* [ ] Runtime behavior is unaffected +* [ ] Development workflow changed +* [ ] CI or release behavior changed +* [ ] Supported PHP, extension, platform or dependency requirements changed +* [ ] Generated files or configuration changed +* [ ] Backward compatibility may be affected + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Relevant workflow or job was exercised +* [ ] Supported matrix or dependency mode was considered +* [ ] Generated or published files were verified +* [ ] Failure and rollback behavior was considered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The change is focused and excludes unrelated source refactoring. +* [ ] Dependency or workflow changes are minimal and justified. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and generated files were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/performance.md b/.github/PULL_REQUEST_TEMPLATE/performance.md new file mode 100644 index 0000000..ce2d10d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/performance.md @@ -0,0 +1,99 @@ +## Bottleneck + +Describe the measured performance problem, affected execution path and practical impact. + + + +## Optimization + +Describe the change, why it improves the measured path and any trade-offs introduced. + +## Correctness and Compatibility + +* [ ] Observable behavior remains unchanged +* [ ] Public API remains compatible +* [ ] Error and exception behavior remains compatible +* [ ] Behavior or public API changed intentionally +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Benchmark Evidence + +* [ ] Relevant benchmarks were added or updated +* [ ] Results were compared against a relevant baseline +* [ ] Multiple stable runs were considered +* [ ] Runtime impact was measured +* [ ] Memory or allocation impact was measured where relevant +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Expected behavior remains covered +* [ ] Boundary and failure paths remain covered +* [ ] Performance-sensitive behavior is covered + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] The optimization targets a measured bottleneck. +* [ ] Results are reproducible in comparable environments. +* [ ] Correctness was not traded for an unverified micro-optimization. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Benchmark and documentation changes are included where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/refactor.md b/.github/PULL_REQUEST_TEMPLATE/refactor.md new file mode 100644 index 0000000..588be4f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/refactor.md @@ -0,0 +1,108 @@ +## Intent and Scope + +Describe what was restructured, why it was necessary and what remains intentionally unchanged. + + + +## Behavioral Guarantee + +* [ ] No observable behavior changed +* [ ] Public API remains unchanged +* [ ] Existing behavior was intentionally corrected +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected + + + +## Design Notes + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Existing behavior remains covered +* [ ] Relevant regression and edge cases are covered +* [ ] Public API compatibility was verified + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Performance + + + +* [ ] Results were compared against a relevant baseline +* [ ] `composer ic:benchmark` +* [ ] `composer ic:bench:quick` +* [ ] `composer ic:bench:chart` + + + +## Review Focus + + + +## Checklist + +* [ ] The refactor is focused and excludes unrelated behavior changes. +* [ ] Complexity was reduced without unnecessary abstraction or file growth. +* [ ] Existing contracts and failure behavior remain covered. +* [ ] Public API and backward-compatibility implications were considered. +* [ ] Documentation and type information were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/PULL_REQUEST_TEMPLATE/security_reliability.md b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md new file mode 100644 index 0000000..ef2ca28 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md @@ -0,0 +1,92 @@ + + +## Concern + +Describe the security weakness, reliability failure mode or defensive gap being addressed. + + + +## Mitigation + +Describe how the change reduces the risk and what assumptions or limitations remain. + +## Impact and Compatibility + +* [ ] Security hardening with no observable behavior change +* [ ] Reliability improvement with no public API change +* [ ] Failure or exception behavior changed +* [ ] Public API or documented behavior changed +* [ ] Backward compatibility may be affected +* [ ] PHP, extension, platform or dependency requirements changed + + + +## Validation + +* [ ] `composer ic:ci` +* [ ] Security-sensitive or failure behavior is covered +* [ ] Abuse, malformed-input or failure paths are covered +* [ ] Regression coverage was added or updated +* [ ] `composer ic:test:security` + + + +
+Focused validation + + + +* [ ] `composer ic:test:syntax` +* [ ] `composer ic:test:code` +* [ ] `composer ic:test:lint` +* [ ] `composer ic:test:sniff` +* [ ] `composer ic:test:duplicates` +* [ ] `composer ic:test:probe` +* [ ] `composer ic:test:comments` +* [ ] `composer ic:test:architecture` +* [ ] `composer ic:test:static` +* [ ] `composer ic:test:security` +* [ ] `composer ic:test:refactor` + +
+ + + +## Review Focus + + + +## Checklist + +* [ ] Confidential vulnerability details are not exposed publicly. +* [ ] The change is focused and avoids unrelated refactoring. +* [ ] Security or reliability claims are supported by tests. +* [ ] Failure paths and backward-compatibility implications were considered. +* [ ] Documentation and upgrade guidance were updated where required. +* [ ] No credentials, secrets, personal data or sensitive debug output are included. +* [ ] I followed `SECURITY.md`, `CONTRIBUTING.md` and the engineering principles. diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index f2b00ee..973b495 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -25,6 +25,12 @@ jobs: run_analysis: true run_svg_report: true fail_on_skipped_tests: false + run_clean_install: true + benchmark_composer_script: "" + benchmark_result_file: "" + benchmark_baseline_file: "" + benchmark_max_regression_percent: 2 + benchmark_stable_environment: false enable_redis_service: false enable_valkey_service: false enable_memcached_service: false @@ -37,3 +43,43 @@ jobs: service_db_user: "phpforge" service_db_password: "phpforge" artifact_retention_days: 61 + + windows: + name: "Windows / PHP ${{ matrix.php }}" + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + php: ["8.4", "8.5"] + steps: + - uses: actions/checkout@v4 + - name: "Set up PHP" + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php }}" + extensions: "fileinfo, simplexml, xmlreader, zip" + coverage: none + tools: composer:v2 + - name: "Install dependencies" + run: composer install --no-interaction --prefer-dist + - name: "Run platform tests" + run: composer ic:test:code + + adapter-contracts: + name: "Optional adapter contracts" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: "Set up PHP" + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: "fileinfo, simplexml, xmlreader, zip" + coverage: none + tools: composer:v2 + - name: "Install project and contract adapters" + run: | + composer install --no-interaction --prefer-dist + composer require --dev --no-interaction --prefer-dist league/flysystem-memory:^3 league/flysystem-read-only:^3 league/flysystem-path-prefixing:^3 + - name: "Run adapter contracts" + run: composer ic:test:code diff --git a/.gitignore b/.gitignore index 6991d72..a90c2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ test.php var vendor d2utmp* +plan.md +pathwise.md +feature.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9c2638f..eff64bb 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,49 +2,71 @@ ## Our Commitment -We are committed to making participation in this project a harassment-free -experience for everyone, regardless of age, body size, disability, ethnicity, -gender identity and expression, level of experience, nationality, personal -appearance, race, religion or sexual identity and orientation. +We are committed to providing a welcoming, inclusive and harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity or expression, level of experience, nationality, personal appearance, race, religion, sexual identity or sexual orientation. ## Expected Behavior -Examples of behavior that contributes to a positive environment: +Examples of behavior that contributes to a positive environment include: -- Be respectful and constructive. -- Assume good intent and ask clarifying questions. -- Give and receive feedback professionally. -- Focus on what is best for the community and project. +* Being respectful, constructive and considerate +* Giving and receiving feedback professionally +* Disagreeing with ideas without attacking individuals +* Respecting differing viewpoints and experiences +* Accepting responsibility, apologizing when appropriate and learning from mistakes +* Focusing on what is best for the project and its community ## Unacceptable Behavior Examples of unacceptable behavior include: -- Harassment, discrimination or personal attacks. -- Trolling, insulting or derogatory comments. -- Publishing private information without consent. -- Any conduct that is inappropriate in a professional setting. - -## Enforcement Responsibilities - -Project maintainers are responsible for clarifying and enforcing this code of -conduct. They may remove, edit or reject comments, commits, code, issues, and -other contributions that violate this policy. +* Harassment, discrimination, intimidation or personal attacks +* Trolling, insults, threats or derogatory comments +* Sexualized language, imagery or unwanted attention +* Repeated disruption of discussions or project activities +* Publishing private or identifying information without permission +* Retaliating against anyone who reports an incident or participates in an investigation +* Any conduct that would reasonably be considered inappropriate in a professional setting ## Scope This code of conduct applies in all project spaces, including: -- Issue trackers -- Pull requests -- Discussions and chat related to the project -- Any public or private communication where someone represents the project +* Issues, pull requests and code reviews +* Discussions and project-related chat +* Documentation, commits and other contributions +* Public or private communication where an individual represents the project or its community ## Reporting -To report unacceptable behavior, contact project maintainers privately. +Report unacceptable behavior privately to the project maintainers. + +Do not include sensitive incident details in a public issue, discussion or pull request. When no private contact method is available, open a public issue requesting a private communication channel without describing the incident. + +Reports should include, when available: + +* A description of what occurred +* Relevant links, screenshots or other supporting information +* The approximate date and location of the incident +* Any immediate safety or confidentiality concerns + +All reports will be reviewed as confidentially and impartially as reasonably possible. Information will be shared only when necessary to investigate and respond to the report. + +## Enforcement Responsibilities + +Project maintainers are responsible for interpreting and enforcing this code of conduct. + +Maintainers may remove, edit or reject comments, commits, code, issues, pull requests and other contributions that violate this policy. Maintainers who have a conflict of interest regarding a report should not participate in its review. ## Enforcement -Maintainers may take any action they deem appropriate, including warnings, -temporary bans or permanent bans from community participation. +Actions will be based on the severity, frequency and context of the behavior and may include: + +* A private warning +* Removal or editing of inappropriate content +* Temporary restrictions on project participation +* Permanent removal from project spaces +* Reporting serious threats or unlawful conduct to the relevant platform or authorities + +Enforcement decisions should be proportionate, documented privately and applied consistently. + +Retaliation against reporters, witnesses or participants in an investigation is prohibited. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9950065..ad81ec1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,18 @@ # Contributing -Thanks for contributing. +Thanks for contributing to this project. ## Before You Start -- Review the project code of conduct. -- For security issues, use private reporting and avoid opening a public issue. -- Check existing issues and pull requests first to avoid duplicates. +* Review `CODE_OF_CONDUCT.md`. +* Report security vulnerabilities privately according to `SECURITY.md`. +* Search existing issues and pull requests to avoid duplicate work. +* An issue is not required for small fixes or improvements discovered during development. +* Discuss substantial API, architectural or compatibility changes before implementation. ## Local Setup -Requirements: - -- See `README.md` for current PHP and Composer requirements. +Review `README.md` and `composer.json` for supported PHP versions, extensions, dependencies and project-specific requirements. Install dependencies: @@ -20,49 +20,180 @@ Install dependencies: composer install ``` +Inspect the detected PHPForge configuration: + +```bash +composer ic:doctor +``` + +Do not modify files inside `vendor/`. + +## Engineering Standards + +Before changing or reviewing code, read and follow: + +```text +vendor/infocyph/phpforge/resources/engineering-principles.md +``` + +These principles apply equally to human contributors and automated coding agents. They define the expected approach to implementation decisions, scope control, architecture, performance, security, compatibility, testing and maintainability. + +Project-specific requirements may extend these principles but should not silently weaken them. + ## Development Workflow -Typical contributor workflow: +1. Create a branch from the repository’s default branch. +2. Make one focused logical change. +3. Add or update tests for changed behavior. +4. Run relevant focused checks during development. +5. Apply automated processing where appropriate. +6. Review every automatically modified file. +7. Run the complete CI suite before opening a pull request. +8. Add reproducible benchmark evidence for performance-related changes. +9. Complete the pull request template accurately. + +## Automated Processing + +Run all configured processors: + +```bash +composer ic:process +``` + +Run an individual processor when only a targeted change is needed: + +```bash +composer ic:process:refactor +composer ic:process:lint +composer ic:process:sniff +``` + +Automated processing may modify source files and `composer.json`. Review all resulting changes before committing. -1. Create a branch from `main`. -2. Make focused changes. -3. Run quality checks locally. -4. Open a pull request with context and verification notes. +## Validation -Recommended checks: +Run the complete project validation suite before opening a pull request: ```bash -composer ic:tests +composer ic:ci ``` -Useful targeted commands: +When `composer ic:ci` passes, running the same checks individually is unnecessary. + +Use focused commands while developing or when the complete suite cannot run: + +
+Focused validation commands ```bash composer ic:test:syntax composer ic:test:code composer ic:test:lint composer ic:test:sniff +composer ic:test:duplicates +composer ic:test:probe +composer ic:test:comments +composer ic:test:architecture composer ic:test:static composer ic:test:security -composer ic:test:architecture +composer ic:test:refactor +``` + +
+ +When `composer ic:ci` cannot complete, document: + +* Why it could not complete +* Which focused checks passed +* Relevant PHP, dependency, extension or platform limitations +* Any remaining validation risk + +Do not suppress, baseline, exclude or weaken a check merely to make validation pass. Any configuration or baseline change must be intentional and explained in the pull request. + +## Tests + +Test observable behavior and public contracts rather than internal implementation details. + +Include relevant coverage for: + +* New or corrected behavior +* Regression scenarios +* Boundary and edge cases +* Failure and exception paths +* Public API compatibility +* PHP-version, dependency, extension or platform-sensitive behavior + +A bug fix should normally include a regression test that fails without the fix. + +## Performance Changes + +Run benchmarks when performance is affected or claimed: + +```bash +composer ic:benchmark ``` -Auto-fix and processing helpers: +Additional benchmark commands: ```bash -composer ic:process +composer ic:bench:quick +composer ic:bench:chart +``` + +Performance claims must include reproducible before-and-after results from comparable environments. Avoid conclusions based on a single unstable run. + +Add or update benchmark coverage when existing benchmarks do not represent the changed execution path. + +## Configuration + +Inspect the active PHPForge configuration sources: + +```bash +composer ic:list-config +composer ic:list-config --json +``` + +Publish a configuration file only when the project requires rules that differ from PHPForge defaults: + +```bash +composer ic:publish-config ``` +When changing quality configuration: + +* Explain why the current rule is unsuitable +* Keep exclusions narrow +* Avoid weakening checks globally for one change +* Document compatibility or baseline implications + ## Pull Request Guidelines -- Keep pull requests scoped to one logical change. -- Include why the change is needed and what behavior changed. -- Add or update tests when behavior changes. -- Update docs when command behavior, config, or workflow behavior changes. -- Ensure CI is green before requesting review. +* Keep each pull request limited to one logical change. +* Explain what changed, why it was needed and the expected behavior. +* Identify public API, backward-compatibility, PHP, extension, platform or dependency impacts. +* Select only validation and benchmark checkboxes that reflect work actually performed. +* Add or update tests for behavior changes. +* Update documentation, examples, types and configuration where required. +* Exclude unrelated formatting, refactoring, dependency or generated-file changes. +* Ensure CI passes before requesting review. +* Address review feedback through focused follow-up changes. + +Draft pull requests are welcome for incomplete work or early design feedback, but validation claims and checklist items must remain accurate. ## Reporting Bugs and Requesting Features -- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests. -- Include reproducible steps, expected behavior, and actual behavior. -- Share environment details (PHP version, OS, Composer version). +Use the relevant issue template for bugs, regressions, CI failures, documentation problems, questions and feature requests. + +Include when relevant: + +* A clear description of the problem or proposed behavior +* A minimal reproduction +* Expected and actual behavior +* Package and dependency versions +* PHP and Composer versions +* Operating system and relevant extensions +* Logs or error output with sensitive information removed + +Small, self-contained fixes may be submitted directly as pull requests. Larger behavioral, architectural or compatibility changes should be discussed first. + +Security vulnerabilities must not be reported through public issues, discussions or pull requests. diff --git a/README.md b/README.md index f980949..6a71a85 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,47 @@ -# Pathwise: File Management Made Simple +# Pathwise -[![Security & Standards](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml) -[![Documentation](https://img.shields.io/badge/Documentation-Pathwise-blue?logo=readthedocs&logoColor=white)](https://docs.infocyph.com/projects/pathwise/) -![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/pathwise?color=green&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2Fpathwise) +![Security & Standards](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml) +![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/Pathwise?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2FPathwise) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) -![Packagist Version](https://img.shields.io/packagist/v/infocyph/pathwise) -![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/pathwise/php) -![GitHub Code Size](https://img.shields.io/github/languages/code-size/infocyph/pathwise) - -Pathwise is a robust PHP library designed as streamlined file and directory management. It combines storage operations with higher-level workflows like safe reading/writing, metadata extraction, compression, upload pipelines, policy enforcement and observability. - -## **Table of Contents** -1. [Introduction](#pathwise-file-management-made-simple) -2. [Prerequisites](#prerequisites) -3. [Installation](#installation) -4. [Features Overview](#features-overview) -5. [Quality Gates](#quality-gates) -6. [FileManager](#filemanager) - - [SafeFileReader](#safefilereader) - - [SafeFileWriter](#safefilewriter) - - [FileOperations](#fileoperations) - - [FileCompression](#filecompression) -7. [DirectoryManager](#directorymanager) - - [DirectoryOperations](#directoryoperations) -8. [Utils](#utils) - - [PathHelper](#pathhelper) - - [PermissionsHelper](#permissionshelper) - - [MetadataHelper](#metadatahelper) -9. [Storage Adapter Setup](#storage-adapter-setup) -10. [Handy Functions](#handy-functions) - - [File and Directory Utilities](#file-and-directory-utilities) -11. [Support](#support) -12. [License](#license) - -## **Prerequisites** -- Language: PHP 8.4/+ - -## **Installation** -Pathwise is available via Composer: +![Packagist Version](https://img.shields.io/packagist/v/infocyph/Pathwise) +![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/Pathwise/php) +![GitHub Code Size](https://img.shields.io/github/languages/code-size/infocyph/Pathwise) +[![Documentation](https://img.shields.io/badge/Documentation-Pathwise-blue?logo=readthedocs&logoColor=white)](https://docs.infocyph.com/projects/Pathwise/en/latest/) + +High-level PHP filesystem workflows powered by Flysystem, including safe I/O, uploads, downloads, archives, directory synchronization, retention, policy enforcement, auditing and storage adapters. + +Pathwise 3.0 requires PHP 8.4 or newer. It is a direct breaking release: reader and writer methods are explicit, complex operations return readonly result objects, and unsupported mounted-storage operations fail with focused exceptions. + +## Installation ```bash composer require infocyph/pathwise ``` -Requirements: -- PHP 8.4 or higher -- `ext-fileinfo` -- Optional Extensions: - - `ext-zip`: Required for compression features. - - `ext-pcntl`: Required for long-running watch loops. - - `ext-posix`: Required for permission handling. - - `ext-xmlreader` and `ext-simplexml`: Required for XML parsing. - ---- +`ext-fileinfo` is required. ZIP and XML features check for `ext-zip`, `ext-xmlreader`, and `ext-simplexml` at runtime and report a clear `MissingExtensionException` when unavailable. -## **Features Overview** +## Quick start -- Filesystem operations across core modules. -- Mount support with scheme paths (`name://path`) and default filesystem support for relative paths. -- Config-driven storage bootstrap via `StorageFactory` for local/custom/adapter-based filesystems. -- Unified entry facade via `Infocyph\Pathwise\PathwiseFacade` for file/dir/processors/storage/tooling. -- Advanced file APIs: checksum verification, visibility controls, URL passthrough (`publicUrl`, `temporaryUrl`). -- Directory automation: sync with diff report, recursive copy/move/delete, mounted-path ZIP/unzip bridging. -- Upload/download pipelines: chunked/resumable uploads, validation profiles (image/video/document), extension allow/deny controls, strict MIME/signature checks, upload-id safety validation, malware-scan hook, secure download metadata + range handling. -- Compression workflows: include/exclude glob patterns, ignore files, progress callbacks, hooks, optional native acceleration. -- Operational tooling: `AuditTrail`, `FileJobQueue`, `FileWatcher`, `RetentionManager` and policy engine support. +```php +use Infocyph\Pathwise\FileManager\FileOperations; +use Infocyph\Pathwise\FileManager\SafeFileReader; +use Infocyph\Pathwise\FileManager\SafeFileWriter; -## **Storage Adapter Setup** +$file = new FileOperations('/tmp/example.txt'); +$file->create('hello')->append("\nworld"); -Pathwise supports any Flysystem adapter. You can mount storages through `StorageFactory` and use them with all modules (`UploadProcessor`, `DownloadProcessor`, `FileOperations`, etc.). +foreach ((new SafeFileReader('/tmp/example.txt'))->lines() as $line) { + echo $line; +} -`StorageFactory` supports: -- `['driver' => 'local', 'root' => '/path']` -- `['driver' => 'aws-s3', 'adapter' => $adapter]` -- `['driver' => 'aws-s3', 'constructor' => [...]]` -- `['filesystem' => $filesystemOperator]` -- custom drivers via `StorageFactory::registerDriver()` +$writer = new SafeFileWriter('/tmp/events.json'); +$writer->writeJson(['status' => 'ready']); +$writer->close(); +``` -Official adapter driver keys covered: -- `local`, `ftp`, `inmemory` (`in-memory`) -- `read-only`, `path-prefixing` -- `aws-s3` (`s3`), `async-aws-s3` -- `azure-blob-storage`, `google-cloud-storage`, `mongodb-gridfs` -- `sftp-v2`, `sftp-v3`, `webdav`, `ziparchive` +The reader exposes `lines()`, `characters()`, `chunks()`, `csv()`, `jsonLines()`, `jsonArray()`, `fixedWidth()`, `xmlElements()`, `serializedValues()`, and `matchingLines()`. The writer exposes the corresponding `write*` methods. There is no runtime `__call()` dispatch and no global helper-function autoloading. -### **Local Driver** +## Storage model ```php use Infocyph\Pathwise\Storage\StorageFactory; @@ -98,346 +55,71 @@ StorageFactory::mount('assets', [ FlysystemHelper::write('assets://reports/a.txt', 'hello'); ``` -### **Any Adapter (Example: S3)** - -```php -use Aws\S3\S3Client; -use Infocyph\Pathwise\Storage\StorageFactory; -use League\Flysystem\AwsS3V3\AwsS3V3Adapter; - -$client = new S3Client([ - 'version' => 'latest', - 'region' => 'us-east-1', - 'credentials' => [ - 'key' => getenv('AWS_ACCESS_KEY_ID'), - 'secret' => getenv('AWS_SECRET_ACCESS_KEY'), - ], -]); - -$adapter = new AwsS3V3Adapter($client, 'my-bucket', 'app-prefix'); - -StorageFactory::mount('s3', ['adapter' => $adapter]); -// Use s3://... paths in processors and managers. -``` - -### **Custom Driver Registration** - -```php -use Infocyph\Pathwise\Storage\StorageFactory; -use League\Flysystem\Filesystem; -use League\Flysystem\Local\LocalFilesystemAdapter; - -StorageFactory::registerDriver('tenant-local', function (array $config): Filesystem { - $tenant = (string) ($config['tenant'] ?? 'default'); - return new Filesystem(new LocalFilesystemAdapter('/srv/tenants/' . $tenant)); -}); - -StorageFactory::mount('tenant', [ - 'driver' => 'tenant-local', - 'tenant' => 'acme', -]); -``` - -## **Unified Pathwise Facade** - -Use a single entry point when you want fewer direct class imports. - -```php -use Infocyph\Pathwise\PathwiseFacade; - -$entry = PathwiseFacade::at('/tmp/example.txt'); -$entry->file()->create('hello')->append("\nworld"); - -$upload = PathwiseFacade::upload(); -$download = PathwiseFacade::download(); - -PathwiseFacade::mountStorage('assets', ['driver' => 'local', 'root' => '/srv/assets']); -``` - -## **FileManager** - -The `FileManager` module provides classes for handling files, including reading, writing, compressing and general file operations. - -### **SafeFileReader** - -A memory-safe file reader supporting various reading modes (line-by-line, binary chunks, JSON, CSV, XML, etc.) and iterator interfaces. - -#### **Key Features** -- Supports multiple reading modes. -- Provides locking to prevent concurrent access issues. -- Implements `Countable`, `Iterator` and `SeekableIterator`. - -#### **Usage Example** - -```php -use Infocyph\Pathwise\FileManager\SafeFileReader; - -$reader = new SafeFileReader('/path/to/file.txt'); - -// Line-by-line iteration -foreach ($reader->line() as $line) { - echo $line; -} - -// JSON decoding with error handling -foreach ($reader->json() as $data) { - print_r($data); -} -``` - -### **SafeFileWriter** - -A memory-safe file writer with support for various writing modes, including CSV, JSON, binary and more. - -#### **Key Features** -- Supports multiple writing modes. -- Ensures file locking and robust error handling. -- Tracks write operations and supports flush and truncate methods. - -#### **Usage Example** - -```php -use Infocyph\Pathwise\FileManager\SafeFileWriter; - -$writer = new SafeFileWriter('/path/to/file.txt'); - -// Writing lines -$writer->line('Hello, World!'); - -// Writing JSON data -$writer->json(['key' => 'value']); -``` - -### **FileOperations** - -General-purpose file handling class for creating, deleting, copying, renaming and manipulating files. - -#### **Key Features** -- File creation and deletion. -- Append and update content. -- Rename, copy and metadata retrieval. - -#### **Usage Example** - -```php -use Infocyph\Pathwise\FileManager\FileOperations; - -$fileOps = new FileOperations('/path/to/file.txt'); - -// Check existence -if ($fileOps->exists()) { - echo 'File exists'; -} - -// Read content -echo $fileOps->read(); -``` - -### **FileCompression** +Storage-neutral reads, writes, copies, streams, uploads, downloads, ZIP staging, retention, and synchronization accept local, default-Flysystem, and mounted scheme paths where the adapter supplies the required capability. POSIX modes/ownership, native processes, shell searching, direct locks/handles, and transactions are local-filesystem-only and throw `UnsupportedStorageOperationException` for mounted paths. -Provides utilities for compressing and decompressing files using the ZIP format with optional password protection and encryption. +Local `append()` uses native append mode. Mounted stores must opt into `appendEmulated()`, which visibly represents a complete object replacement. Local transactions use a structured, disk-backed rollback journal and reject nesting. -#### **Key Features** -- Compress files/directories. -- Decompress ZIP archives. -- Support for AES encryption and password-protected ZIPs. +See the [storage capability contract](docs/storage-contracts.rst) for the compatibility matrix, atomicity, locking, sync, native execution, archive security, and performance characteristics. -#### **Usage Example** - -```php -use Infocyph\Pathwise\FileManager\FileCompression; - -$compression = new FileCompression('/path/to/archive.zip'); - -// Compress a directory -$compression->compress('/path/to/directory'); - -// Decompress -$compression->decompress('/path/to/extract/'); -``` - -## **DirectoryManager** - -The `DirectoryManager` module offers tools for handling directory creation, deletion and traversal. - - -### **DirectoryOperations** - -Provides comprehensive tools for managing directories, including creation, deletion, copying and listing contents. - -#### **Key Features** -- Create, delete and copy directories. -- Retrieve directory size, depth and contents. -- Supports recursive operations and filtering. - -#### **Usage Example** +## Synchronization and result types ```php +use Infocyph\Pathwise\Core\SyncComparison; use Infocyph\Pathwise\DirectoryManager\DirectoryOperations; -$dirOps = new DirectoryOperations('/path/to/directory'); - -// Create a directory -$dirOps->create(); - -// List contents -$contents = $dirOps->listContents(detailed: true); -print_r($contents); +$report = (new DirectoryOperations('/srv/source'))->syncTo( + '/srv/target', + deleteOrphans: true, + comparison: SyncComparison::SIZE_AND_MODIFIED_TIME, +); ``` -## **Utils** +`syncTo()` returns a readonly `SyncReport`. Download preparation/ranges, chunk uploads, queue processing, native execution, retention, deduplication, and file watching likewise return dedicated readonly result objects rather than significant associative arrays. -Utility classes for managing paths, permissions and metadata. +## Secure archives +Every extraction path validates every ZIP member before writing. Absolute paths, Windows drive paths, null bytes, traversal segments, symbolic-link entries, extraction-root escapes, and existing destination-symlink breakouts are rejected with `UnsafeArchiveEntryException`. -### **PathHelper** +## Auditing -Provides utilities for working with file paths, including joining, normalizing and converting between relative and absolute paths. +`AuditTrail` accepts a local JSONL path or an `AuditSink`. `LocalJsonlAuditSink` uses locked append. `PartitionedAuditSink` writes one object per event and is suitable for mounted object stores. `CallbackAuditSink` integrates application loggers. Remote audit append is never silently emulated by reading and rewriting a log object. -#### **Key Features** -- Path joining and normalization. -- Convert between relative and absolute paths. -- Retrieve and manipulate file extensions. +## Native execution -#### **Usage Example** - -```php -use Infocyph\Pathwise\Utils\PathHelper; - -$absolutePath = PathHelper::toAbsolutePath('relative/path'); -echo $absolutePath; - -$joinedPath = PathHelper::join('/var', 'www', 'html'); -echo $joinedPath; -``` - -### **PermissionsHelper** - -Handles file and directory permissions, ownership and access control. - -#### **Key Features** -- Retrieve and set permissions. -- Check read, write and execute access. -- Retrieve and set ownership details. - -#### **Usage Example** - -```php -use Infocyph\Pathwise\Utils\PermissionsHelper; - -// Get human-readable permissions -echo PermissionsHelper::getHumanReadablePermissions('/path/to/file'); - -// Check if writable -if (PermissionsHelper::canWrite('/path/to/file')) { - echo 'File is writable'; -} -``` - -### **MetadataHelper** - -Extracts metadata for files and directories, such as size, timestamps, MIME type and more. - -#### **Key Features** -- Retrieve file size and type. -- Compute checksums and timestamps. -- Get ownership and visibility details. - -#### **Usage Example** - -```php -use Infocyph\Pathwise\Utils\MetadataHelper; - -// Get file size -$size = MetadataHelper::getFileSize('/path/to/file'); -echo "File size: $size bytes"; - -// Retrieve metadata -$metadata = MetadataHelper::getAllMetadata('/path/to/file'); -print_r($metadata); -``` - -## **Handy Functions** - -### **File and Directory Utilities** - -Pathwise provides standalone utility functions to simplify common file and directory operations. - -#### **1. Get Human-Readable File Size** -Formats a file size in bytes into a human-readable format (e.g., `1.23 KB`, `4.56 GB`). - -**Usage Example:** -```php -$size = getHumanReadableFileSize(123456789); -echo $size; // Output: "117.74 MB" -``` - -#### **2. Check if a Directory is Empty** -Checks whether the given directory contains any files or subdirectories. - -**Usage Example:** -```php -$isEmpty = isDirectoryEmpty('/path/to/directory'); -echo $isEmpty ? 'Empty' : 'Not Empty'; -``` - -#### **3. Delete a Directory Recursively** -Deletes a directory and all its contents (files and subdirectories). - -**Usage Example:** -```php -$success = deleteDirectory('/path/to/directory'); -echo $success ? 'Deleted successfully' : 'Failed to delete'; -``` - -#### **4. Get Directory Size** -Calculates the total size of a directory, including all its files and subdirectories. - -**Usage Example:** -```php -$size = getDirectorySize('/path/to/directory'); -echo "Directory size: " . getHumanReadableFileSize($size); -``` - -#### **5. Create a Directory** -Creates a directory (including parent directories) with specified permissions. - -**Usage Example:** -```php -$success = createDirectory('/path/to/new/directory'); -echo $success ? 'Directory created' : 'Failed to create directory'; -``` - -#### **6. List Files in a Directory** -Lists all files in a directory, excluding subdirectories. - -**Usage Example:** -```php -$files = listFiles('/path/to/directory'); -print_r($files); -``` - -#### **7. Copy a Directory Recursively** -Copies a directory and all its contents to a new location. - -**Usage Example:** -```php -$success = copyDirectory('/source/directory', '/destination/directory'); -echo $success ? 'Copied successfully' : 'Failed to copy'; -``` +`ExecutionStrategy::PHP` always uses PHP, `AUTO` may use an available native executable and fall back, and `NATIVE` either completes natively or throws `NativeExecutionException`. Native execution accepts local paths only; command arguments are escaped and execution results retain command, output, and exit code. ## Security -Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects. +Do not disclose suspected vulnerabilities in a public issue, discussion or pull request. Review the +[security policy](SECURITY.md), then use [GitHub private vulnerability reporting](https://github.com/infocyph/Pathwise/security/advisories/new) +to contact the maintainers confidentially. + +Pathwise is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security gate covering +tests, static and taint analysis, dependency auditing, architecture checks, and release readiness. Automated controls reduce +risk but do not replace responsible disclosure or manual review. ---
Made with ❤️ for the PHP community
MIT Licensed
- Documentation • + DocumentationSecurityCode of Conduct • - Contributing • - Report | Request | Suggest + Contributing
+ Issues: + Bug • + Feature • + Documentation • + Question • + CI failure
+ Pull requests: + General • + Bug fix • + Feature • + Refactor • + Performance • + Security & reliability • + Documentation • + Maintenance
diff --git a/SECURITY.md b/SECURITY.md index 37a355e..ca14478 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,48 +2,51 @@ ## Supported Versions -The project currently supports security updates for the latest release. +Security updates are provided for the latest stable release. + +Reports affecting older versions are welcome, but fixes may be released only for the latest version. Users should upgrade before confirming whether an issue remains present. ## Reporting a Vulnerability -Please report vulnerabilities privately. +Please report suspected vulnerabilities privately. + +1. Go to `Security` → `Advisories` → `Report a vulnerability`. +2. If private vulnerability reporting is unavailable, open a public issue requesting a private security contact. +3. Do not include vulnerability details in that issue or disclose them through public issues, discussions, pull requests or other public channels. + +Include when available: -1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`). -2. If private reporting is unavailable, contact maintainers through a private channel. -3. Do not open a public issue for security vulnerabilities. +* Affected package version and component +* PHP version and runtime environment +* Relevant extensions or dependencies +* Reproduction steps or a minimal proof of concept +* Exploitation requirements and potential impact +* Known workarounds or suggested remediation -Please include: +## Response and Disclosure -- Affected package version(s) -- PHP version and runtime environment -- Reproduction steps or proof of concept -- Impact assessment (confidentiality/integrity/availability) -- Any known workaround +The maintainers will make a best-effort attempt to: -## Response Process +* Acknowledge the report within five business days +* Validate the report and assess its severity +* Coordinate remediation and responsible disclosure +* Publish a fix, mitigation or security advisory when appropriate -- Initial acknowledgment: best effort, typically within a few days -- Triage: best effort, based on maintainer availability -- Fix and release timeline depends on severity and exploitability +Resolution timelines depend on severity, exploitability, complexity and maintainer availability. These targets are not a service-level agreement. -If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise. +Please coordinate public disclosure with the maintainers so affected users have a reasonable opportunity to update or apply mitigations. -## Protected by PHPForge +Confirmed reporters will receive credit unless they request anonymity. -This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects. +## PHPForge Security Controls -PHPForge helps keep the project reliable by running checks for: +This project uses [PHPForge](https://github.com/infocyph/PHPForge) to automate security and quality checks, including: -- Code style and standards -- Tests and syntax validation -- Static analysis and type safety -- Security and taint analysis -- Dependency vulnerability audit -- Architecture boundary validation -- Duplicate-code detection -- API snapshot and comment-policy checks -- Refactor safety checks -- Benchmark and release-readiness checks -- Git hooks and CI workflow protection +* Test and syntax validation +* Static and taint analysis +* Dependency vulnerability auditing +* Architecture validation +* Release-readiness checks +* Git hooks and CI enforcement -These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release. +These controls help reduce security risk and prevent regressions, but they do not guarantee the absence of vulnerabilities or replace manual review and responsible reporting. diff --git a/benchmarks/WorkflowContractsBench.php b/benchmarks/WorkflowContractsBench.php new file mode 100644 index 0000000..d476a3c --- /dev/null +++ b/benchmarks/WorkflowContractsBench.php @@ -0,0 +1,116 @@ +baseDir = PathHelper::join(sys_get_temp_dir(), 'pathwise_contract_bench_' . uniqid('', true)); + $this->sourceDir = PathHelper::join($this->baseDir, 'source'); + $this->syncTarget = PathHelper::join($this->baseDir, 'sync-target'); + $auditRoot = PathHelper::join($this->baseDir, 'audit-store'); + FlysystemHelper::createDirectory($this->sourceDir); + FlysystemHelper::createDirectory($this->syncTarget); + FlysystemHelper::createDirectory($auditRoot); + + for ($index = 0; $index < 1_000; $index++) { + FlysystemHelper::write( + PathHelper::join($this->sourceDir, sprintf('entry-%04d.txt', $index)), + str_repeat((string) ($index % 10), 256), + ); + } + + $this->largeFile = PathHelper::join($this->baseDir, 'large.bin'); + FlysystemHelper::write($this->largeFile, str_repeat('0123456789abcdef', 512 * 1024)); + FlysystemHelper::mount('bench-audit', new Filesystem(new LocalFilesystemAdapter($auditRoot))); + } + + public function tearDown(): void + { + FlysystemHelper::reset(); + if (FlysystemHelper::directoryExists($this->baseDir)) { + FlysystemHelper::deleteDirectory($this->baseDir); + } + } + + public function benchDirectorySynchronization(): void + { + (new DirectoryOperations($this->sourceDir))->syncTo($this->syncTarget, true); + } + + public function benchLargeDirectoryTraversal(): void + { + foreach (FlysystemHelper::listContentsListing($this->sourceDir, true) as $entry) { + $entry->path(); + } + } + + public function benchLargeNativeAppend(): void + { + (new FileOperations($this->largeFile))->append(str_repeat('a', 4_096), false); + } + + public function benchNativeFileCopy(): void + { + $destination = PathHelper::join($this->baseDir, 'native-copy-' . uniqid('', true) . '.bin'); + (new FileOperations($this->largeFile))->setExecutionStrategy(ExecutionStrategy::NATIVE)->copy($destination); + } + + public function benchPartitionedMountedAuditWrites(): void + { + $audit = new AuditTrail(new PartitionedAuditSink('bench-audit://events')); + for ($index = 0; $index < 25; $index++) { + $audit->log('benchmark', ['index' => $index, 'path' => $this->largeFile]); + } + } + + public function benchPhpCompressionAndDecompression(): void + { + $archive = PathHelper::join($this->baseDir, 'archive-' . uniqid('', true) . '.zip'); + $destination = PathHelper::join($this->baseDir, 'extract-' . uniqid('', true)); + $compression = (new FileCompression($archive, true))->setExecutionStrategy(ExecutionStrategy::PHP); + $compression->compress($this->sourceDir)->save(); + (new FileCompression($archive))->setExecutionStrategy(ExecutionStrategy::PHP)->decompress($destination); + } + + public function benchPhpFileCopy(): void + { + $destination = PathHelper::join($this->baseDir, 'php-copy-' . uniqid('', true) . '.bin'); + (new FileOperations($this->largeFile))->setExecutionStrategy(ExecutionStrategy::PHP)->copy($destination); + } + + public function benchStreamingRead(): void + { + foreach ((new SafeFileReader($this->largeFile))->chunks(65_536) as $chunk) { + strlen($chunk); + } + } +} diff --git a/composer.json b/composer.json index 9f2cccd..cb021e3 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "infocyph/pathwise", - "description": "File management made simple.", + "description": "High-level PHP filesystem workflows powered by Flysystem, including safe I/O, uploads, downloads, archives, directory synchronization, retention, policy enforcement, auditing and storage adapters.", "license": "MIT", "type": "library", "authors": [ @@ -15,10 +15,9 @@ "league/flysystem": "^3.35.2" }, "require-dev": { - "infocyph/phpforge": "dev-main" + "infocyph/phpforge": "dev-main@dev" }, "suggest": { - "ext-pcntl": "required if you want to use long-running watch loops.", "ext-posix": "required if you want to use permissions.", "ext-simplexml": "required if you want to use XML parsing.", "ext-xmlreader": "required if you want to use XML parsing.", @@ -42,10 +41,7 @@ "autoload": { "psr-4": { "Infocyph\\Pathwise\\": "src/" - }, - "files": [ - "src/functions.php" - ] + } }, "config": { "allow-plugins": { @@ -53,7 +49,6 @@ "infocyph/phpforge": true, "pestphp/pest-plugin": true }, - "classmap-authoritative": true, "optimize-autoloader": true, "sort-packages": true } diff --git a/docs/capabilities.rst b/docs/capabilities.rst index 4b5929b..8e59375 100644 --- a/docs/capabilities.rst +++ b/docs/capabilities.rst @@ -56,7 +56,7 @@ What you get: * Idempotent create, recursive copy/move/delete. * Listing, flattening, find/filter, size/depth metrics. -* Directory sync with diff report. +* Lazy directory sync with readonly ``SyncReport`` and explicit comparison strategy. * Zip/unzip helpers for local and mounted paths. Uploads (``Infocyph\Pathwise\StreamHandler``) @@ -130,7 +130,6 @@ Required: Optional: * ``ext-zip`` (archive features) -* ``ext-pcntl`` (watch loop process patterns) * ``ext-posix`` (richer Unix ownership data) * ``ext-xmlreader``, ``ext-simplexml`` (XML helpers) diff --git a/docs/directory-manager.rst b/docs/directory-manager.rst index 9d93b24..d64aab2 100644 --- a/docs/directory-manager.rst +++ b/docs/directory-manager.rst @@ -14,12 +14,13 @@ Where it fits: * Recursive ``copy()``, ``move()``, ``delete()``. * Listing and discovery: ``listContents()``, ``flatten()``, ``find()``. * Metrics and structure helpers: ``size()``, ``getDepth()``. -* Sync API with diff report: ``syncTo()``. +* Lazy sync API returning ``SyncReport`` with configurable ``SyncComparison``. * Archive helpers: ``zip()`` and ``unzip()``. Flysystem-aware behavior: -* Works with local paths and mounted scheme paths. +* Storage-neutral workflows work with local and mounted paths when the adapter + provides their capabilities; POSIX permissions and direct iterators are local-only. * Uses storage-safe resolution for relative paths. * Can bridge non-local ZIP source/destination through temporary streaming. diff --git a/docs/file-facade.rst b/docs/file-facade.rst index 651d676..312935f 100644 --- a/docs/file-facade.rst +++ b/docs/file-facade.rst @@ -28,12 +28,12 @@ Path-Bound Access $entry->file()->create('hello')->append("\nworld"); $reader = $entry->reader(); - foreach ($reader->line() as $line) { + foreach ($reader->lines() as $line) { // ... } $writer = $entry->writer(true); - $writer->line('tail'); + $writer->writeLine('tail'); $writer->close(); $metadata = $entry->metadata(); diff --git a/docs/file-manager.rst b/docs/file-manager.rst index d1495b5..f438425 100644 --- a/docs/file-manager.rst +++ b/docs/file-manager.rst @@ -17,7 +17,8 @@ Brief capabilities: * Checksum helpers: ``verifyChecksum()``, ``writeAndVerify()``, ``copyWithVerification()``. * Stream APIs: ``readStream()``, ``writeStream()``. * Visibility/URL passthrough where adapter supports it. -* Optional transaction rollback and policy enforcement. +* Local-only structured transaction rollback and policy enforcement. +* Native local append plus explicit ``appendEmulated()`` object replacement for mounts. Example: @@ -37,7 +38,7 @@ Brief capabilities: * Memory-safe reads: line, char, binary chunk, CSV, JSON, XML. * Lock-aware reads for safer concurrent usage. -* Iterator-friendly API. +* Explicit generator APIs; the reader itself implements ``Countable``, not ``Iterator``. Example: @@ -46,7 +47,7 @@ Example: use Infocyph\Pathwise\FileManager\SafeFileReader; $reader = new SafeFileReader('/tmp/report.txt'); - foreach ($reader->line() as $line) { + foreach ($reader->lines() as $line) { // process line } @@ -67,9 +68,10 @@ Example: use Infocyph\Pathwise\FileManager\SafeFileWriter; $writer = new SafeFileWriter('/tmp/events.log'); - $writer->enableAtomicWrite() - ->line('started') - ->line('finished'); + $writer->enableAtomicWrite(); + $writer->writeLine('started'); + $writer->writeLine('finished'); + $writer->close(); ``FileCompression`` ------------------- @@ -81,6 +83,8 @@ Brief capabilities: * Include/exclude glob patterns. * Ignore-file support (for example ``.pathwiseignore``). * Hook and progress callback support. +* Shared pre-extraction validation for traversal, absolute/drive paths, null bytes, + symbolic links, and destination breakout. Example: diff --git a/docs/helper-functions.rst b/docs/helper-functions.rst deleted file mode 100644 index fc1a2db..0000000 --- a/docs/helper-functions.rst +++ /dev/null @@ -1,42 +0,0 @@ -Helper Functions -================ - -Global helper functions are autoloaded from ``src/functions.php``. - -Available helpers (brief): - -* ``getHumanReadableFileSize(int $bytes): string`` -* ``isDirectoryEmpty(string $directoryPath): bool`` -* ``deleteDirectory(string $directoryPath): bool`` -* ``getDirectorySize(string $directoryPath): int`` -* ``createDirectory(string $directoryPath, int $permissions = 0755): bool`` -* ``listFiles(string $directoryPath): array`` -* ``copyDirectory(string $source, string $destination): bool`` -* ``createFilesystem(array $config): FilesystemOperator`` -* ``mountStorage(string $name, array $config): FilesystemOperator`` -* ``mountStorages(array $mounts): void`` - -Notes: - -* Helpers are Flysystem-aware and can work with mounted scheme paths. -* They keep return types small and script-friendly for utility usage. - -Example -------- - -.. code-block:: php - - createDirectory('/tmp/demo'); - file_put_contents('/tmp/demo/a.txt', 'data'); - - $size = getDirectorySize('/tmp/demo'); - $files = listFiles('/tmp/demo'); - -Storage setup helper example: - -.. code-block:: php - - mountStorage('assets', [ - 'driver' => 'local', - 'root' => '/srv/storage/assets', - ]); diff --git a/docs/index.rst b/docs/index.rst index 67390cb..7611cee 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,6 +12,7 @@ queue/audit tooling, and operational helpers. overview installation capabilities + storage-contracts storage-adapters file-facade quickstart @@ -26,5 +27,5 @@ queue/audit tooling, and operational helpers. indexing retention utilities - helper-functions native-execution + release-3.0 diff --git a/docs/installation.rst b/docs/installation.rst index 0d8b26a..b3e4a3a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -16,7 +16,6 @@ Requirements: Optional extensions: * ``ext-zip`` for ZIP features. -* ``ext-pcntl`` for long-running watch loops. * ``ext-posix`` for richer Unix ownership details. * ``ext-xmlreader`` and ``ext-simplexml`` for XML helpers. diff --git a/docs/native-execution.rst b/docs/native-execution.rst index c7c4ce6..7def184 100644 --- a/docs/native-execution.rst +++ b/docs/native-execution.rst @@ -6,8 +6,8 @@ Namespaces: ``Infocyph\Pathwise\Core`` and ``Infocyph\Pathwise\Native`` Pathwise can use OS-native commands for selected workflows via ``ExecutionStrategy``: * ``PHP``: force pure PHP implementation. -* ``NATIVE``: force native command path. -* ``AUTO``: attempt native first, then fallback. +* ``NATIVE``: require a local path and available executable; throw on failure. +* ``AUTO``: attempt native first when supported, then fall back to PHP. ``NativeOperationsAdapter`` covers: @@ -20,6 +20,12 @@ Platform behavior: * Windows: ``robocopy``, ``cmd copy``, PowerShell archive commands. * Unix-like: ``rsync``, ``cp``, ``zip``/``unzip``. +Native mode is local-filesystem-only. Mounted and default-Flysystem paths are +rejected even when their backing adapter happens to use a local directory. +Failures retain the exit code and output in ``NativeExecutionResult`` or the +resulting ``NativeExecutionException``. Caller paths are passed as escaped +arguments; caller-provided shell fragments are not accepted. + Where to use ------------ diff --git a/docs/observability.rst b/docs/observability.rst index 20707e3..ef427df 100644 --- a/docs/observability.rst +++ b/docs/observability.rst @@ -3,12 +3,14 @@ Observability Namespace: ``Infocyph\Pathwise\Observability`` -``AuditTrail`` writes append-only JSONL records for operations. +``AuditTrail`` delegates records to an ``AuditSink``. Brief capabilities: * Log timestamped operation events with context. -* Store audit output as line-delimited JSON. +* ``LocalJsonlAuditSink`` stores line-delimited JSON with locked native append. +* ``PartitionedAuditSink`` stores one event object per event on any writable mount. +* ``CallbackAuditSink`` forwards records to an application logger or collector. * Integrate with ``FileOperations`` to trace file lifecycle actions. Typical fields: @@ -31,3 +33,7 @@ Example ->setAuditTrail($audit) ->create('hello') ->append("\nworld"); + +Mounted paths cannot be passed as the local JSONL sink because portable remote +append does not exist. Use a partitioned or callback sink; Pathwise never hides +a complete remote audit-object rewrite. diff --git a/docs/release-3.0.rst b/docs/release-3.0.rst new file mode 100644 index 0000000..451a270 --- /dev/null +++ b/docs/release-3.0.rst @@ -0,0 +1,25 @@ +Pathwise 3.0 +============ + +Pathwise 3.0 is a direct breaking release for PHP 8.4+. There are no deprecated +aliases or compatibility wrappers. + +Migration Summary +----------------- + +* Replace reader magic calls with explicit methods such as ``lines()``, + ``csv()``, and ``jsonLines()``. +* Replace writer magic calls with ``writeLine()``, ``writeCsv()``, + ``writeJson()``, and the other explicit ``write*`` methods. +* Consume readonly result objects from sync, downloads, chunk uploads, queues, + native execution, retention, deduplication, and watching. +* Replace global helpers with ``PathwiseFacade`` or the corresponding service. +* Use ``append()`` only for direct local paths. Choose ``appendEmulated()`` + explicitly for adapter-backed object replacement. +* Keep transactions on direct local paths and handle + ``UnsupportedStorageOperationException`` for local-only capabilities. +* Treat ``ExecutionStrategy::NATIVE`` as strict: it never falls back. + +Archive extraction now rejects unsafe entries before any member is written. +Review code that previously expected permissive extraction or boolean command +results. diff --git a/docs/storage-adapters.rst b/docs/storage-adapters.rst index 0a5e47e..b93ea62 100644 --- a/docs/storage-adapters.rst +++ b/docs/storage-adapters.rst @@ -101,8 +101,8 @@ Then pass the adapter directly: 'adapter' => $adapter, ]); - // Works with all Pathwise modules that accept paths: - // s3://uploads/a.pdf + // Storage-neutral workflows can now address s3://uploads/a.pdf. + // Native processes, direct locks, POSIX metadata, and transactions remain local-only. Constructor mode example (official drivers): @@ -154,23 +154,12 @@ If you want environment-driven config, register a custom driver once: // tenant://docs/report.txt -Helper Functions (autoloaded) ------------------------------ +Facade Gateways +--------------- -Global helpers mirror the factory: - -* ``createFilesystem(array $config): FilesystemOperator`` -* ``mountStorage(string $name, array $config): FilesystemOperator`` -* ``mountStorages(array $mounts): void`` - -Example: - -.. code-block:: php - - mountStorage('media', [ - 'driver' => 'local', - 'root' => '/srv/media', - ]); +Use ``PathwiseFacade::createFilesystem()``, ``mountStorage()``, and +``mountStorages()`` when a static gateway is preferable. Pathwise 3.0 does not +autoload global helper functions. Processor Integration Notes --------------------------- diff --git a/docs/storage-contracts.rst b/docs/storage-contracts.rst new file mode 100644 index 0000000..59c8c48 --- /dev/null +++ b/docs/storage-contracts.rst @@ -0,0 +1,133 @@ +Storage Capability Contract +=========================== + +Pathwise distinguishes the path syntax from the capability behind it. A mounted +``local`` adapter is still adapter-backed: Pathwise does not unwrap it and call +native PHP functions against its internal root. + +Compatibility Matrix +-------------------- + +.. list-table:: + :header-rows: 1 + :widths: 34 20 20 26 + + * - Capability + - Direct local path + - Default Flysystem path + - Mounted scheme path + * - Read/write/stream/copy/visibility + - Supported + - Adapter-dependent + - Adapter-dependent + * - Directory traversal and synchronization + - Supported + - Adapter-dependent + - Adapter-dependent + * - Upload/download processing + - Supported + - Adapter-dependent + - Adapter-dependent + * - ZIP creation/extraction + - Supported + - Streamed through local staging + - Streamed through local staging + * - Native append + - Supported + - Rejected + - Rejected + * - Emulated append/object replacement + - Supported + - Explicit ``appendEmulated()`` + - Explicit ``appendEmulated()`` + * - Transactions + - Supported + - Rejected + - Rejected + * - POSIX modes, owner, and group + - Platform-dependent + - Rejected + - Rejected + * - Direct locks/handles and shell search + - Platform-dependent + - Rejected + - Rejected + * - Native process execution + - Tool-dependent + - Rejected + - Rejected + +``Adapter-dependent`` means Flysystem and the selected adapter must implement +the requested metadata, checksum, visibility, URL, or write operation. A +read-only adapter, for example, remains readable but rejects mutation. + +Atomicity and Transactions +-------------------------- + +``SafeFileWriter::enableAtomicWrite()`` stages a local file and replaces the +destination at close time. Local same-filesystem rename is atomic on supported +operating systems; a mounted destination requires a final adapter write and is +not claimed to be atomic. + +``FileOperations`` transactions are local-only. They use structured journal +entries and disk-backed copies, restore file existence/content and permission +bits, restore copy destinations, and reset the object's path after a rename +rollback. Transactions are process-local, reject nesting, and do not provide +database isolation. Commit and rollback outside an active transaction throw +``TransactionStateException``. + +Locking and Append +------------------ + +Direct locks and ``append()`` operate only on local paths. Local append uses +``FILE_APPEND`` and optional ``LOCK_EX`` without reading the existing file. +Flysystem does not define portable append semantics, so mounted callers must +choose ``appendEmulated()`` and accept a complete object read/replacement. Audit +logging follows the same rule: local JSONL is locked append; remote sinks use +separate event objects or application callbacks. + +ZIP Extraction +-------------- + +``FileCompression::decompress()``, ``batchExtractFiles()``, and +``DirectoryOperations::unzip()`` share one validator. Validation completes for +the entire archive before extraction and rejects absolute/drive paths, null +bytes, traversal, root escape, ZIP symbolic links, and existing destination +symlink chains. Remote archives and destinations are localized/streamed only +after applying the same validation. + +Synchronization +--------------- + +``syncTo()`` consumes source listings lazily and returns ``SyncReport``. Progress +events report ``total: null`` when obtaining a total would require buffering or +a second traversal. Comparison strategies are: + +* ``SIZE_AND_MODIFIED_TIME``: default for two direct local paths. +* ``SIZE``: default when either side is adapter-backed. +* ``CHECKSUM``: explicit integrity-first comparison with extra reads/requests. +* ``ALWAYS_COPY``: overwrite every source file. + +Orphan deletion necessarily buffers and reverse-sorts the destination listing +so children are deleted before parents. + +Native Execution +---------------- + +``PHP`` never starts native tools. ``AUTO`` may attempt an available tool and +fall back to PHP. ``NATIVE`` validates tool availability and local paths, then +throws ``NativeExecutionException`` on any native failure without falling back. +Command arguments are escaped, and ``NativeExecutionResult`` retains command, +exit code, and output. Actual tools vary by platform (``cp``, ``rsync``, +``zip``/``unzip`` on Unix-like systems; ``cmd``, ``robocopy``, and PowerShell on +Windows). + +Performance Characteristics +--------------------------- + +Streams are used for cross-filesystem file copy, downloads, writes, checksums, +and file-compression extraction. Directory listings remain lazy except where +ordering is required. Transaction backups consume temporary disk proportional +to the original local files. Remote emulated append consumes bandwidth and +memory proportional to the complete object, so partitioned writes are preferred +for logs and event workloads. diff --git a/src/Core/SyncComparison.php b/src/Core/SyncComparison.php new file mode 100644 index 0000000..4d924dd --- /dev/null +++ b/src/Core/SyncComparison.php @@ -0,0 +1,16 @@ + - */ - private function listStorageEntries(string $path, bool $deep): array + /** @return \Generator */ + private function listStorageEntries(string $path, bool $deep): \Generator { - $entries = []; - foreach (FlysystemHelper::listContents($path, $deep) as $item) { - $entries[] = $item; - } + foreach (FlysystemHelper::listContentsListing($path, $deep) as $item) { + $entry = [ + 'path' => str_replace('\\', '/', $item->path()), + 'type' => $item->type(), + ]; + if ($item instanceof \League\Flysystem\FileAttributes) { + $entry['file_size'] = $item->fileSize(); + $entry['mime_type'] = $item->mimeType(); + } else { + $entry['file_size'] = 0; + } - return $entries; + try { + $entry['last_modified'] = $item->lastModified(); + } catch (\Throwable) { + $entry['last_modified'] = null; + } + + try { + $entry['visibility'] = $item->visibility(); + } catch (\Throwable) { + $entry['visibility'] = null; + } + + yield $entry; + } } /** diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php index 66d9d30..ebd5dac 100644 --- a/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php +++ b/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php @@ -5,18 +5,16 @@ namespace Infocyph\Pathwise\DirectoryManager\Concerns; use Infocyph\Pathwise\Core\ExecutionStrategy; +use Infocyph\Pathwise\Core\SyncComparison; use Infocyph\Pathwise\Exceptions\DirectoryOperationException; +use Infocyph\Pathwise\Exceptions\NativeExecutionException; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\Native\NativeOperationsAdapter; +use Infocyph\Pathwise\Results\SyncReport; use Infocyph\Pathwise\Utils\FlysystemHelper; /** * @phpstan-type StorageEntry array - * @phpstan-type SyncReport array{ - * created: list, - * updated: list, - * deleted: list, - * unchanged: list - * } */ trait DirectoryOperationsSyncConcern { @@ -41,20 +39,31 @@ private function assertZipSourceExists(string $source): void private function attemptNativeCopy(string $destination, ?callable $progress): bool { + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + if (!$this->isLocalPath($this->path) || !$this->isLocalPath($destination)) { + throw new UnsupportedStorageOperationException('Native directory copy requires local source and destination paths.'); + } + if (!NativeOperationsAdapter::canUseNativeDirectoryCopy()) { + throw new NativeExecutionException('Native directory copy executable is unavailable.'); + } + } + if (!$this->canAttemptNativeCopy($destination)) { return false; } $this->emitCopyProgress($progress, 0); $native = NativeOperationsAdapter::copyDirectory($this->path, $destination, false); - if ($native['success']) { + if ($native->success) { $this->emitCopyProgress($progress, 1); return true; } if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new DirectoryOperationException("Native directory copy failed for '{$this->path}' to '{$destination}'."); + throw new NativeExecutionException( + "Native directory copy failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + ); } return false; @@ -68,6 +77,14 @@ private function canAttemptNativeCopy(string $destination): bool && $this->isLocalPath($destination); } + private function checksumsMatch(string $sourcePath, string $targetPath): bool + { + $sourceHash = FlysystemHelper::checksum($sourcePath, 'sha256'); + $targetHash = FlysystemHelper::checksum($targetPath, 'sha256'); + + return is_string($sourceHash) && is_string($targetHash) && hash_equals($sourceHash, $targetHash); + } + private function cleanupTemporaryFile(bool $shouldCleanup, string $path): void { if ($shouldCleanup && is_file($path)) { @@ -75,17 +92,18 @@ private function cleanupTemporaryFile(bool $shouldCleanup, string $path): void } } - private function copyIfSyncRequired(string $sourcePath, string $targetPath): string - { + private function copyIfSyncRequired( + string $sourcePath, + string $targetPath, + SyncComparison $comparison, + ): string { if (!FlysystemHelper::fileExists($targetPath)) { FlysystemHelper::copy($sourcePath, $targetPath); return 'created'; } - $sourceHash = FlysystemHelper::checksum($sourcePath, 'sha256'); - $targetHash = FlysystemHelper::checksum($targetPath, 'sha256'); - if (!is_string($sourceHash) || !is_string($targetHash) || !hash_equals($sourceHash, $targetHash)) { + if (!$this->filesMatchForSync($sourcePath, $targetPath, $comparison)) { FlysystemHelper::copy($sourcePath, $targetPath); return 'updated'; @@ -110,7 +128,7 @@ private function createDirectorySilently(string $path): void private function deleteSyncOrphans(string $destination, array $sourceEntries, array &$report): void { $destinationLocation = $this->storageLocation($destination); - $destinationItems = $this->listStorageEntries($destination, true); + $destinationItems = iterator_to_array($this->listStorageEntries($destination, true), false); usort( $destinationItems, @@ -150,7 +168,7 @@ private function emitCopyProgress(?callable $progress, int $current): void ]); } - private function emitSyncProgress(?callable $progress, string $relative, int $current, int $total): void + private function emitSyncProgress(?callable $progress, string $relative, int $current, ?int $total): void { if (!is_callable($progress)) { return; @@ -160,27 +178,38 @@ private function emitSyncProgress(?callable $progress, string $relative, int $cu 'operation' => 'sync', 'path' => $relative, 'current' => $current, - 'total' => max(1, $total), + 'total' => $total, ]); } + private function filesMatchForSync( + string $sourcePath, + string $targetPath, + SyncComparison $comparison, + ): bool { + return match ($comparison) { + SyncComparison::ALWAYS_COPY => false, + SyncComparison::SIZE => FlysystemHelper::size($sourcePath) === FlysystemHelper::size($targetPath), + SyncComparison::SIZE_AND_MODIFIED_TIME => FlysystemHelper::size($sourcePath) === FlysystemHelper::size($targetPath) + && FlysystemHelper::lastModified($sourcePath) === FlysystemHelper::lastModified($targetPath), + SyncComparison::CHECKSUM => $this->checksumsMatch($sourcePath, $targetPath), + }; + } + /** * @param array> $report - * @return SyncReport */ - private function finalizeSyncReport(array $report): array + private function finalizeSyncReport(array $report): SyncReport { - return [ - 'created' => $report['created'] ?? [], - 'updated' => $report['updated'] ?? [], - 'deleted' => $report['deleted'] ?? [], - 'unchanged' => $report['unchanged'] ?? [], - ]; + return new SyncReport( + created: $report['created'] ?? [], + updated: $report['updated'] ?? [], + deleted: $report['deleted'] ?? [], + unchanged: $report['unchanged'] ?? [], + ); } - /** - * @return SyncReport - */ + /** @return array{created: list, updated: list, deleted: list, unchanged: list} */ private function newSyncReport(): array { return [ @@ -217,8 +246,14 @@ private function runSilently(callable $operation): mixed * @param array $sourceEntries * @param array> $report */ - private function syncOneItem(string $destination, string $relative, array $item, array &$sourceEntries, array &$report): void - { + private function syncOneItem( + string $destination, + string $relative, + array $item, + array &$sourceEntries, + array &$report, + SyncComparison $comparison, + ): void { $type = $this->entryType($item); $sourceEntries[$relative] = $type; @@ -234,7 +269,7 @@ private function syncOneItem(string $destination, string $relative, array $item, $sourcePath = $this->buildPath($this->path, $relative); $targetPath = $this->buildPath($destination, $relative); - $result = $this->copyIfSyncRequired($sourcePath, $targetPath); + $result = $this->copyIfSyncRequired($sourcePath, $targetPath, $comparison); $report[$result][] = $relative; } diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php index 6fa1127..a86ed00 100644 --- a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php +++ b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php @@ -7,7 +7,10 @@ use FilesystemIterator; use Infocyph\Pathwise\Core\ExecutionStrategy; use Infocyph\Pathwise\Exceptions\DirectoryOperationException; +use Infocyph\Pathwise\Exceptions\NativeExecutionException; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\Native\NativeOperationsAdapter; +use Infocyph\Pathwise\Security\ZipEntryValidator; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; use RecursiveDirectoryIterator; @@ -249,35 +252,17 @@ private function prepareZipPath(string $destination, bool $useLocalDestination): return $destination; } - private function sanitizeZipEntryPath(string $entry): string + private function tryNativeUnzip(string $localSource, string $source): bool { - $normalized = str_replace('\\', '/', $entry); - $trimmed = ltrim($normalized, '/'); - if ($trimmed === '') { - return ''; - } - - $safePath = preg_replace('#/+#', '/', $trimmed) ?? ''; - $safePath = preg_replace('#(^|/)\./#', '$1', $safePath) ?? $safePath; - $trimmedSafePath = rtrim($safePath, '/'); - - if ( - str_contains($trimmedSafePath, "\0") - || preg_match('#(^|/)\.\.(/|$)#', $trimmedSafePath) === 1 - || preg_match('/^[A-Za-z]:($|\/)/', $trimmedSafePath) === 1 - ) { - throw new DirectoryOperationException("Unsafe ZIP entry path detected: {$entry}"); - } - - if ($trimmedSafePath === '') { - return ''; + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + if (!$this->isLocalPath($source) || !$this->isLocalPath($this->path)) { + throw new UnsupportedStorageOperationException('Native unzip requires local source and destination paths.'); + } + if (!NativeOperationsAdapter::canUseNativeCompression()) { + throw new NativeExecutionException('Native ZIP decompression executables are unavailable.'); + } } - return str_ends_with($normalized, '/') ? $trimmedSafePath . '/' : $trimmedSafePath; - } - - private function tryNativeUnzip(string $localSource, string $source): bool - { if ( $this->executionStrategy === ExecutionStrategy::PHP || !NativeOperationsAdapter::canUseNativeCompression() @@ -287,12 +272,14 @@ private function tryNativeUnzip(string $localSource, string $source): bool } $native = NativeOperationsAdapter::decompressZip($localSource, $this->path); - if ($native['success']) { + if ($native->success) { return true; } if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new DirectoryOperationException("Native unzip failed for '{$source}' to '{$this->path}'."); + throw new NativeExecutionException( + "Native unzip failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + ); } return false; @@ -300,6 +287,15 @@ private function tryNativeUnzip(string $localSource, string $source): bool private function tryNativeZip(string $destination, bool $useLocalDestination): bool { + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + if (!$this->isLocalPath($this->path) || !$useLocalDestination) { + throw new UnsupportedStorageOperationException('Native zip requires local source and destination paths.'); + } + if (!NativeOperationsAdapter::canUseNativeCompression()) { + throw new NativeExecutionException('Native ZIP compression executables are unavailable.'); + } + } + if ( $this->executionStrategy === ExecutionStrategy::PHP || !NativeOperationsAdapter::canUseNativeCompression() @@ -310,12 +306,14 @@ private function tryNativeZip(string $destination, bool $useLocalDestination): b } $native = NativeOperationsAdapter::compressToZip($this->path, $destination); - if ($native['success']) { + if ($native->success) { return true; } if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new DirectoryOperationException("Native zip failed for '{$this->path}' to '{$destination}'."); + throw new NativeExecutionException( + "Native zip failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + ); } return false; @@ -331,23 +329,10 @@ private function validateZipEntries(string $localSource, string $source): array throw new DirectoryOperationException("Unable to open ZIP source: {$source}"); } - $validatedEntries = []; - try { - for ($i = 0; $i < $zip->numFiles; $i++) { - $entryName = $zip->getNameIndex($i); - if (!is_string($entryName)) { - $validatedEntries[$i] = ''; - - continue; - } - - $validatedEntries[$i] = $this->sanitizeZipEntryPath($entryName); - } + return ZipEntryValidator::validateArchive($zip, $this->path); } finally { $zip->close(); } - - return $validatedEntries; } } diff --git a/src/DirectoryManager/DirectoryOperations.php b/src/DirectoryManager/DirectoryOperations.php index 59a1b6a..817221a 100644 --- a/src/DirectoryManager/DirectoryOperations.php +++ b/src/DirectoryManager/DirectoryOperations.php @@ -6,10 +6,13 @@ use FilesystemIterator; use Infocyph\Pathwise\Core\ExecutionStrategy; +use Infocyph\Pathwise\Core\SyncComparison; use Infocyph\Pathwise\DirectoryManager\Concerns\DirectoryOperationsEntryConcern; use Infocyph\Pathwise\DirectoryManager\Concerns\DirectoryOperationsSyncConcern; use Infocyph\Pathwise\DirectoryManager\Concerns\DirectoryOperationsZipConcern; use Infocyph\Pathwise\Exceptions\DirectoryOperationException; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; +use Infocyph\Pathwise\Results\SyncReport; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; use Infocyph\Pathwise\Utils\PermissionsHelper; @@ -35,12 +38,6 @@ * minSize?: int, * maxSize?: int * } - * @phpstan-type SyncReport array{ - * created: list, - * updated: list, - * deleted: list, - * unchanged: list - * } */ class DirectoryOperations { @@ -66,9 +63,8 @@ public function __construct(protected string $path) * Copies the contents of the directory to the specified destination. * * @param string $destination The path to the destination directory. - * @return bool True if the copy operation was successful, false otherwise. */ - public function copy(string $destination, ?callable $progress = null): bool + public function copy(string $destination, ?callable $progress = null): self { if (!FlysystemHelper::directoryExists($this->path)) { throw new DirectoryOperationException("Source directory does not exist: {$this->path}"); @@ -80,7 +76,7 @@ public function copy(string $destination, ?callable $progress = null): bool } if ($this->attemptNativeCopy($destination, $progress)) { - return true; + return $this; } $this->emitCopyProgress($progress, 0); @@ -89,7 +85,7 @@ public function copy(string $destination, ?callable $progress = null): bool $this->emitCopyProgress($progress, 1); - return true; + return $this; } /** @@ -97,16 +93,15 @@ public function copy(string $destination, ?callable $progress = null): bool * * @param int $permissions The permissions to set for the newly created directory. * @param bool $recursive Whether to create the directory recursively. - * @return bool True if the directory was successfully created, false otherwise. */ - public function create(int $permissions = 0755, bool $recursive = true): bool + public function create(int $permissions = 0755, bool $recursive = true): self { if (FlysystemHelper::directoryExists($this->path)) { - return true; + return $this; } if (!$recursive && !$this->parentDirectoryExists($this->path)) { - return false; + throw new DirectoryOperationException("Parent directory does not exist: {$this->path}"); } FlysystemHelper::createDirectory($this->path); @@ -114,7 +109,7 @@ public function create(int $permissions = 0755, bool $recursive = true): bool $this->applyPermissionsSilently($this->path, $permissions); } - return true; + return $this; } /** @@ -136,27 +131,26 @@ public function createTempDir(): string * Deletes the directory. * * @param bool $recursive Whether to delete the contents of the directory first. - * @return bool True if the directory was successfully deleted, false otherwise. */ - public function delete(bool $recursive = false): bool + public function delete(bool $recursive = false): self { if (!FlysystemHelper::directoryExists($this->path)) { - return true; + return $this; } if ($recursive) { FlysystemHelper::deleteDirectory($this->path); - return true; + return $this; } foreach (FlysystemHelper::listContentsListing($this->path, false) as $_item) { - return false; + throw new DirectoryOperationException("Directory is not empty: {$this->path}"); } FlysystemHelper::deleteDirectory($this->path); - return true; + return $this; } /** @@ -240,7 +234,9 @@ public function getDepth(): int public function getIterator(): RecursiveIteratorIterator { if (!$this->isLocalPath($this->path) || !is_dir($this->path)) { - throw new DirectoryOperationException("Iterator is only available for local directories: {$this->path}"); + throw new UnsupportedStorageOperationException( + "Native iteration is only available for local directories: {$this->path}", + ); } return new RecursiveIteratorIterator( @@ -256,7 +252,9 @@ public function getIterator(): RecursiveIteratorIterator public function getPermissions(): int { if (!$this->isLocalPath($this->path) || !file_exists($this->path)) { - throw new DirectoryOperationException("Unable to retrieve permissions for non-local directory: {$this->path}"); + throw new UnsupportedStorageOperationException( + "POSIX permissions are only available for local directories: {$this->path}", + ); } $permissions = fileperms($this->path); @@ -345,17 +343,18 @@ public function listSortedContents(string $sortOrder = 'asc'): array * Moves the directory to the given destination. * * @param string $destination The path to move the directory to. - * @return bool True if the directory was successfully moved, false otherwise. */ - public function move(string $destination): bool + public function move(string $destination): self { if (!FlysystemHelper::directoryExists($this->path)) { - return false; + throw new DirectoryOperationException("Directory does not exist: {$this->path}"); } FlysystemHelper::moveDirectory($this->path, PathHelper::normalize($destination)); - return true; + $this->path = PathHelper::normalize($destination); + + return $this; } /** @@ -375,15 +374,20 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self * Set the permissions of the directory to the given value. * * @param int $permissions The new permissions for the directory. - * @return bool True if the permissions were successfully set, false otherwise. */ - public function setPermissions(int $permissions): bool + public function setPermissions(int $permissions): self { if (!$this->isLocalPath($this->path) || !file_exists($this->path)) { - throw new DirectoryOperationException("Unable to set permissions for non-local directory: {$this->path}"); + throw new UnsupportedStorageOperationException( + "POSIX permissions are only available for local directories: {$this->path}", + ); } - return chmod($this->path, $permissions); + if (!chmod($this->path, $permissions)) { + throw new DirectoryOperationException("Unable to set permissions for directory: {$this->path}"); + } + + return $this; } /** @@ -424,11 +428,13 @@ public function size(?callable $filter = null): int /** * Mirror the source directory to destination and return a diff report. - * - * @return SyncReport */ - public function syncTo(string $destination, bool $deleteOrphans = true, ?callable $progress = null): array - { + public function syncTo( + string $destination, + bool $deleteOrphans = true, + ?callable $progress = null, + ?SyncComparison $comparison = null, + ): SyncReport { $this->assertSourceDirectoryExists(); $destination = $this->ensureDirectoryExists($destination); $report = $this->newSyncReport(); @@ -436,7 +442,9 @@ public function syncTo(string $destination, bool $deleteOrphans = true, ?callabl $sourceLocation = $this->storageLocation($this->path); $sourceItems = $this->listStorageEntries($this->path, true); - $total = count($sourceItems); + $comparison ??= $this->isLocalPath($this->path) && $this->isLocalPath($destination) + ? SyncComparison::SIZE_AND_MODIFIED_TIME + : SyncComparison::SIZE; $current = 0; foreach ($sourceItems as $item) { @@ -446,8 +454,8 @@ public function syncTo(string $destination, bool $deleteOrphans = true, ?callabl } $current++; - $this->syncOneItem($destination, $relative, $item, $sourceEntries, $report); - $this->emitSyncProgress($progress, $relative, $current, $total); + $this->syncOneItem($destination, $relative, $item, $sourceEntries, $report, $comparison); + $this->emitSyncProgress($progress, $relative, $current, null); } if ($deleteOrphans) { @@ -461,9 +469,8 @@ public function syncTo(string $destination, bool $deleteOrphans = true, ?callabl * Extracts the contents of a zip file to the directory represented by this object. * * @param string $source The path to the zip file. - * @return bool True if the extraction was successful, false otherwise. */ - public function unzip(string $source): bool + public function unzip(string $source): self { $source = PathHelper::normalize($source); $this->assertZipSourceExists($source); @@ -474,12 +481,12 @@ public function unzip(string $source): bool $validatedEntries = $this->validateZipEntries($localSource, $source); if ($this->tryNativeUnzip($localSource, $source)) { - return true; + return $this; } $this->extractZipContents($localSource, $source, $validatedEntries); - return true; + return $this; } finally { $this->cleanupTemporaryFile($cleanupSource, $localSource); } @@ -504,16 +511,15 @@ public function visibility(): ?string * Zip the contents of the directory to a file. * * @param string $destination The path to the zip file. - * @return bool True if the zip was created successfully, false otherwise. */ - public function zip(string $destination): bool + public function zip(string $destination): self { $this->assertSourceDirectoryExists(); $destination = PathHelper::normalize($destination); $useLocalDestination = $this->isLocalPath($destination); if ($this->tryNativeZip($destination, $useLocalDestination)) { - return true; + return $this; } $zipPath = $this->prepareZipPath($destination, $useLocalDestination); @@ -529,7 +535,7 @@ public function zip(string $destination): bool $this->persistZipToDestination($zipPath, $destination); } - return true; + return $this; } /** diff --git a/src/Exceptions/CompressionException.php b/src/Exceptions/CompressionException.php index 18fdf52..1da673b 100644 --- a/src/Exceptions/CompressionException.php +++ b/src/Exceptions/CompressionException.php @@ -4,4 +4,4 @@ namespace Infocyph\Pathwise\Exceptions; -class CompressionException extends \RuntimeException {} +class CompressionException extends \RuntimeException implements PathwiseException {} diff --git a/src/Exceptions/DirectoryOperationException.php b/src/Exceptions/DirectoryOperationException.php index f36504d..2beb1e8 100644 --- a/src/Exceptions/DirectoryOperationException.php +++ b/src/Exceptions/DirectoryOperationException.php @@ -4,4 +4,4 @@ namespace Infocyph\Pathwise\Exceptions; -class DirectoryOperationException extends \RuntimeException {} +class DirectoryOperationException extends \RuntimeException implements PathwiseException {} diff --git a/src/Exceptions/DownloadException.php b/src/Exceptions/DownloadException.php index 4171ca6..d095627 100644 --- a/src/Exceptions/DownloadException.php +++ b/src/Exceptions/DownloadException.php @@ -4,4 +4,4 @@ namespace Infocyph\Pathwise\Exceptions; -class DownloadException extends \Exception {} +class DownloadException extends \Exception implements PathwiseException {} diff --git a/src/Exceptions/FileAccessException.php b/src/Exceptions/FileAccessException.php index cae11b7..f7d3750 100644 --- a/src/Exceptions/FileAccessException.php +++ b/src/Exceptions/FileAccessException.php @@ -4,4 +4,4 @@ namespace Infocyph\Pathwise\Exceptions; -class FileAccessException extends \Exception {} +class FileAccessException extends \Exception implements PathwiseException {} diff --git a/src/Exceptions/FileNotFoundException.php b/src/Exceptions/FileNotFoundException.php index 7bfa0ae..a58c33d 100644 --- a/src/Exceptions/FileNotFoundException.php +++ b/src/Exceptions/FileNotFoundException.php @@ -4,4 +4,4 @@ namespace Infocyph\Pathwise\Exceptions; -class FileNotFoundException extends \Exception {} +class FileNotFoundException extends \Exception implements PathwiseException {} diff --git a/src/Exceptions/FileSizeExceededException.php b/src/Exceptions/FileSizeExceededException.php index 1ce23f5..b4945c4 100644 --- a/src/Exceptions/FileSizeExceededException.php +++ b/src/Exceptions/FileSizeExceededException.php @@ -4,4 +4,4 @@ namespace Infocyph\Pathwise\Exceptions; -class FileSizeExceededException extends \Exception {} +class FileSizeExceededException extends \Exception implements PathwiseException {} diff --git a/src/Exceptions/InvalidPathException.php b/src/Exceptions/InvalidPathException.php new file mode 100644 index 0000000..f0e452b --- /dev/null +++ b/src/Exceptions/InvalidPathException.php @@ -0,0 +1,7 @@ +executionStrategy === ExecutionStrategy::NATIVE) { + if (!FlysystemHelper::isLocalPath($this->zipFilePath) || $isRemoteDestination) { + throw new UnsupportedStorageOperationException( + 'Native decompression requires local archive and destination paths.', + ); + } + if ($this->password !== null) { + throw new NativeExecutionException('Native decompression is unavailable for password-protected archives.'); + } + if (!NativeOperationsAdapter::canUseNativeCompression()) { + throw new NativeExecutionException('Native ZIP decompression executables are unavailable.'); + } + } + if ( $this->executionStrategy === ExecutionStrategy::PHP || $this->password !== null @@ -190,7 +207,7 @@ private function attemptNativeDecompression(string $destination, bool $isRemoteD $this->closeZip(); $native = NativeOperationsAdapter::decompressZip($this->workingZipPath, $destination); - if ($native['success']) { + if ($native->success) { if (is_callable($this->progressCallback)) { ($this->progressCallback)([ 'operation' => 'decompress', @@ -205,7 +222,9 @@ private function attemptNativeDecompression(string $destination, bool $isRemoteD } if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new CompressionException("Native decompression failed for archive: {$this->zipFilePath}"); + throw new NativeExecutionException( + "Native decompression failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + ); } $this->openZip(); @@ -213,6 +232,16 @@ private function attemptNativeDecompression(string $destination, bool $isRemoteD return false; } + private function closeExtractionStreams(mixed $input, mixed $output): void + { + if (is_resource($input)) { + fclose($input); + } + if (is_resource($output)) { + fclose($output); + } + } + private function copyLocalDirectoryToFlysystem(string $localSource, string $destination): void { $iterator = new \RecursiveIteratorIterator( @@ -323,10 +352,18 @@ private function emitDecompressionProgress(): void } } + private function ensureLocalExtractionDirectory(string $directory, string $entry): void + { + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new CompressionException("Unable to create extraction directory for: {$entry}"); + } + } + private function extractArchive(string $extractDestination, string $destination, bool $isRemoteDestination): void { - if (!$this->zip->extractTo($extractDestination)) { - throw new CompressionException('Failed to extract ZIP archive.'); + $entries = ZipEntryValidator::validateArchive($this->zip, $extractDestination); + foreach ($entries as $index => $entry) { + $this->extractArchiveEntry($index, $entry, $extractDestination); } if ($isRemoteDestination) { @@ -334,6 +371,34 @@ private function extractArchive(string $extractDestination, string $destination, } } + private function extractArchiveEntry(int $index, string $entry, string $extractDestination): void + { + $target = PathHelper::join($extractDestination, rtrim($entry, '/')); + if (str_ends_with($entry, '/')) { + $this->ensureLocalExtractionDirectory($target, $entry); + + return; + } + + $this->ensureLocalExtractionDirectory(dirname($target), $entry); + $input = $this->zip->getStream((string) $this->zip->getNameIndex($index)); + $output = fopen($target, 'wb'); + if (!is_resource($input) || !is_resource($output)) { + $this->closeExtractionStreams($input, $output); + + throw new CompressionException("Unable to extract ZIP entry: {$entry}"); + } + + try { + if (stream_copy_to_stream($input, $output) === false) { + throw new CompressionException("Unable to extract ZIP entry: {$entry}"); + } + } finally { + fclose($input); + fclose($output); + } + } + /** * Build a ZIP-safe relative path. */ diff --git a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php index 56e7db5..350c306 100644 --- a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php +++ b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php @@ -4,7 +4,6 @@ namespace Infocyph\Pathwise\FileManager\Concerns; -use Exception; use Infocyph\Pathwise\Exceptions\FileAccessException; use SimpleXMLElement; use SplFileObject; @@ -18,7 +17,7 @@ private function optionalBoolParam(array $params, int $index, bool $default): bo { $value = $this->optionalParamValue($params, $index, $default); if (!is_bool($value)) { - throw new Exception("Expected bool parameter at index {$index}."); + throw new FileAccessException("Expected bool parameter at index {$index}."); } return $value; @@ -39,7 +38,7 @@ private function optionalStringParam(array $params, int $index, string $default) { $value = $this->optionalParamValue($params, $index, $default); if (!is_string($value)) { - throw new Exception("Expected string parameter at index {$index}."); + throw new FileAccessException("Expected string parameter at index {$index}."); } return $value; @@ -53,7 +52,7 @@ private function requireArrayParam(array $params, int $index, string $type): arr { $value = $params[$index] ?? null; if (!is_array($value)) { - throw new Exception("Write type '{$type}' expects array parameter at index {$index}."); + throw new FileAccessException("Write type '{$type}' expects array parameter at index {$index}."); } return $value; @@ -69,7 +68,7 @@ private function requireCsvRowParam(array $params, int $index, string $type): ar $row = []; foreach ($value as $column) { if (!is_string($column) && !is_int($column) && !is_float($column) && !is_bool($column) && $column !== null) { - throw new Exception("Write type '{$type}' expects scalar CSV values."); + throw new FileAccessException("Write type '{$type}' expects scalar CSV values."); } $row[] = $column; @@ -103,7 +102,7 @@ private function requireStringParam(array $params, int $index, string $type): st { $value = $params[$index] ?? null; if (!is_string($value)) { - throw new Exception("Write type '{$type}' expects string parameter at index {$index}."); + throw new FileAccessException("Write type '{$type}' expects string parameter at index {$index}."); } return $value; @@ -119,7 +118,7 @@ private function requireWidthsParam(array $params, int $index, string $type): ar $widths = []; foreach ($value as $width) { if (!is_int($width)) { - throw new Exception("Write type '{$type}' expects integer widths."); + throw new FileAccessException("Write type '{$type}' expects integer widths."); } $widths[] = $width; @@ -135,7 +134,7 @@ private function requireXmlParam(array $params, int $index, string $type): Simpl { $value = $params[$index] ?? null; if (!$value instanceof SimpleXMLElement) { - throw new Exception("Write type '{$type}' expects SimpleXMLElement at index {$index}."); + throw new FileAccessException("Write type '{$type}' expects SimpleXMLElement at index {$index}."); } return $value; @@ -164,7 +163,7 @@ private function trackWriteType(string $type): void * @param string $data The binary data to write. * @return int|false The number of bytes written, or false on failure. */ - private function writeBinary(string $data): int|false + private function writeBinaryData(string $data): int|false { $this->writeCount++; @@ -180,7 +179,7 @@ private function writeBinary(string $data): int|false * @param string $char The character to write to the file. * @return int|false The number of bytes written, or false on failure. */ - private function writeCharacter(string $char): int|false + private function writeCharacterData(string $char): int|false { $this->writeCount++; @@ -200,7 +199,7 @@ private function writeCharacter(string $char): int|false * @param string $escape The character used to escape special characters. Defaults to '\\'. * @return int|false The number of bytes written, or false on failure. */ - private function writeCSV( + private function writeCsvRow( array $row, string $separator = ',', string $enclosure = '"', @@ -220,18 +219,18 @@ private function writeCSV( * @param array $data The data to write. Each element is written as a string. * @param array $widths The widths of each field. Each element is a positive integer. * @return int|false The number of bytes written, or false on failure. - * @throws Exception If the count of $data does not match the count of $widths. + * @throws FileAccessException If the count of $data does not match the count of $widths. */ - private function writeFixedWidth(array $data, array $widths): int|false + private function writeFixedWidthData(array $data, array $widths): int|false { if (count($data) !== count($widths)) { - throw new Exception('Data and widths arrays must match.'); + throw new FileAccessException('Data and widths arrays must match.'); } $line = ''; foreach ($data as $index => $field) { $width = $widths[$index] ?? null; if (!is_int($width)) { - throw new Exception('Widths must contain integers.'); + throw new FileAccessException('Widths must contain integers.'); } $line .= str_pad((string) $field, $width); @@ -241,22 +240,6 @@ private function writeFixedWidth(array $data, array $widths): int|false return $this->requireFileHandle()->fwrite($line . PHP_EOL); } - /** - * Writes JSON data to the file. - * - * This function encodes the provided data as JSON and writes it to the file. - * Optionally, it can format the JSON with indentation and whitespace for readability. - * - * @param mixed $data The data to encode as JSON and write. - * @param bool $prettyPrint If true, the JSON will be formatted for readability. Defaults to false. - * @return int|false The number of bytes written, or false on failure. - * @throws Exception If JSON encoding fails. - */ - private function writeJSON(mixed $data, bool $prettyPrint = false): int|false - { - return $this->writeJsonEncodedLine($data, $prettyPrint); - } - /** * Writes a JSON array to the file. * @@ -264,9 +247,9 @@ private function writeJSON(mixed $data, bool $prettyPrint = false): int|false * @param bool $prettyPrint If true, the JSON will be formatted with * indentation and whitespace for readability. Defaults to false. * @return int|false The number of bytes written, or false on failure. - * @throws Exception If the JSON encoding fails. + * @throws FileAccessException If the JSON encoding fails. */ - private function writeJSONArray(array $data, bool $prettyPrint = false): int|false + private function writeJsonArrayData(array $data, bool $prettyPrint = false): int|false { return $this->writeJsonEncodedLine($data, $prettyPrint); } @@ -276,13 +259,29 @@ private function writeJsonEncodedLine(mixed $data, bool $prettyPrint): 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()); + throw new FileAccessException('JSON encoding failed: ' . json_last_error_msg()); } $this->writeCount++; return $this->requireFileHandle()->fwrite($jsonData . PHP_EOL); } + /** + * Writes JSON data to the file. + * + * This function encodes the provided data as JSON and writes it to the file. + * Optionally, it can format the JSON with indentation and whitespace for readability. + * + * @param mixed $data The data to encode as JSON and write. + * @param bool $prettyPrint If true, the JSON will be formatted for readability. Defaults to false. + * @return int|false The number of bytes written, or false on failure. + * @throws FileAccessException If JSON encoding fails. + */ + private function writeJsonLineData(mixed $data, bool $prettyPrint = false): int|false + { + return $this->writeJsonEncodedLine($data, $prettyPrint); + } + /** * Writes a line of text to the file. * @@ -293,7 +292,7 @@ private function writeJsonEncodedLine(mixed $data, bool $prettyPrint): int|false * @param string $content The content to write to the file. * @return int|false The number of bytes written, or false on failure. */ - private function writeLine(string $content): int|false + private function writeLineData(string $content): int|false { $this->writeCount++; @@ -311,7 +310,7 @@ private function writeLine(string $content): int|false * @param string $pattern The regex pattern to match against the content. * @return int|false The number of bytes written, or false on failure. */ - private function writePatternMatch(string $content, string $pattern): int|false + private function writeMatchingLineData(string $content, string $pattern): int|false { if (preg_match($pattern, $content)) { $this->writeCount++; @@ -332,7 +331,7 @@ private function writePatternMatch(string $content, string $pattern): int|false * @param mixed $data The data to serialize and write. * @return int|false The number of bytes written, or false on failure. */ - private function writeSerialized(mixed $data): int|false + private function writeSerializedData(mixed $data): int|false { $serializedData = serialize($data); $this->writeCount++; @@ -349,7 +348,7 @@ private function writeSerialized(mixed $data): int|false * @param SimpleXMLElement $element The XML element to write. * @return int|false The number of bytes written, or false on failure. */ - private function writeXML(SimpleXMLElement $element): int|false + private function writeXmlData(SimpleXMLElement $element): int|false { $this->writeCount++; diff --git a/src/FileManager/FileCompression.php b/src/FileManager/FileCompression.php index ae221ac..b8413d2 100644 --- a/src/FileManager/FileCompression.php +++ b/src/FileManager/FileCompression.php @@ -6,10 +6,14 @@ use Infocyph\Pathwise\Core\ExecutionStrategy; use Infocyph\Pathwise\Exceptions\CompressionException; +use Infocyph\Pathwise\Exceptions\MissingExtensionException; +use Infocyph\Pathwise\Exceptions\NativeExecutionException; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\FileManager\Concerns\FileCompressionArchiveConcern; use Infocyph\Pathwise\FileManager\Concerns\FileCompressionRuntimeConcern; use Infocyph\Pathwise\FileManager\Concerns\FsConcern; use Infocyph\Pathwise\Native\NativeOperationsAdapter; +use Infocyph\Pathwise\Security\ZipEntryValidator; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; use ZipArchive; @@ -81,6 +85,10 @@ class FileCompression */ public function __construct(private readonly string $zipFilePath, bool $create = false) { + if (!extension_loaded('zip')) { + throw new MissingExtensionException('ZIP operations require ext-zip.'); + } + $this->zip = new ZipArchive(); $this->workingZipPath = $this->resolveWorkingZipPath($create); @@ -185,9 +193,10 @@ public function batchExtractFiles(array $files, string $destination): self $this->log('Batch extracting files.'); $this->progressCurrent = 0; $this->progressTotal = count($files); + ZipEntryValidator::validateArchive($this->zip, $destination); foreach ($files as $zipPath => $localPath) { - $zipPath = $this->normalizeZipPath($zipPath); - $localPath = ltrim(PathHelper::normalize($localPath), DIRECTORY_SEPARATOR); + $zipPath = ZipEntryValidator::validate($zipPath, $destination); + $localPath = ZipEntryValidator::validate($localPath, $destination); $targetPath = PathHelper::join($destination, $localPath); if (str_ends_with($zipPath, '/')) { @@ -198,8 +207,8 @@ public function batchExtractFiles(array $files, string $destination): self continue; } - $content = $this->zip->getFromName($zipPath); - if ($content === false) { + $stream = $this->zip->getStream($zipPath); + if (!is_resource($stream)) { throw new CompressionException("File not found in ZIP archive: $zipPath."); } @@ -208,7 +217,11 @@ public function batchExtractFiles(array $files, string $destination): self FlysystemHelper::createDirectory($targetDir); } - FlysystemHelper::write($targetPath, $content); + try { + FlysystemHelper::writeStream($targetPath, $stream); + } finally { + fclose($stream); + } $this->advanceProgress('decompress', $zipPath); } @@ -216,21 +229,6 @@ public function batchExtractFiles(array $files, string $destination): self return $this; } - /** - * Check the integrity of the current ZIP archive. - * - * This function checks the status of the current ZIP archive and returns - * true if the archive is valid and false otherwise. - * - * @return bool True if the archive is valid, false otherwise. - */ - public function checkIntegrity(): bool - { - $this->reopenIfNeeded(); - - return $this->zip->status === ZipArchive::ER_OK; - } - /** * Compress a file or directory into the ZIP archive. * @@ -241,10 +239,14 @@ public function compress(string $source): self $this->reopenIfNeeded(); $resolvedSource = $this->prepareCompressionSource($source); + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + $this->assertNativeCompressionSupported($source); + } + if ($this->shouldAttemptNativeCompression() && NativeOperationsAdapter::canUseNativeCompression()) { $this->closeZip(); $native = NativeOperationsAdapter::compressToZip($resolvedSource, $this->workingZipPath); - if ($native['success']) { + if ($native->success) { if (is_callable($this->progressCallback)) { ($this->progressCallback)([ 'operation' => 'compress', @@ -259,7 +261,9 @@ public function compress(string $source): self } if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - throw new CompressionException("Native compression failed for source: {$resolvedSource}"); + throw new NativeExecutionException( + "Native compression failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + ); } $this->openZip(); @@ -364,6 +368,21 @@ public function getFileIterator(): \Generator } } + /** + * Check the integrity of the current ZIP archive. + * + * This function checks the status of the current ZIP archive and returns + * true if the archive is valid and false otherwise. + * + * @return bool True if the archive is valid, false otherwise. + */ + public function hasNoReportedArchiveErrors(): bool + { + $this->reopenIfNeeded(); + + return $this->zip->status === ZipArchive::ER_OK; + } + /** * Get an array of all the files in the current ZIP archive. * @@ -551,6 +570,21 @@ public function setProgressCallback(callable $progressCallback): self return $this; } + private function assertNativeCompressionSupported(string $source): void + { + if (!FlysystemHelper::isLocalPath($source) || !FlysystemHelper::isLocalPath($this->zipFilePath)) { + throw new UnsupportedStorageOperationException( + 'Native compression requires local source and archive paths.', + ); + } + if (!$this->shouldAttemptNativeCompression()) { + throw new NativeExecutionException('Native compression does not support the selected archive options.'); + } + if (!NativeOperationsAdapter::canUseNativeCompression()) { + throw new NativeExecutionException('Native ZIP compression executables are unavailable.'); + } + } + private function prepareCompressionSource(string $source): string { $cleanupPath = null; diff --git a/src/FileManager/FileOperations.php b/src/FileManager/FileOperations.php index c31798a..71e0e42 100644 --- a/src/FileManager/FileOperations.php +++ b/src/FileManager/FileOperations.php @@ -8,6 +8,9 @@ use Infocyph\Pathwise\Core\ExecutionStrategy; use Infocyph\Pathwise\Exceptions\FileAccessException; use Infocyph\Pathwise\Exceptions\FileNotFoundException; +use Infocyph\Pathwise\Exceptions\NativeExecutionException; +use Infocyph\Pathwise\Exceptions\TransactionStateException; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\Native\NativeOperationsAdapter; use Infocyph\Pathwise\Observability\AuditTrail; use Infocyph\Pathwise\Security\PolicyEngine; @@ -26,11 +29,10 @@ class FileOperations private ?PolicyEngine $policyEngine = null; - /** @var array */ - private array $rollbackActions = []; - private bool $transactionActive = false; + private ?FileTransactionJournal $transactionJournal = null; + /** * Constructor to initialize the file path. */ @@ -42,17 +44,38 @@ public function __construct(protected string $filePath) /** * Append content to the file. */ - public function append(string $content): self + public function append(string $content, bool $lock = true): self { $this->assertPolicy('append', $this->filePath); - $previousContent = $this->snapshotRollbackContent(); - $newContent = ($previousContent ?? '') . $content; - FlysystemHelper::write($this->filePath, $newContent); + $this->assertLocalOperation('append'); + $this->recordFileState($this->filePath); + + $flags = FILE_APPEND | ($lock ? LOCK_EX : 0); + if (file_put_contents($this->filePath, $content, $flags) === false) { + throw new FileAccessException("Unable to append to file: {$this->filePath}"); + } $this->audit('append', ['path' => $this->filePath, 'bytes' => strlen($content)]); return $this; } + /** + * Append to adapter-backed storage by explicitly replacing the complete object. + */ + public function appendEmulated(string $content): self + { + $this->assertPolicy('append', $this->filePath); + if (FlysystemHelper::isLocalPath($this->filePath)) { + return $this->append($content); + } + + $existing = $this->exists() ? FlysystemHelper::read($this->filePath) : ''; + FlysystemHelper::write($this->filePath, $existing . $content); + $this->audit('append-emulated', ['path' => $this->filePath, 'bytes' => strlen($content)]); + + return $this; + } + /** * Begin a transaction for atomic file operations. * @@ -60,8 +83,12 @@ public function append(string $content): self */ public function beginTransaction(): self { + if ($this->transactionActive) { + throw new TransactionStateException('Nested transactions are not supported.'); + } + $this->assertLocalOperation('transactions'); $this->transactionActive = true; - $this->rollbackActions = []; + $this->transactionJournal = new FileTransactionJournal($this->filePath); return $this; } @@ -73,8 +100,10 @@ public function beginTransaction(): self */ public function commitTransaction(): self { + $this->assertTransactionActive('commit'); + $this->transactionJournal?->commit(); $this->transactionActive = false; - $this->rollbackActions = []; + $this->transactionJournal = null; return $this; } @@ -86,45 +115,10 @@ public function copy(string $destination, ?callable $progress = null): self { $this->assertPolicy('copy', $this->filePath, ['destination' => $destination]); - if (is_callable($progress)) { - $progress([ - 'operation' => 'copy', - 'path' => $this->filePath, - 'destination' => $destination, - 'current' => 0, - 'total' => 1, - ]); - } - - $copied = false; - if ($this->executionStrategy !== ExecutionStrategy::PHP && NativeOperationsAdapter::canUseNativeFileCopy()) { - $native = NativeOperationsAdapter::copyFile($this->filePath, $destination); - $copied = $native['success']; - } - - if (!$copied) { - try { - FlysystemHelper::copy($this->filePath, $destination); - } catch (\Throwable $e) { - throw new FileAccessException("Unable to copy file to $destination.", 0, $e); - } - } - $this->recordRollback(function () use ($destination): void { - if (FlysystemHelper::fileExists($destination)) { - FlysystemHelper::delete($destination); - } - }); - - if (is_callable($progress)) { - $progress([ - 'operation' => 'copy', - 'path' => $this->filePath, - 'destination' => $destination, - 'current' => 1, - 'total' => 1, - ]); - } - + $this->emitCopyProgress($progress, $destination, 0); + $this->recordFileState($destination); + $this->performCopy($destination); + $this->emitCopyProgress($progress, $destination, 1); $this->audit('copy', ['source' => $this->filePath, 'destination' => $destination]); return $this; @@ -156,15 +150,7 @@ public function copyWithVerification(string $destination, string $algorithm = 's public function create(?string $content = ''): self { $this->assertPolicy('create', $this->filePath); - $hadFile = $this->exists(); - $previousContent = $hadFile ? $this->read() : null; - $this->recordRollback(function () use ($hadFile, $previousContent): void { - if ($hadFile) { - FlysystemHelper::write($this->filePath, (string) $previousContent); - } elseif (FlysystemHelper::fileExists($this->filePath)) { - FlysystemHelper::delete($this->filePath); - } - }); + $this->recordFileState($this->filePath); FlysystemHelper::write($this->filePath, (string) $content); $this->audit('create', ['path' => $this->filePath]); @@ -180,10 +166,7 @@ public function delete(): self if (!$this->exists()) { throw new FileNotFoundException("File does not exist at $this->filePath."); } - $content = $this->read(); - $this->recordRollback(function () use ($content): void { - FlysystemHelper::write($this->filePath, $content); - }); + $this->recordFileState($this->filePath); try { FlysystemHelper::delete($this->filePath); @@ -230,6 +213,7 @@ public function getLineCount(): int */ public function getMetadata(): array { + $this->assertLocalOperation('local metadata'); $info = new SplFileInfo($this->filePath); return [ @@ -255,6 +239,10 @@ public function isReadable(): bool throw new FileNotFoundException("File not found at $this->filePath."); } + if (!FlysystemHelper::isLocalPath($this->filePath)) { + return true; + } + return is_readable($this->filePath); } @@ -265,6 +253,7 @@ public function isReadable(): bool */ public function openWithLock(bool $exclusive = true, int $timeout = 0): self { + $this->assertLocalOperation('direct file locking'); $file = $this->requireFile('r+'); $lockType = $exclusive ? LOCK_EX : LOCK_SH; $lockType |= LOCK_NB; @@ -329,18 +318,15 @@ public function rename(string $newPath): self { $this->assertPolicy('rename', $this->filePath, ['destination' => $newPath]); $newPath = PathHelper::normalize($newPath); + $oldPath = $this->filePath; + $this->recordFileState($oldPath); + $this->recordFileState($newPath); try { FlysystemHelper::move($this->filePath, $newPath); } catch (\Throwable $e) { throw new FileAccessException("Unable to rename or move file to $newPath.", 0, $e); } - $oldPath = $this->filePath; - $this->recordRollback(function () use ($oldPath, $newPath): void { - if (FlysystemHelper::fileExists($newPath)) { - FlysystemHelper::move($newPath, $oldPath); - } - }); $this->filePath = $newPath; $this->initFile(); // Reinitialize file object with new path $this->audit('rename', ['from' => $oldPath, 'to' => $newPath]); @@ -355,11 +341,16 @@ public function rename(string $newPath): self */ public function rollbackTransaction(): self { - for ($i = count($this->rollbackActions) - 1; $i >= 0; $i--) { - ($this->rollbackActions[$i])(); + $this->assertTransactionActive('rollback'); + $journal = $this->transactionJournal; + if (!$journal instanceof FileTransactionJournal) { + throw new TransactionStateException('Transaction journal is unavailable.'); } + $journal->rollback(); + $this->filePath = $journal->originalPath; + $this->file = null; $this->transactionActive = false; - $this->rollbackActions = []; + $this->transactionJournal = null; return $this; } @@ -371,6 +362,7 @@ public function rollbackTransaction(): self */ public function searchContent(string $searchTerm): array { + $this->assertLocalOperation('native content searching'); $command = escapeshellarg($this->filePath); $escapedTerm = escapeshellarg($searchTerm); @@ -447,16 +439,12 @@ public function setOwner(int $ownerId): self */ public function setPermissions(int $permissions): self { + $this->assertLocalOperation('POSIX permissions'); $this->assertPolicy('set-permissions', $this->filePath); if (!$this->exists()) { throw new FileNotFoundException("File does not exist at $this->filePath."); } - $previous = fileperms($this->filePath); - if (is_int($previous)) { - $this->recordRollback(function () use ($previous): void { - chmod($this->filePath, $previous & 0777); - }); - } + $this->recordFileState($this->filePath); if (!chmod($this->filePath, $permissions)) { throw new FileAccessException("Unable to set permissions for file: {$this->filePath}."); } @@ -527,7 +515,15 @@ public function transaction(callable $callback): mixed return $result; } catch (\Throwable $e) { - $this->rollbackTransaction(); + try { + $this->rollbackTransaction(); + } catch (\Throwable $rollbackFailure) { + throw new FileAccessException( + 'Transaction failed and rollback was incomplete: ' . $rollbackFailure->getMessage(), + 0, + $e, + ); + } throw $e; } @@ -538,6 +534,7 @@ public function transaction(callable $callback): mixed */ public function unlock(): self { + $this->assertLocalOperation('direct file locking'); $this->requireFile()->flock(LOCK_UN); return $this; @@ -549,7 +546,7 @@ public function unlock(): self public function update(string $content): self { $this->assertPolicy('update', $this->filePath); - $this->snapshotRollbackContent(); + $this->recordFileState($this->filePath); FlysystemHelper::write($this->filePath, $content); $this->audit('update', ['path' => $this->filePath, 'bytes' => strlen($content)]); @@ -622,6 +619,7 @@ public function writeAndVerify(string $content, string $algorithm = 'sha256'): s public function writeStream(mixed $stream, array $config = []): self { $this->assertPolicy('write-stream', $this->filePath); + $this->recordFileState($this->filePath); FlysystemHelper::writeStream($this->filePath, $stream, $config); $this->audit('write-stream', ['path' => $this->filePath]); @@ -633,6 +631,7 @@ public function writeStream(mixed $stream, array $config = []): self */ protected function initFile(string $mode = 'r'): self { + $this->assertLocalOperation('direct file handles'); $this->file = new SplFileObject($this->filePath, $mode); return $this; @@ -640,7 +639,9 @@ protected function initFile(string $mode = 'r'): self private function applyOwnershipChange(string $action, int $value, callable $updater, string $label): self { + $this->assertLocalOperation($label . ' changes'); $this->assertPolicy($action, $this->filePath); + $this->recordFileState($this->filePath); if (!$updater($this->filePath, $value)) { throw new FileAccessException("Unable to change {$label} for file: {$this->filePath}."); } @@ -650,6 +651,15 @@ private function applyOwnershipChange(string $action, int $value, callable $upda return $this; } + private function assertLocalOperation(string $operation): void + { + if (!FlysystemHelper::isLocalPath($this->filePath)) { + throw new UnsupportedStorageOperationException( + "{$operation} is only supported for local filesystem paths: {$this->filePath}", + ); + } + } + /** * @param array $context */ @@ -658,6 +668,13 @@ private function assertPolicy(string $operation, string $path, array $context = $this->policyEngine?->assertAllowed($operation, PathHelper::normalize($path), $context); } + private function assertTransactionActive(string $operation): void + { + if (!$this->transactionActive) { + throw new TransactionStateException("Cannot {$operation} without an active transaction."); + } + } + /** * @param array $context */ @@ -693,13 +710,68 @@ private function determineMimeType(): ?string return null; } - private function recordRollback(callable $rollbackAction): void + private function emitCopyProgress(?callable $progress, string $destination, int $current): void + { + if (!is_callable($progress)) { + return; + } + + $progress([ + 'operation' => 'copy', + 'path' => $this->filePath, + 'destination' => $destination, + 'current' => $current, + 'total' => 1, + ]); + } + + private function performCopy(string $destination): void + { + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + $this->assertLocalOperation('native copy'); + if (!NativeOperationsAdapter::canUseNativeFileCopy()) { + throw new NativeExecutionException('Native file copy executable is unavailable.'); + } + $native = NativeOperationsAdapter::copyFile($this->filePath, $destination); + if (!$native->success) { + throw new NativeExecutionException( + "Native file copy failed with exit code {$native->exitCode}: {$native->command}", + ); + } + + return; + } + + if ( + $this->executionStrategy === ExecutionStrategy::AUTO + && FlysystemHelper::isLocalPath($this->filePath) + && FlysystemHelper::isLocalPath($destination) + && NativeOperationsAdapter::canUseNativeFileCopy() + ) { + $native = NativeOperationsAdapter::copyFile($this->filePath, $destination); + if ($native->success) { + return; + } + } + + try { + FlysystemHelper::copy($this->filePath, $destination); + } catch (\Throwable $e) { + throw new FileAccessException("Unable to copy file to $destination.", 0, $e); + } + } + + private function recordFileState(string $path): void { if (!$this->transactionActive) { return; } - $this->rollbackActions[] = $rollbackAction; + if (!FlysystemHelper::isLocalPath($path)) { + throw new UnsupportedStorageOperationException('Transactions only support local filesystem paths.'); + } + + $this->transactionJournal?->record($path); } private function requireFile(string $mode = 'r'): SplFileObject @@ -714,18 +786,4 @@ 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/FileTransactionJournal.php b/src/FileManager/FileTransactionJournal.php new file mode 100644 index 0000000..b45bcbc --- /dev/null +++ b/src/FileManager/FileTransactionJournal.php @@ -0,0 +1,98 @@ + */ + private array $entries = []; + + public function __construct(public readonly string $originalPath) {} + + public function commit(): void + { + $this->cleanup(); + $this->entries = []; + } + + public function record(string $path): void + { + $existed = is_file($path); + $backup = null; + $permissions = null; + if ($existed) { + $backup = tempnam(sys_get_temp_dir(), 'pathwise_tx_'); + if ($backup === false || !copy($path, $backup)) { + throw new FileAccessException("Unable to create rollback backup for {$path}."); + } + $mode = fileperms($path); + $permissions = is_int($mode) ? $mode & 0777 : null; + } + + $this->entries[] = [ + 'path' => $path, + 'existed' => $existed, + 'backup' => $backup, + 'permissions' => $permissions, + ]; + } + + public function rollback(): void + { + $failures = []; + for ($index = count($this->entries) - 1; $index >= 0; $index--) { + try { + $this->restore($this->entries[$index]); + } catch (\Throwable $exception) { + $failures[] = $exception->getMessage(); + } + } + $this->cleanup(); + $this->entries = []; + + if ($failures !== []) { + throw new FileAccessException('Transaction rollback failed: ' . implode('; ', $failures)); + } + } + + private function cleanup(): void + { + foreach ($this->entries as $entry) { + if (is_string($entry['backup']) && is_file($entry['backup'])) { + unlink($entry['backup']); + } + } + } + + /** + * @param array{path: string, existed: bool, backup: string|null, permissions: int|null} $entry + */ + private function restore(array $entry): void + { + if (!$entry['existed']) { + if (is_file($entry['path']) && !unlink($entry['path'])) { + throw new FileAccessException("Unable to remove transaction-created file: {$entry['path']}"); + } + + return; + } + if (!is_string($entry['backup']) || !is_file($entry['backup'])) { + throw new FileAccessException("Rollback backup is unavailable for {$entry['path']}."); + } + + $parent = dirname($entry['path']); + if (!is_dir($parent) && !mkdir($parent, 0755, true) && !is_dir($parent)) { + throw new FileAccessException("Unable to recreate rollback directory: {$parent}"); + } + if (!copy($entry['backup'], $entry['path'])) { + throw new FileAccessException("Unable to restore rollback backup for {$entry['path']}."); + } + if (is_int($entry['permissions']) && !chmod($entry['path'], $entry['permissions'])) { + throw new FileAccessException("Unable to restore permissions for {$entry['path']}."); + } + } +} diff --git a/src/FileManager/SafeFileReader.php b/src/FileManager/SafeFileReader.php index 95f063c..926872a 100644 --- a/src/FileManager/SafeFileReader.php +++ b/src/FileManager/SafeFileReader.php @@ -5,42 +5,24 @@ namespace Infocyph\Pathwise\FileManager; use Countable; -use Exception; use Generator; use Infocyph\Pathwise\Exceptions\FileAccessException; +use Infocyph\Pathwise\Exceptions\MissingExtensionException; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; -use Iterator; -use NoRewindIterator; -use SeekableIterator; use SimpleXMLElement; use SplFileObject; use XMLReader; /** - * Memory-safe file reader with multiple read modes, locking, and interface support. - * - * @method SafeFileReader character() Character iterator - * @method SafeFileReader line() Line iterator - * @method SafeFileReader csv(string $separator = ",", string $enclosure = "\"", string $escape = "\\") CSV iterator - * @method SafeFileReader binary(int $bytes = 1024) Binary iterator - * @method SafeFileReader json() JSON line-by-line iterator - * @method SafeFileReader regex(string $pattern) Regex iterator - * @method SafeFileReader fixedWidth(array $widths) Fixed-width field iterator - * @method SafeFileReader xml(string $element) XML iterator - * @method SafeFileReader serialized() Serialized object iterator - * @method SafeFileReader jsonArray() JSON array iterator - * @implements Iterator - * @implements SeekableIterator + * Memory-safe file reader with explicit, statically discoverable read modes. */ -final class SafeFileReader implements Countable, Iterator, SeekableIterator +final class SafeFileReader implements Countable { private bool $cleanupLocalWorkingPath = false; private int $count = 0; - private ?Generator $currentIterator = null; - private SplFileObject $file; private int $fileSize; @@ -80,42 +62,23 @@ public function __destruct() } } - /** - * Dynamically invokes an iterator based on the specified type. - * - * This method initializes the file reader and returns an iterator - * for the requested type. Supported types include 'character', 'line', - * 'csv', 'binary', 'json', 'regex', 'fixedWidth', 'xml', 'serialized', - * and 'jsonArray'. Each type corresponds to a specific method that - * generates the appropriate iterator for processing the file content. - * - * @param string $type The type of iterator to create. - * @param list $params Parameters to pass to the iterator method. - * @return NoRewindIterator The requested iterator wrapped in a NoRewindIterator. - * @throws Exception If the specified iterator type is unknown. - */ - public function __call(string $type, array $params): NoRewindIterator + /** @return Generator */ + public function characters(): Generator { - $this->initiate(); - $this->currentIterator = match ($type) { - 'character' => $this->characterIterator(), - 'line' => $this->lineIterator(), - 'csv' => $this->csvIterator( - $this->optionalStringParam($params, 0, ','), - $this->optionalStringParam($params, 1, '"'), - $this->optionalStringParam($params, 2, '\\'), - ), - 'binary' => $this->binaryIterator($this->optionalIntParam($params, 0, 1024)), - 'json' => $this->jsonIteratorWithHandling(), - 'regex' => $this->regexIterator($this->requireStringParam($params, 0, 'regex pattern')), - 'fixedWidth' => $this->fixedWidthIterator($this->requireWidthsParam($params)), - 'xml' => $this->xmlIterator($this->requireStringParam($params, 0, 'xml element')), - 'serialized' => $this->serializedIterator(), - 'jsonArray' => $this->jsonArrayIteratorWithHandling(), - default => throw new Exception("Unknown iterator type '$type'"), - }; - - return new NoRewindIterator($this->currentIterator); + $this->prepareRead(); + + return $this->characterIterator(); + } + + /** @return Generator */ + public function chunks(int $bytes = 1024): Generator + { + if ($bytes < 1) { + throw new \InvalidArgumentException('Chunk size must be positive.'); + } + $this->prepareRead(); + + return $this->binaryIterator($bytes); } /** @@ -128,45 +91,55 @@ public function count(): int return $this->count; } - /** - * Returns the current element in the file. - * - * This method returns the current element from the internal iterator. - * The type of the element depends on the iterator type, which is determined - * by the method call that created the iterator. - * - * @return mixed The current element in the file. - */ - public function current(): mixed + /** @return Generator> */ + public function csv(string $separator = ',', string $enclosure = '"', string $escape = '\\'): Generator { - return $this->currentIterator?->current(); + $this->prepareRead(); + + return $this->csvIterator($separator, $enclosure, $escape); } - /** - * Returns the current position in the file. - * - * This method returns the current value of the internal position counter, - * which is incremented by the `next` method and reset by the `rewind` - * method. - * - * @return int The current position in the file. - */ - public function key(): int + /** @param list $widths @return Generator> */ + public function fixedWidth(array $widths): Generator { - return $this->position; + $this->prepareRead(); + + return $this->fixedWidthIterator($this->validateWidths($widths)); } - /** - * Moves the internal iterator to the next position. - * - * This method calls `next` on the current iterator, and then increments the - * internal position counter. It should be called after `valid` has been - * called to verify that the iterator is valid. - */ - public function next(): void + /** @return Generator */ + public function jsonArray(): Generator { - $this->currentIterator?->next(); - $this->position++; + $this->prepareRead(); + + return $this->jsonArrayIteratorWithHandling(); + } + + /** @return Generator */ + public function jsonLines(): Generator + { + $this->prepareRead(); + + return $this->jsonIteratorWithHandling(); + } + + /** @return Generator */ + public function lines(): Generator + { + $this->prepareRead(); + + return $this->lineIterator(); + } + + /** @return Generator> */ + public function matchingLines(string $pattern): Generator + { + if ($pattern === '') { + throw new \InvalidArgumentException('A regular-expression pattern is required.'); + } + $this->prepareRead(); + + return $this->regexIterator($pattern); } /** @@ -183,50 +156,29 @@ public function releaseLock(): void } } - /** - * Resets the file pointer to the beginning of the file. - * - * This method rewinds the internal file pointer to the beginning of the file, - * resets the internal position counter, and rewinds the current iterator - * instance if one exists. - */ - public function rewind(): void + /** @return Generator */ + public function serializedValues(): Generator { - $this->file->rewind(); - $this->resetPosition(); - $this->currentIterator?->rewind(); + $this->prepareRead(); + + return $this->serializedIterator(); } - /** - * Seeks to the specified position in the file. - * - * This method initializes the file if necessary and then moves the internal pointer - * to the specified offset. If the offset is negative, an exception is thrown. - * - * @param int $offset The position to seek to in the file. - * @throws Exception If the specified offset is negative. - */ - public function seek(int $offset): void + /** @return Generator */ + public function xmlElements(string $element): Generator { - $this->initiate(); - if ($offset < 0) { - throw new Exception("Invalid position ($offset)"); + if (!extension_loaded('xmlreader')) { + throw new MissingExtensionException('XML reading requires ext-xmlreader.'); } - $this->file->seek($offset); - $this->position = $offset; - } + if (!extension_loaded('simplexml')) { + throw new MissingExtensionException('XML reading requires ext-simplexml.'); + } + if ($element === '') { + throw new \InvalidArgumentException('An XML element name is required.'); + } + $this->prepareRead(); - /** - * Checks if the current element is valid. - * - * This method returns the validity of the current element from the internal - * iterator. If the iterator is not initialized, it returns false. - * - * @return bool True if the current element is valid, false otherwise. - */ - public function valid(): bool - { - return $this->currentIterator?->valid() ?? false; + return $this->xmlIterator($element); } /** @@ -332,11 +284,11 @@ private function deserializeValue(string $serializedLine): mixed { $result = unserialize($serializedLine, ['allowed_classes' => false]); if ($result === false && $serializedLine !== 'b:0;') { - throw new Exception('Failed to unserialize data.'); + throw new FileAccessException('Failed to unserialize data.'); } if ($this->containsObjectValue($result)) { - throw new Exception('Serialized objects are not allowed.'); + throw new FileAccessException('Serialized objects are not allowed.'); } return $result; @@ -405,18 +357,18 @@ private function initiate(): void * an array, an exception is thrown. * * @return Generator Yields each element of the JSON array. - * @throws Exception If decoding the JSON array fails. + * @throws FileAccessException If decoding the JSON array fails. */ private function jsonArrayIteratorWithHandling(): Generator { $jsonContent = $this->file->fread($this->fileSize); if (!is_string($jsonContent)) { - throw new Exception('JSON array decoding error: failed to read file content.'); + throw new FileAccessException('JSON array decoding error: failed to read file content.'); } $jsonArray = json_decode($jsonContent, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($jsonArray)) { - throw new Exception('JSON array decoding error: ' . json_last_error_msg()); + throw new FileAccessException('JSON array decoding error: ' . json_last_error_msg()); } foreach ($jsonArray as $element) { yield $element; @@ -434,7 +386,7 @@ private function jsonArrayIteratorWithHandling(): Generator * are incremented for each valid JSON line. * * @return Generator Yields decoded JSON objects from each line of the file. - * @throws Exception If JSON decoding fails for any line. + * @throws FileAccessException If JSON decoding fails for any line. */ private function jsonIteratorWithHandling(): Generator { @@ -443,7 +395,7 @@ private function jsonIteratorWithHandling(): Generator if ($line) { $decoded = json_decode($line, true); if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception('JSON decoding error: ' . json_last_error_msg()); + throw new FileAccessException('JSON decoding error: ' . json_last_error_msg()); } yield $decoded; $this->position++; @@ -475,30 +427,11 @@ private function lineIterator(): Generator } } - /** - * @param list $params - */ - private function optionalIntParam(array $params, int $index, int $default): int - { - $value = $params[$index] ?? $default; - if (!is_int($value)) { - throw new Exception("Parameter #{$index} must be an integer."); - } - - return $value; - } - - /** - * @param list $params - */ - private function optionalStringParam(array $params, int $index, string $default): string + private function prepareRead(): void { - $value = $params[$index] ?? $default; - if (!is_string($value)) { - throw new Exception("Parameter #{$index} must be a string."); - } - - return $value; + $this->initiate(); + $this->file->rewind(); + $this->resetPosition(); } /** @@ -511,7 +444,7 @@ private function optionalStringParam(array $params, int $index, string $default) * * @param string $pattern The regex pattern to apply to each line. * @return Generator An iterator over the matches from the file. - * @throws Exception If the regex pattern is invalid. + * @throws FileAccessException If the regex pattern is invalid. */ private function regexIterator(string $pattern): Generator { @@ -525,46 +458,6 @@ private function regexIterator(string $pattern): Generator } } - /** - * @param list $params - */ - private function requireStringParam(array $params, int $index, string $name): string - { - $value = $params[$index] ?? null; - if (!is_string($value) || $value === '') { - throw new Exception("Missing or invalid {$name}."); - } - - return $value; - } - - /** - * @param list $params - * @return list - */ - private function requireWidthsParam(array $params): array - { - $value = $params[0] ?? null; - if (!is_array($value)) { - throw new Exception('Missing fixed-width field definitions.'); - } - - $widths = []; - foreach ($value as $width) { - if (!is_int($width) || $width < 1) { - throw new Exception('Fixed-width definitions must be positive integers.'); - } - - $widths[] = $width; - } - - if ($widths === []) { - throw new Exception('At least one fixed-width field definition is required.'); - } - - return $widths; - } - /** * Resets the internal position and count. * @@ -630,7 +523,7 @@ private function resolveReadablePath(): string * file. The iteration is terminated when the end of the file is reached. * * @return Generator An iterator over the deserialized values from the file. - * @throws Exception If the data cannot be deserialized. + * @throws FileAccessException If the data cannot be deserialized. */ private function serializedIterator(): Generator { @@ -660,6 +553,24 @@ private function unlinkPathSilently(string $path): void } } + /** + * @param list $widths + * @return list + */ + private function validateWidths(array $widths): array + { + if ($widths === []) { + throw new \InvalidArgumentException('At least one fixed-width field definition is required.'); + } + foreach ($widths as $width) { + if ($width < 1) { + throw new \InvalidArgumentException('Fixed-width definitions must be positive integers.'); + } + } + + return $widths; + } + /** * Reads an XML file and yields each element with the given name. * @@ -670,13 +581,13 @@ private function unlinkPathSilently(string $path): void * * @param string $element The name of the element to yield. * @return Generator Yields each element with the given name. - * @throws Exception If the file cannot be opened or read. + * @throws FileAccessException If the file cannot be opened or read. */ private function xmlIterator(string $element): Generator { $reader = new XMLReader(); if (!$reader->open($this->localWorkingPath ?? $this->filename)) { - throw new Exception("Failed to open XML file: {$this->filename}"); + throw new FileAccessException("Failed to open XML file: {$this->filename}"); } while ($reader->read()) { diff --git a/src/FileManager/SafeFileWriter.php b/src/FileManager/SafeFileWriter.php index 541813d..6197a89 100644 --- a/src/FileManager/SafeFileWriter.php +++ b/src/FileManager/SafeFileWriter.php @@ -7,28 +7,17 @@ use Countable; use DateTime; use DateTimeInterface; -use Exception; use Infocyph\Pathwise\Exceptions\FileAccessException; +use Infocyph\Pathwise\Exceptions\MissingExtensionException; use Infocyph\Pathwise\FileManager\Concerns\SafeFileWriterWriteConcern; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; use Infocyph\Pathwise\Utils\StreamTransferHelper; use JsonSerializable; +use SimpleXMLElement; use SplFileObject; use Stringable; -/** - * @method SafeFileReader character() Character iterator - * @method SafeFileReader line() Line iterator - * @method SafeFileReader csv(string $separator = ",", string $enclosure = "\"", string $escape = "\\") CSV iterator - * @method SafeFileReader binary(int $bytes = 1024) Binary iterator - * @method SafeFileReader json() JSON line-by-line iterator - * @method SafeFileReader regex(string $pattern) Regex iterator - * @method SafeFileReader fixedWidth(array $widths) Fixed-width field iterator - * @method SafeFileReader xml(string $element) XML iterator - * @method SafeFileReader serialized() Serialized object iterator - * @method SafeFileReader jsonArray() JSON array iterator - */ class SafeFileWriter implements Countable, Stringable, JsonSerializable { use SafeFileWriterWriteConcern; @@ -79,54 +68,6 @@ public function __destruct() } } - /** - * Dynamically handles different write operations based on the specified type. - * - * This method uses a dynamic approach to invoke various write operations such as - * 'character', 'line', 'csv', 'binary', 'json', 'regex', 'fixedWidth', 'xml', - * 'serialized', and 'jsonArray'. It initializes the file for writing, acquires - * a lock, performs the specified write operation, tracks the write type, and - * finally releases the lock. - * - * @param string $type The type of write operation to perform. - * @param list $params The parameters to be passed to the specific write operation. - * @throws Exception If the specified write type is unknown. - */ - public function __call(string $type, array $params): mixed - { - $this->initiate($this->append ? 'a' : 'w'); - $returnable = match ($type) { - 'character' => $this->writeCharacter($this->requireStringParam($params, 0, $type)), - 'line' => $this->writeLine($this->requireStringParam($params, 0, $type)), - 'csv' => $this->writeCSV( - $this->requireCsvRowParam($params, 0, $type), - $this->optionalStringParam($params, 1, ','), - $this->optionalStringParam($params, 2, '"'), - $this->optionalStringParam($params, 3, '\\'), - ), - 'binary' => $this->writeBinary($this->requireStringParam($params, 0, $type)), - 'json' => $this->writeJSON($params[0] ?? null, $this->optionalBoolParam($params, 1, false)), - 'regex' => $this->writePatternMatch( - $this->requireStringParam($params, 0, $type), - $this->requireStringParam($params, 1, $type), - ), - 'fixedWidth' => $this->writeFixedWidth( - $this->requireFixedWidthDataParam($params, 0, $type), - $this->requireWidthsParam($params, 1, $type), - ), - 'xml' => $this->writeXML($this->requireXmlParam($params, 0, $type)), - 'serialized' => $this->writeSerialized($params[0] ?? null), - 'jsonArray' => $this->writeJSONArray( - $this->requireArrayParam($params, 0, $type), - $this->optionalBoolParam($params, 1, false), - ), - default => throw new Exception("Unknown write type '$type'"), - }; - $this->trackWriteType($type); - - return $returnable; - } - /** * Converts the SafeFileWriter object to a string representation. * @@ -341,12 +282,12 @@ public function unlock(): void * @param string $expectedChecksum The expected checksum. * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'. * @return bool True if the checksum matches, false otherwise. - * @throws Exception If the algorithm is not supported. + * @throws FileAccessException If the algorithm is not supported. */ public function verifyChecksum(string $expectedChecksum, string $algorithm = 'sha256'): bool { if (!in_array($algorithm, hash_algos(), true)) { - throw new Exception("Unsupported checksum algorithm: {$algorithm}"); + throw new FileAccessException("Unsupported checksum algorithm: {$algorithm}"); } $path = $this->getActiveOrFinalPath(); @@ -364,12 +305,12 @@ public function verifyChecksum(string $expectedChecksum, string $algorithm = 'sh /** * Write content and verify checksum against the persisted file. * - * @throws Exception + * @throws FileAccessException */ - public function writeAndVerify(string $content, string $algorithm = 'sha256'): bool + public function writeAndVerify(string $content, string $algorithm = 'sha256'): self { if (!in_array($algorithm, hash_algos(), true)) { - throw new Exception("Unsupported checksum algorithm: {$algorithm}"); + throw new FileAccessException("Unsupported checksum algorithm: {$algorithm}"); } $this->initiate('w'); @@ -386,11 +327,76 @@ public function writeAndVerify(string $content, string $algorithm = 'sha256'): b $fileHash = $this->isRemoteTarget() ? FlysystemHelper::checksum($this->filename, $algorithm) : hash_file($algorithm, $this->filename); - if (!is_string($fileHash)) { - return false; + if (!is_string($fileHash) || !hash_equals(hash($algorithm, $content), $fileHash)) { + throw new FileAccessException("Checksum verification failed for {$this->filename}."); + } + + return $this; + } + + public function writeBinary(string $data): int + { + return $this->performWrite('binary', fn(): int|false => $this->writeBinaryData($data)); + } + + public function writeCharacters(string $characters): int + { + $written = 0; + foreach (str_split($characters) as $character) { + $written += $this->performWrite('characters', fn(): int|false => $this->writeCharacterData($character)); } - return hash_equals(hash($algorithm, $content), $fileHash); + return $written; + } + + /** @param list $row */ + public function writeCsv(array $row, string $separator = ',', string $enclosure = '"', string $escape = '\\'): int + { + return $this->performWrite('csv', fn(): int|false => $this->writeCsvRow($row, $separator, $enclosure, $escape)); + } + + /** + * @param list $data + * @param list $widths + */ + public function writeFixedWidth(array $data, array $widths): int + { + return $this->performWrite('fixed-width', fn(): int|false => $this->writeFixedWidthData($data, $widths)); + } + + public function writeJson(mixed $data, bool $prettyPrint = false): int + { + return $this->performWrite('json', fn(): int|false => $this->writeJsonLineData($data, $prettyPrint)); + } + + /** @param array $data */ + public function writeJsonArray(array $data, bool $prettyPrint = false): int + { + return $this->performWrite('json-array', fn(): int|false => $this->writeJsonArrayData($data, $prettyPrint)); + } + + public function writeLine(string $content): int + { + return $this->performWrite('line', fn(): int|false => $this->writeLineData($content)); + } + + public function writeMatchingLine(string $content, string $pattern): int + { + return $this->performWrite('matching-line', fn(): int|false => $this->writeMatchingLineData($content, $pattern)); + } + + public function writeSerialized(mixed $data): int + { + return $this->performWrite('serialized', fn(): int|false => $this->writeSerializedData($data)); + } + + public function writeXml(SimpleXMLElement $element): int + { + if (!extension_loaded('simplexml')) { + throw new MissingExtensionException('XML writing requires ext-simplexml.'); + } + + return $this->performWrite('xml', fn(): int|false => $this->writeXmlData($element)); } private function createAtomicTempFilePath(): string @@ -501,6 +507,19 @@ private function isRemoteTarget(): bool return PathHelper::hasScheme($this->filename) || (FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($this->filename)); } + /** @param callable(): (int|false) $write */ + private function performWrite(string $type, callable $write): int + { + $this->initiate($this->append ? 'a' : 'w'); + $written = $write(); + if ($written === false) { + throw new FileAccessException("Unable to perform {$type} write for {$this->filename}."); + } + $this->trackWriteType($type); + + return $written; + } + private function preloadRemoteAppendSourceIfNeeded(): void { if (!$this->append || !FlysystemHelper::fileExists($this->filename) || !is_string($this->localWorkingPath)) { diff --git a/src/Indexing/ChecksumIndexer.php b/src/Indexing/ChecksumIndexer.php index 08b7b4c..b740c5a 100644 --- a/src/Indexing/ChecksumIndexer.php +++ b/src/Indexing/ChecksumIndexer.php @@ -4,6 +4,8 @@ namespace Infocyph\Pathwise\Indexing; +use Infocyph\Pathwise\Results\DeduplicationResult; + use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\FlysystemPathResolver; use Infocyph\Pathwise\Utils\LocalFileIterator; @@ -45,10 +47,11 @@ public static function buildIndex(string $directory, string $algorithm = 'sha256 * * @param string $directory The directory to deduplicate. * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'. - * @return array{linked: list, skipped: list} Array with linked and skipped file paths. */ - public static function deduplicateWithHardLinks(string $directory, string $algorithm = 'sha256'): array - { + public static function deduplicateWithHardLinks( + string $directory, + string $algorithm = 'sha256', + ): DeduplicationResult { $duplicates = self::findDuplicates($directory, $algorithm); $linked = []; $skipped = []; @@ -90,10 +93,7 @@ public static function deduplicateWithHardLinks(string $directory, string $algor } } - return [ - 'linked' => $linked, - 'skipped' => $skipped, - ]; + return new DeduplicationResult($linked, $skipped); } /** diff --git a/src/Native/NativeOperationsAdapter.php b/src/Native/NativeOperationsAdapter.php index 4872058..c14548a 100644 --- a/src/Native/NativeOperationsAdapter.php +++ b/src/Native/NativeOperationsAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\Pathwise\Native; +use Infocyph\Pathwise\Results\NativeExecutionResult; use Infocyph\Pathwise\Utils\PathHelper; final class NativeOperationsAdapter @@ -50,10 +51,7 @@ public static function canUseNativeFileCopy(): bool return NativeCommandRunner::commandExists('cp'); } - /** - * @return array{success: bool, command: string, code: int} - */ - public static function compressToZip(string $source, string $zipPath): array + public static function compressToZip(string $source, string $zipPath): NativeExecutionResult { $source = PathHelper::normalize($source); $zipPath = PathHelper::normalize($zipPath); @@ -66,11 +64,7 @@ public static function compressToZip(string $source, string $zipPath): array ); $result = NativeCommandRunner::run($command); - return [ - 'success' => $result['success'], - 'command' => $command, - 'code' => $result['code'], - ]; + return new NativeExecutionResult($result['success'], $command, $result['code'], array_values($result['output'])); } if (NativeCommandRunner::commandExists('zip')) { @@ -96,25 +90,17 @@ public static function compressToZip(string $source, string $zipPath): array $wrapped = sprintf('cd %s && %s', escapeshellarg($cwd), $command); $result = NativeCommandRunner::run($wrapped); - return [ - 'success' => $result['success'], - 'command' => $wrapped, - 'code' => $result['code'], - ]; + return new NativeExecutionResult($result['success'], $wrapped, $result['code'], array_values($result['output'])); } - return [ - 'success' => false, - 'command' => '', - 'code' => 127, - ]; + return self::unsupportedResult(); } - /** - * @return array{success: bool, command: string, code: int} - */ - public static function copyDirectory(string $source, string $destination, bool $mirror = false): array - { + public static function copyDirectory( + string $source, + string $destination, + bool $mirror = false, + ): NativeExecutionResult { $source = PathHelper::normalize($source); $destination = PathHelper::normalize($destination); @@ -152,10 +138,7 @@ public static function copyDirectory(string $source, string $destination, bool $ return self::unsupportedResult(); } - /** - * @return array{success: bool, command: string, code: int} - */ - public static function copyFile(string $source, string $destination): array + public static function copyFile(string $source, string $destination): NativeExecutionResult { return self::runDualPathOperation( $source, @@ -175,10 +158,7 @@ public static function copyFile(string $source, string $destination): array ); } - /** - * @return array{success: bool, command: string, code: int} - */ - public static function decompressZip(string $zipPath, string $destination): array + public static function decompressZip(string $zipPath, string $destination): NativeExecutionResult { return self::runDualPathOperation( $zipPath, @@ -201,13 +181,12 @@ public static function decompressZip(string $zipPath, string $destination): arra /** * @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 { + ): ?NativeExecutionResult { if (!NativeCommandRunner::commandExists($command)) { return null; } @@ -215,17 +194,17 @@ private static function runCommandIfAvailable( $builtCommand = $commandBuilder(); $result = NativeCommandRunner::run($builtCommand); - return [ - 'success' => $successResolver !== null ? (bool) $successResolver($result) : $result['success'], - 'command' => $builtCommand, - 'code' => $result['code'], - ]; + return new NativeExecutionResult( + success: $successResolver !== null ? (bool) $successResolver($result) : $result['success'], + command: $builtCommand, + exitCode: $result['code'], + output: array_values($result['output']), + ); } /** * @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, @@ -234,7 +213,7 @@ private static function runDualPathOperation( callable $windowsCommandBuilder, string $unixCommand, callable $unixCommandBuilder, - ): array { + ): NativeExecutionResult { $normalizedSourcePath = PathHelper::normalize($sourcePath); $normalizedDestinationPath = PathHelper::normalize($destinationPath); @@ -249,14 +228,13 @@ private static function runDualPathOperation( /** * @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 { + ): NativeExecutionResult { if (PHP_OS_FAMILY === 'Windows') { $windowsResult = self::runCommandIfAvailable($windowsCommand, $windowsCommandBuilder); if ($windowsResult !== null) { @@ -267,15 +245,8 @@ private static function runWindowsThenUnix( return self::runCommandIfAvailable($unixCommand, $unixCommandBuilder) ?? self::unsupportedResult(); } - /** - * @return array{success: bool, command: string, code: int} - */ - private static function unsupportedResult(): array + private static function unsupportedResult(): NativeExecutionResult { - return [ - 'success' => false, - 'command' => '', - 'code' => 127, - ]; + return new NativeExecutionResult(false, '', 127); } } diff --git a/src/Observability/AuditSink.php b/src/Observability/AuditSink.php new file mode 100644 index 0000000..c26dc27 --- /dev/null +++ b/src/Observability/AuditSink.php @@ -0,0 +1,11 @@ + $record */ + public function write(array $record): void; +} diff --git a/src/Observability/AuditTrail.php b/src/Observability/AuditTrail.php index 477352f..977ca3e 100644 --- a/src/Observability/AuditTrail.php +++ b/src/Observability/AuditTrail.php @@ -5,18 +5,14 @@ namespace Infocyph\Pathwise\Observability; use DateTimeInterface; -use Infocyph\Pathwise\Utils\FlysystemHelper; -use Infocyph\Pathwise\Utils\PathHelper; -use RuntimeException; final readonly class AuditTrail { - public function __construct(private string $logFilePath) + private AuditSink $sink; + + public function __construct(string|AuditSink $sink) { - $directory = dirname($this->logFilePath); - if (!FlysystemHelper::directoryExists($directory)) { - FlysystemHelper::createDirectory($directory); - } + $this->sink = is_string($sink) ? new LocalJsonlAuditSink($sink) : $sink; } /** @@ -24,9 +20,9 @@ public function __construct(private string $logFilePath) * * @return string The normalized log file path. */ - public function getLogFilePath(): string + public function getLogFilePath(): ?string { - return PathHelper::normalize($this->logFilePath); + return $this->sink instanceof LocalJsonlAuditSink ? $this->sink->path : null; } /** @@ -43,23 +39,6 @@ public function log(string $operation, array $context = []): void 'context' => $context, ]; - $line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL; - if ($this->isLocalLogPath()) { - $written = file_put_contents($this->logFilePath, $line, FILE_APPEND | LOCK_EX); - if ($written !== strlen($line)) { - throw new RuntimeException("Unable to append audit record to {$this->logFilePath}."); - } - - return; - } - - $existing = FlysystemHelper::fileExists($this->logFilePath) ? FlysystemHelper::read($this->logFilePath) : ''; - FlysystemHelper::write($this->logFilePath, $existing . $line); - } - - private function isLocalLogPath(): bool - { - return !PathHelper::hasScheme($this->logFilePath) - && (PathHelper::isAbsolute($this->logFilePath) || !FlysystemHelper::hasDefaultFilesystem()); + $this->sink->write($record); } } diff --git a/src/Observability/CallbackAuditSink.php b/src/Observability/CallbackAuditSink.php new file mode 100644 index 0000000..1f9482b --- /dev/null +++ b/src/Observability/CallbackAuditSink.php @@ -0,0 +1,22 @@ +): void */ + private \Closure $callback; + + /** @param callable(array): void $callback */ + public function __construct(callable $callback) + { + $this->callback = $callback(...); + } + + public function write(array $record): void + { + ($this->callback)($record); + } +} diff --git a/src/Observability/LocalJsonlAuditSink.php b/src/Observability/LocalJsonlAuditSink.php new file mode 100644 index 0000000..4e24f22 --- /dev/null +++ b/src/Observability/LocalJsonlAuditSink.php @@ -0,0 +1,39 @@ +path = PathHelper::normalize($path); + $directory = dirname($this->path); + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new RuntimeException("Unable to create audit directory: {$directory}"); + } + } + + public function write(array $record): void + { + $line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL; + $written = file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX); + if ($written !== strlen($line)) { + throw new RuntimeException("Unable to append audit record to {$this->path}."); + } + } +} diff --git a/src/Observability/PartitionedAuditSink.php b/src/Observability/PartitionedAuditSink.php new file mode 100644 index 0000000..df65bf9 --- /dev/null +++ b/src/Observability/PartitionedAuditSink.php @@ -0,0 +1,21 @@ +directory, $partition, $name); + FlysystemHelper::write($path, json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/src/PathwiseFacade.php b/src/PathwiseFacade.php index b0c4ecf..150798b 100644 --- a/src/PathwiseFacade.php +++ b/src/PathwiseFacade.php @@ -10,8 +10,13 @@ use Infocyph\Pathwise\FileManager\SafeFileReader; use Infocyph\Pathwise\FileManager\SafeFileWriter; use Infocyph\Pathwise\Indexing\ChecksumIndexer; +use Infocyph\Pathwise\Observability\AuditSink; use Infocyph\Pathwise\Observability\AuditTrail; use Infocyph\Pathwise\Queue\FileJobQueue; +use Infocyph\Pathwise\Results\DeduplicationResult; +use Infocyph\Pathwise\Results\RetentionResult; +use Infocyph\Pathwise\Results\SnapshotDiff; +use Infocyph\Pathwise\Results\WatchResult; use Infocyph\Pathwise\Retention\RetentionManager; use Infocyph\Pathwise\Security\PolicyEngine; use Infocyph\Pathwise\Storage\StorageFactory; @@ -54,12 +59,12 @@ public static function at(string $path): self /** * Create an audit trail logger. * - * @param string $logFilePath The path to the log file. + * @param string|AuditSink $sink A local JSONL path or a custom audit sink. * @return AuditTrail The audit trail instance. */ - public static function audit(string $logFilePath): AuditTrail + public static function audit(string|AuditSink $sink): AuditTrail { - return new AuditTrail($logFilePath); + return new AuditTrail($sink); } /** @@ -78,16 +83,10 @@ public static function createFilesystem(array $config): FilesystemOperator * * @param string $directory The directory to deduplicate. * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'. - * @return array{linked: list, skipped: list} Array with linked and skipped file paths. */ - public static function deduplicate(string $directory, string $algorithm = 'sha256'): array + public static function deduplicate(string $directory, string $algorithm = 'sha256'): DeduplicationResult { - $result = ChecksumIndexer::deduplicateWithHardLinks($directory, $algorithm); - - return [ - 'linked' => self::normalizeStringList($result['linked']), - 'skipped' => self::normalizeStringList($result['skipped']), - ]; + return ChecksumIndexer::deduplicateWithHardLinks($directory, $algorithm); } /** @@ -95,9 +94,8 @@ public static function deduplicate(string $directory, string $algorithm = 'sha25 * * @param SnapshotMap $previousSnapshot The previous snapshot data. * @param SnapshotMap $currentSnapshot The current snapshot data. - * @return DiffReport The diff report. */ - public static function diffSnapshots(array $previousSnapshot, array $currentSnapshot): array + public static function diffSnapshots(array $previousSnapshot, array $currentSnapshot): SnapshotDiff { return FileWatcher::diff($previousSnapshot, $currentSnapshot); } @@ -197,20 +195,14 @@ public static function queue(string $queueFilePath): FileJobQueue * @param int|null $keepLast Number of most recent files to keep (null for unlimited). * @param int|null $maxAgeDays Maximum age of files in days (null for unlimited). * @param string $sortBy Field to sort by ('mtime' or 'ctime'). - * @return array{deleted: list, kept: list} Array with deleted and kept file paths. */ public static function retain( string $directory, ?int $keepLast = null, ?int $maxAgeDays = null, string $sortBy = 'mtime', - ): array { - $result = RetentionManager::apply($directory, $keepLast, $maxAgeDays, $sortBy); - - return [ - 'deleted' => self::normalizeStringList($result['deleted']), - 'kept' => self::normalizeStringList($result['kept']), - ]; + ): RetentionResult { + return RetentionManager::apply($directory, $keepLast, $maxAgeDays, $sortBy); } /** @@ -243,7 +235,6 @@ public static function upload(): UploadProcessor * @param int $durationSeconds How long to watch in seconds. Defaults to 5. * @param int $intervalMilliseconds Polling interval in milliseconds. Defaults to 500. * @param bool $recursive Whether to watch subdirectories. Defaults to true. - * @return array Final snapshot. */ public static function watch( string $path, @@ -251,7 +242,7 @@ public static function watch( int $durationSeconds = 5, int $intervalMilliseconds = 500, bool $recursive = true, - ): array { + ): WatchResult { return FileWatcher::watch($path, $onChange, $durationSeconds, $intervalMilliseconds, $recursive); } @@ -350,27 +341,6 @@ public function writer(bool $append = false): SafeFileWriter return new SafeFileWriter($this->path, $append); } - /** - * @return list - */ - private static function normalizeStringList(mixed $values): array - { - if (!is_array($values)) { - return []; - } - - $result = []; - foreach ($values as $value) { - if (!is_string($value)) { - continue; - } - - $result[] = $value; - } - - return $result; - } - /** * @param array|null $values * @return array|null diff --git a/src/Queue/FileJobQueue.php b/src/Queue/FileJobQueue.php index 9940f37..3c37b5d 100644 --- a/src/Queue/FileJobQueue.php +++ b/src/Queue/FileJobQueue.php @@ -4,6 +4,8 @@ namespace Infocyph\Pathwise\Queue; +use Infocyph\Pathwise\Results\QueueProcessResult; + use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; use InvalidArgumentException; @@ -77,9 +79,8 @@ public function enqueue(string $type, array $payload = [], int $priority = 0): s * * @param callable(QueueJob): void $handler Callback to process each job. * @param int $maxJobs Maximum number of jobs to process (0 for unlimited). - * @return array{processed: int, failed: int} Array with processed and failed counts. */ - public function process(callable $handler, int $maxJobs = 0): array + public function process(callable $handler, int $maxJobs = 0): QueueProcessResult { if ($maxJobs < 0) { throw new InvalidArgumentException('maxJobs must be greater than or equal to zero.'); @@ -108,10 +109,7 @@ public function process(callable $handler, int $maxJobs = 0): array $this->completeJob($job); } - return [ - 'processed' => $processed, - 'failed' => $failed, - ]; + return new QueueProcessResult($processed, $failed); } /** diff --git a/src/Results/ChunkUploadState.php b/src/Results/ChunkUploadState.php new file mode 100644 index 0000000..2b7d95a --- /dev/null +++ b/src/Results/ChunkUploadState.php @@ -0,0 +1,15 @@ + $linked + * @param list $skipped + */ + public function __construct(public array $linked, public array $skipped) {} +} diff --git a/src/Results/DownloadPreparation.php b/src/Results/DownloadPreparation.php new file mode 100644 index 0000000..9066b13 --- /dev/null +++ b/src/Results/DownloadPreparation.php @@ -0,0 +1,21 @@ + $headers */ + public function __construct( + public string $path, + public string $fileName, + public string $mimeType, + public int $size, + public int $lastModified, + public string $etag, + public int $status, + public RangeDownloadMetadata $range, + public array $headers, + ) {} +} diff --git a/src/Results/DownloadStreamResult.php b/src/Results/DownloadStreamResult.php new file mode 100644 index 0000000..db14aa4 --- /dev/null +++ b/src/Results/DownloadStreamResult.php @@ -0,0 +1,10 @@ + $output */ + public function __construct( + public bool $success, + public string $command, + public int $exitCode, + public array $output = [], + ) {} +} diff --git a/src/Results/QueueProcessResult.php b/src/Results/QueueProcessResult.php new file mode 100644 index 0000000..9b5538f --- /dev/null +++ b/src/Results/QueueProcessResult.php @@ -0,0 +1,10 @@ + $deleted + * @param list $kept + */ + public function __construct(public array $deleted, public array $kept) {} +} diff --git a/src/Results/SnapshotDiff.php b/src/Results/SnapshotDiff.php new file mode 100644 index 0000000..3c6dcff --- /dev/null +++ b/src/Results/SnapshotDiff.php @@ -0,0 +1,20 @@ + $created + * @param list $modified + * @param list $deleted + */ + public function __construct(public array $created, public array $modified, public array $deleted) {} + + public function isEmpty(): bool + { + return $this->created === [] && $this->modified === [] && $this->deleted === []; + } +} diff --git a/src/Results/SyncReport.php b/src/Results/SyncReport.php new file mode 100644 index 0000000..e839365 --- /dev/null +++ b/src/Results/SyncReport.php @@ -0,0 +1,21 @@ + $created + * @param list $updated + * @param list $deleted + * @param list $unchanged + */ + public function __construct( + public array $created, + public array $updated, + public array $deleted, + public array $unchanged, + ) {} +} diff --git a/src/Results/WatchResult.php b/src/Results/WatchResult.php new file mode 100644 index 0000000..f67083d --- /dev/null +++ b/src/Results/WatchResult.php @@ -0,0 +1,11 @@ + $finalSnapshot */ + public function __construct(public array $finalSnapshot, public int $changeSets) {} +} diff --git a/src/Retention/RetentionManager.php b/src/Retention/RetentionManager.php index bf6b1ca..029d212 100644 --- a/src/Retention/RetentionManager.php +++ b/src/Retention/RetentionManager.php @@ -4,6 +4,8 @@ namespace Infocyph\Pathwise\Retention; +use Infocyph\Pathwise\Results\RetentionResult; + use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\FlysystemPathResolver; use Infocyph\Pathwise\Utils\LocalFileIterator; @@ -19,19 +21,18 @@ final class RetentionManager * @param int|null $keepLast Number of most recent files to keep (null for unlimited). * @param int|null $maxAgeDays Maximum age of files in days (null for unlimited). * @param string $sortBy Field to sort by ('mtime' or 'ctime'). - * @return array{deleted: list, kept: list} Array with deleted and kept file paths. */ public static function apply( string $directory, ?int $keepLast = null, ?int $maxAgeDays = null, string $sortBy = 'mtime', - ): array { + ): RetentionResult { self::validateOptions($keepLast, $maxAgeDays, $sortBy); $directory = PathHelper::normalize($directory); if (!FlysystemHelper::directoryExists($directory)) { - return ['deleted' => [], 'kept' => []]; + return new RetentionResult([], []); } $files = self::collectFiles($directory); @@ -54,10 +55,7 @@ public static function apply( } } - return [ - 'deleted' => $deleted, - 'kept' => $kept, - ]; + return new RetentionResult($deleted, $kept); } /** diff --git a/src/Security/ZipEntryValidator.php b/src/Security/ZipEntryValidator.php new file mode 100644 index 0000000..92f414c --- /dev/null +++ b/src/Security/ZipEntryValidator.php @@ -0,0 +1,115 @@ + + */ + public static function validateArchive(ZipArchive $archive, string $extractionRoot): array + { + $entries = []; + + for ($index = 0; $index < $archive->numFiles; $index++) { + $entry = $archive->getNameIndex($index); + if (!is_string($entry)) { + throw new UnsafeArchiveEntryException("Unable to read ZIP entry at index {$index}."); + } + + $entries[$index] = self::validate($entry, $extractionRoot); + self::assertNotSymbolicLink($archive, $index, $entry); + } + + return $entries; + } + + /** @param list $segments */ + private static function assertNoSymbolicLinkInDestination(string $root, array $segments, string $entry): void + { + if (str_contains($root, '://')) { + return; + } + + $candidate = $root; + if (is_link($candidate)) { + throw new UnsafeArchiveEntryException("Extraction root is a symbolic link for ZIP entry: {$entry}"); + } + + foreach ($segments as $segment) { + $candidate .= DIRECTORY_SEPARATOR . $segment; + if (is_link($candidate)) { + throw new UnsafeArchiveEntryException("ZIP destination traverses a symbolic link: {$entry}"); + } + } + } + + private static function assertNotSymbolicLink(ZipArchive $archive, int $index, string $entry): void + { + $attributes = 0; + $operationsSystem = 0; + if (!$archive->getExternalAttributesIndex($index, $operationsSystem, $attributes)) { + return; + } + if (!is_int($attributes)) { + throw new UnsafeArchiveEntryException("Unable to validate ZIP entry attributes: {$entry}"); + } + + $mode = ($attributes >> 16) & self::UNIX_FILE_TYPE_MASK; + if ($mode === self::UNIX_SYMBOLIC_LINK) { + throw new UnsafeArchiveEntryException("Symbolic-link ZIP entry detected: {$entry}"); + } + } +} diff --git a/src/StreamHandler/DownloadProcessor.php b/src/StreamHandler/DownloadProcessor.php index 45b153f..0e97a38 100644 --- a/src/StreamHandler/DownloadProcessor.php +++ b/src/StreamHandler/DownloadProcessor.php @@ -7,6 +7,9 @@ use Infocyph\Pathwise\Exceptions\DownloadException; use Infocyph\Pathwise\Exceptions\FileNotFoundException; use Infocyph\Pathwise\Exceptions\FileSizeExceededException; +use Infocyph\Pathwise\Results\DownloadPreparation; +use Infocyph\Pathwise\Results\DownloadStreamResult; +use Infocyph\Pathwise\Results\RangeDownloadMetadata; use Infocyph\Pathwise\Utils\ExtensionPolicy; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\MetadataHelper; @@ -41,25 +44,15 @@ class DownloadProcessor * @param string $path The file path to download. * @param string|null $downloadName The desired download filename (null to use original). * @param string|null $rangeHeader The HTTP Range header value (null for no range). - * @return array{ - * path: string, - * fileName: string, - * mimeType: string, - * size: int, - * lastModified: int, - * etag: string, - * status: int, - * rangeStart: int, - * rangeEnd: int, - * contentLength: int, - * headers: array - * } The download metadata array. * @throws DownloadException If the file is hidden and blocked. * @throws FileNotFoundException If the file doesn't exist. * @throws FileSizeExceededException If the file exceeds max download size. */ - public function prepareDownload(string $path, ?string $downloadName = null, ?string $rangeHeader = null): array - { + public function prepareDownload( + string $path, + ?string $downloadName = null, + ?string $rangeHeader = null, + ): DownloadPreparation { $normalizedPath = PathHelper::normalize($path); $this->validateDownloadPath($normalizedPath); @@ -95,19 +88,17 @@ public function prepareDownload(string $path, ?string $downloadName = null, ?str $headers['Content-Range'] = sprintf('bytes %d-%d/%d', $rangeStart, $rangeEnd, $size); } - return [ - 'path' => $normalizedPath, - 'fileName' => $resolvedFileName, - 'mimeType' => $mimeType, - 'size' => $size, - 'lastModified' => $lastModified, - 'etag' => $etag, - 'status' => $isPartial ? 206 : 200, - 'rangeStart' => $rangeStart, - 'rangeEnd' => $rangeEnd, - 'contentLength' => $contentLength, - 'headers' => $headers, - ]; + return new DownloadPreparation( + path: $normalizedPath, + fileName: $resolvedFileName, + mimeType: $mimeType, + size: $size, + lastModified: $lastModified, + etag: $etag, + status: $isPartial ? 206 : 200, + range: new RangeDownloadMetadata($rangeStart, $rangeEnd, $contentLength, $isPartial), + headers: $headers, + ); } /** @@ -210,20 +201,6 @@ public function setRangeRequestsEnabled(bool $enabled = true): void * @param mixed $outputStream The output stream resource to write to. * @param string|null $downloadName The desired download filename (null to use original). * @param string|null $rangeHeader The HTTP Range header value (null for no range). - * @return array{ - * path: string, - * fileName: string, - * mimeType: string, - * size: int, - * lastModified: int, - * etag: string, - * status: int, - * rangeStart: int, - * rangeEnd: int, - * contentLength: int, - * bytesSent: int, - * headers: array - * } The download manifest with bytes sent. * @throws DownloadException If the output stream is invalid or download fails. */ public function streamDownload( @@ -231,22 +208,22 @@ public function streamDownload( mixed $outputStream, ?string $downloadName = null, ?string $rangeHeader = null, - ): array { + ): DownloadStreamResult { if (!is_resource($outputStream)) { throw new DownloadException('Invalid output stream.'); } $manifest = $this->prepareDownload($path, $downloadName, $rangeHeader); - $inputStream = FlysystemHelper::readStream($manifest['path']); + $inputStream = FlysystemHelper::readStream($manifest->path); if (!is_resource($inputStream)) { throw new DownloadException('Unable to open input stream for download.'); } try { - $this->seekStreamToOffset($inputStream, $manifest['rangeStart']); + $this->seekStreamToOffset($inputStream, $manifest->range->start); - $remaining = $manifest['contentLength']; + $remaining = $manifest->range->contentLength; $bytesSent = 0; while ($remaining > 0) { $chunk = fread($inputStream, $this->readLength($remaining)); @@ -262,13 +239,11 @@ public function streamDownload( fclose($inputStream); } - if ($bytesSent !== $manifest['contentLength']) { + if ($bytesSent !== $manifest->range->contentLength) { throw new DownloadException('Incomplete download stream copy.'); } - $manifest['bytesSent'] = $bytesSent; - - return $manifest; + return new DownloadStreamResult($manifest, $bytesSent); } private function buildContentDisposition(string $disposition, string $fileName): string diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 1b31415..b142940 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -5,6 +5,8 @@ namespace Infocyph\Pathwise\StreamHandler; use Infocyph\Pathwise\Exceptions\UploadException; + +use Infocyph\Pathwise\Results\ChunkUploadState; use Infocyph\Pathwise\StreamHandler\Concerns\UploadProcessorChunkConcern; use Infocyph\Pathwise\StreamHandler\Concerns\UploadProcessorValidationConcern; use Infocyph\Pathwise\Utils\FlysystemHelper; @@ -183,11 +185,15 @@ public function getValidationProfiles(): array * @param int $chunkIndex The index of this chunk (0-based). * @param int $totalChunks Total number of chunks expected. * @param string $originalFilename The original filename. - * @return array{uploadId: string, receivedChunks: int, totalChunks: int, isComplete: bool} Chunk upload status. * @throws UploadException If the upload directory is not set. */ - public function processChunkUpload(array $chunkFile, string $uploadId, int $chunkIndex, int $totalChunks, string $originalFilename): array - { + public function processChunkUpload( + array $chunkFile, + string $uploadId, + int $chunkIndex, + int $totalChunks, + string $originalFilename, + ): ChunkUploadState { if (!isset($this->uploadDir) || $this->uploadDir === '') { throw new UploadException('Upload directory is not set.'); } @@ -217,12 +223,12 @@ public function processChunkUpload(array $chunkFile, string $uploadId, int $chun ksort($manifest['received']); $this->saveChunkManifest($uploadId, $manifest); - return [ - 'uploadId' => $uploadId, - 'receivedChunks' => count($manifest['received']), - 'totalChunks' => $totalChunks, - 'isComplete' => count($manifest['received']) === $totalChunks, - ]; + return new ChunkUploadState( + uploadId: $uploadId, + receivedChunks: count($manifest['received']), + totalChunks: $totalChunks, + complete: count($manifest['received']) === $totalChunks, + ); } /** diff --git a/src/Utils/FileWatcher.php b/src/Utils/FileWatcher.php index ca814cc..22ab015 100644 --- a/src/Utils/FileWatcher.php +++ b/src/Utils/FileWatcher.php @@ -5,6 +5,9 @@ namespace Infocyph\Pathwise\Utils; use FilesystemIterator; +use Infocyph\Pathwise\Results\SnapshotDiff; + +use Infocyph\Pathwise\Results\WatchResult; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -20,9 +23,8 @@ final class FileWatcher * * @param SnapshotMap $previousSnapshot The previous snapshot data. * @param SnapshotMap $currentSnapshot The current snapshot data. - * @return DiffReport The diff report with created, modified, and deleted files. */ - public static function diff(array $previousSnapshot, array $currentSnapshot): array + public static function diff(array $previousSnapshot, array $currentSnapshot): SnapshotDiff { $created = []; $modified = []; @@ -47,11 +49,7 @@ public static function diff(array $previousSnapshot, array $currentSnapshot): ar } } - return [ - 'created' => $created, - 'modified' => $modified, - 'deleted' => $deleted, - ]; + return new SnapshotDiff($created, $modified, $deleted); } /** @@ -127,7 +125,6 @@ public static function snapshot(string $path, bool $recursive = true): array * @param int $durationSeconds How long to watch in seconds. Defaults to 5. * @param int $intervalMilliseconds Polling interval in milliseconds. Defaults to 500. * @param bool $recursive Whether to watch subdirectories. Defaults to true. - * @return SnapshotMap Final snapshot. */ public static function watch( string $path, @@ -135,23 +132,25 @@ public static function watch( int $durationSeconds = 5, int $intervalMilliseconds = 500, bool $recursive = true, - ): array { + ): WatchResult { $snapshot = self::snapshot($path, $recursive); $endAt = microtime(true) + max(1, $durationSeconds); + $changeSets = 0; while (microtime(true) < $endAt) { usleep(max(10, $intervalMilliseconds) * 1000); $current = self::snapshot($path, $recursive); $diff = self::diff($snapshot, $current); - if ($diff['created'] !== [] || $diff['modified'] !== [] || $diff['deleted'] !== []) { + if (!$diff->isEmpty()) { $onChange($diff); + $changeSets++; } $snapshot = $current; } - return $snapshot; + return new WatchResult($snapshot, $changeSets); } /** diff --git a/src/Utils/FlysystemHelper.php b/src/Utils/FlysystemHelper.php index 15d39c0..d2746e4 100644 --- a/src/Utils/FlysystemHelper.php +++ b/src/Utils/FlysystemHelper.php @@ -231,6 +231,17 @@ public static function hasDefaultFilesystem(): bool return self::$defaultFilesystem !== null; } + /** + * Determine whether a path is handled directly by the local filesystem. + * + * Relative paths are adapter-backed when a default filesystem is configured. + */ + public static function isLocalPath(string $path): bool + { + return !PathHelper::hasScheme($path) + && (PathHelper::isAbsolute($path) || self::$defaultFilesystem === null); + } + /** * Get the last modified timestamp of a file. * diff --git a/src/Utils/MetadataHelper.php b/src/Utils/MetadataHelper.php index 365f781..e8bf9c3 100644 --- a/src/Utils/MetadataHelper.php +++ b/src/Utils/MetadataHelper.php @@ -246,11 +246,8 @@ public static function getMimeType(string $path): ?string /** * Retrieves the owner and group of the given path. * - * This method returns an array with keys 'owner' and 'group', each containing the - * username or groupname of the owner or group of the file or directory, - * respectively. If the file or directory does not exist, or if ownership - * functions are not supported on the current system, this method returns - * null. + * Returns resolved owner and group names when the platform exposes them. + * Missing paths or unavailable ownership metadata produce null. * * @param string $path The path to the file or directory to retrieve * ownership for. diff --git a/src/Utils/PermissionsHelper.php b/src/Utils/PermissionsHelper.php index 9dffcbc..6a155de 100644 --- a/src/Utils/PermissionsHelper.php +++ b/src/Utils/PermissionsHelper.php @@ -4,6 +4,7 @@ namespace Infocyph\Pathwise\Utils; +use Infocyph\Pathwise\Exceptions\MissingExtensionException; use RuntimeException; class PermissionsHelper @@ -115,11 +116,8 @@ public static function getHumanReadablePermissions(string $path): ?string /** * Retrieves the owner and group of the given path. * - * Returns an array with keys 'owner' and 'group', each containing the - * username or groupname of the owner or group of the file or directory, - * respectively. If the file or directory does not exist, or if ownership - * functions are not supported on the current system, this method returns - * null. + * Returns resolved owner and group names for an existing local path. + * Missing paths produce null; unavailable POSIX support throws explicitly. * * @param string $path The path to the file or directory to retrieve * ownership for. @@ -130,7 +128,7 @@ public static function getHumanReadablePermissions(string $path): ?string public static function getOwnership(string $path): ?array { if (!self::isPosixSupported()) { - throw new RuntimeException('Ownership functions are only supported on Unix-based systems.'); + throw new MissingExtensionException('Ownership operations require ext-posix.'); } if (!file_exists($path)) { @@ -206,7 +204,7 @@ public static function isOwnedByCurrentUser(string $path): bool public static function setOwnership(string $path, string $owner, ?string $group = null): self { if (!self::isPosixSupported()) { - throw new RuntimeException('Ownership functions are only supported on Unix-based systems.'); + throw new MissingExtensionException('Ownership operations require ext-posix.'); } $result = chown($path, $owner); @@ -241,18 +239,6 @@ public static function setPermissions(string $path, int $permissions): self return new self(); } - /** - * Determines if the current system supports POSIX-style ownership - * functions. - * - * This method checks if the 'posix_getpwuid' and 'posix_getgrgid' functions - * are available. If they are, it returns true, indicating that - * POSIX-style ownership functions are supported on the current system. If - * they are not available, it returns false. - * - * @return bool True if POSIX-style ownership functions are supported, - * false otherwise. - */ private static function isPosixSupported(): bool { return function_exists('posix_getpwuid') && function_exists('posix_getgrgid'); diff --git a/src/functions.php b/src/functions.php deleted file mode 100644 index 19d54e8..0000000 --- a/src/functions.php +++ /dev/null @@ -1,232 +0,0 @@ - 0 ? (int) floor(log($sizeInBytes, 1024)) : 0; - $power = min($power, count($units) - 1); - - return number_format($sizeInBytes / (1024 ** $power), 2) . ' ' . $units[$power]; - } -} - -if (!function_exists('isDirectoryEmpty')) { - /** - * Check if a directory is empty. - * - * @param string $directoryPath The directory path. - * @return bool True if empty, false otherwise. - */ - function isDirectoryEmpty(string $directoryPath): bool - { - $isLocalDirectory = !PathHelper::hasScheme($directoryPath) && is_dir($directoryPath); - if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { - throw new InvalidArgumentException('The provided path is not a directory.'); - } - foreach (FlysystemHelper::listContentsListing($directoryPath, false) as $_item) { - return false; - } - - return true; - } -} - -if (!function_exists('deleteDirectory')) { - /** - * Delete a directory and its contents recursively. - * - * @param string $directoryPath The directory path. - * @return bool True if successful, false otherwise. - */ - function deleteDirectory(string $directoryPath): bool - { - return PathHelper::deleteDirectory($directoryPath); - } -} - -if (!function_exists('getDirectorySize')) { - /** - * Get the size of a directory recursively. - * - * @param string $directoryPath The directory path. - * @return int The total size of the directory in bytes. - */ - function getDirectorySize(string $directoryPath): int - { - $isLocalDirectory = !PathHelper::hasScheme($directoryPath) && is_dir($directoryPath); - if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { - throw new InvalidArgumentException('The provided path is not a directory.'); - } - - $size = 0; - foreach (FlysystemHelper::listContentsListing($directoryPath, true) as $item) { - if (!$item->isFile()) { - continue; - } - - if ($item instanceof FileAttributes && is_int($item->fileSize())) { - $size += $item->fileSize(); - - continue; - } - - $extra = $item->extraMetadata(); - $extraSize = $extra['file_size'] ?? $extra['filesize'] ?? 0; - if (is_int($extraSize)) { - $size += $extraSize; - } elseif (is_numeric($extraSize)) { - $size += (int) $extraSize; - } - } - - return $size; - } -} - -if (!function_exists('createDirectory')) { - /** - * Create a directory if it doesn't exist. - * - * @param string $directoryPath The directory path. - * @param int $permissions Permissions for the directory (default 0755). - * @return bool True if successful, false otherwise. - */ - function createDirectory(string $directoryPath, int $permissions = 0755): bool - { - $isLocalDirectory = !PathHelper::hasScheme($directoryPath) && is_dir($directoryPath); - if ($isLocalDirectory || FlysystemHelper::directoryExists($directoryPath)) { - return true; - } - - FlysystemHelper::createDirectory($directoryPath); - if (!PathHelper::hasScheme($directoryPath)) { - set_error_handler(static fn(): bool => true); - - try { - chmod($directoryPath, $permissions); - } finally { - restore_error_handler(); - } - } - - return true; - } -} - -if (!function_exists('listFiles')) { - /** - * List all files in a directory. - * - * @param string $directoryPath The directory path. - * @return list List of files (excluding directories). - */ - function listFiles(string $directoryPath): array - { - $isLocalDirectory = !PathHelper::hasScheme($directoryPath) && is_dir($directoryPath); - if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { - throw new InvalidArgumentException('The provided path is not a directory.'); - } - $files = []; - - foreach (FlysystemHelper::listContentsListing($directoryPath, false) as $item) { - if (!$item->isFile()) { - continue; - } - - $path = $item->path(); - if ($path === '') { - continue; - } - - $files[] = basename($path); - } - - return $files; - } -} - -if (!function_exists('copyDirectory')) { - /** - * Copy a directory and its contents recursively. - * - * @param string $source The source directory. - * @param string $destination The destination directory. - * @return bool True if successful, false otherwise. - */ - function copyDirectory(string $source, string $destination): bool - { - $isLocalSource = !PathHelper::hasScheme($source) && is_dir($source); - if (!$isLocalSource && !FlysystemHelper::directoryExists($source)) { - return false; - } - - FlysystemHelper::copyDirectory($source, $destination); - - return true; - } -} - -if (!function_exists('createFilesystem')) { - /** - * Build a Flysystem filesystem from configuration. - * - * Supported inputs: - * - ['driver' => 'local', 'root' => '/path'] - * - ['filesystem' => $filesystemOperator] - * - ['adapter' => $flysystemAdapter] - * - ['driver' => 'custom', ...] after StorageFactory::registerDriver() - * - * @param array $config The filesystem configuration. - * @return FilesystemOperator The created filesystem. - */ - function createFilesystem(array $config): FilesystemOperator - { - return StorageFactory::createFilesystem($config); - } -} - -if (!function_exists('mountStorage')) { - /** - * Build and mount a filesystem under a scheme name. - * - * @param string $name The mount name. - * @param array $config The filesystem configuration. - * @return FilesystemOperator The created filesystem. - */ - function mountStorage(string $name, array $config): FilesystemOperator - { - return StorageFactory::mount($name, $config); - } -} - -if (!function_exists('mountStorages')) { - /** - * Build and mount multiple filesystems. - * - * @param array> $mounts Array of mount name => config pairs. - */ - function mountStorages(array $mounts): void - { - StorageFactory::mountMany($mounts); - } -} diff --git a/tests/Feature/ArchiveSecurityTest.php b/tests/Feature/ArchiveSecurityTest.php new file mode 100644 index 0000000..e62b558 --- /dev/null +++ b/tests/Feature/ArchiveSecurityTest.php @@ -0,0 +1,108 @@ +securityRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_zip_security_', true); + $this->archivePath = $this->securityRoot . DIRECTORY_SEPARATOR . 'archive.zip'; + $this->extractPath = $this->securityRoot . DIRECTORY_SEPARATOR . 'extract'; + mkdir($this->securityRoot, 0755, true); + mkdir($this->extractPath, 0755, true); + + $this->writeArchive = function (string $entry, string $contents = 'blocked'): void { + $zip = new ZipArchive(); + expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue(); + $zip->addFromString($entry, $contents); + $zip->close(); + }; +}); + +afterEach(function () { + if (!is_dir($this->securityRoot)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->securityRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $item) { + if ($item->isLink() || $item->isFile()) { + unlink($item->getPathname()); + } else { + rmdir($item->getPathname()); + } + } + rmdir($this->securityRoot); +}); + +test('all extraction APIs reject traversal archive entries', function () { + ($this->writeArchive)('../outside.txt'); + + expect(fn () => (new FileCompression($this->archivePath))->decompress($this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class) + ->and(fn () => (new FileCompression($this->archivePath))->batchExtractFiles( + ['../outside.txt' => 'selected.txt'], + $this->extractPath, + ))->toThrow(UnsafeArchiveEntryException::class) + ->and(fn () => (new DirectoryOperations($this->extractPath))->unzip($this->archivePath)) + ->toThrow(UnsafeArchiveEntryException::class) + ->and(file_exists($this->securityRoot . DIRECTORY_SEPARATOR . 'outside.txt'))->toBeFalse(); +}); + +test('archive validation rejects absolute and Windows drive paths', function (string $entry) { + ($this->writeArchive)($entry); + + expect(fn () => (new FileCompression($this->archivePath))->decompress($this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class); +})->with(['/absolute.txt', 'C:/windows.txt', 'C:drive-relative.txt', '\\\\server\\share.txt']); + +test('entry validation rejects null bytes', function () { + expect(fn () => ZipEntryValidator::validate("safe\0evil.txt", $this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class, 'Unsafe ZIP entry path'); +}); + +test('archive validation rejects symbolic link entries', function () { + $zip = new ZipArchive(); + expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue(); + $zip->addFromString('unsafe-link', '../outside.txt'); + $zip->setExternalAttributesName('unsafe-link', ZipArchive::OPSYS_UNIX, 0120777 << 16); + $zip->close(); + + expect(fn () => (new DirectoryOperations($this->extractPath))->unzip($this->archivePath)) + ->toThrow(UnsafeArchiveEntryException::class, 'Symbolic-link ZIP entry'); +}); + +test('archive validation rejects extraction through an existing destination symlink', function () { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Symbolic-link creation is not consistently available on Windows CI.'); + } + + $outside = $this->securityRoot . DIRECTORY_SEPARATOR . 'outside'; + mkdir($outside, 0755, true); + symlink($outside, $this->extractPath . DIRECTORY_SEPARATOR . 'linked'); + ($this->writeArchive)('linked/escape.txt'); + + expect(fn () => (new FileCompression($this->archivePath))->decompress($this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class, 'symbolic link') + ->and(file_exists($outside . DIRECTORY_SEPARATOR . 'escape.txt'))->toBeFalse(); +}); + +test('batch extraction validates unselected entries before writing selected files', function () { + $zip = new ZipArchive(); + expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue(); + $zip->addFromString('safe.txt', 'safe'); + $zip->addFromString('../unsafe.txt', 'unsafe'); + $zip->close(); + + expect(fn () => (new FileCompression($this->archivePath))->batchExtractFiles( + ['safe.txt' => 'safe.txt'], + $this->extractPath, + ))->toThrow(UnsafeArchiveEntryException::class) + ->and(file_exists($this->extractPath . DIRECTORY_SEPARATOR . 'safe.txt'))->toBeFalse(); +}); diff --git a/tests/Feature/AuditTrailTest.php b/tests/Feature/AuditTrailTest.php index 244d26b..f2c8b1e 100644 --- a/tests/Feature/AuditTrailTest.php +++ b/tests/Feature/AuditTrailTest.php @@ -3,6 +3,12 @@ declare(strict_types=1); use Infocyph\Pathwise\Observability\AuditTrail; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; +use Infocyph\Pathwise\Observability\CallbackAuditSink; +use Infocyph\Pathwise\Observability\PartitionedAuditSink; +use Infocyph\Pathwise\Utils\FlysystemHelper; +use League\Flysystem\Filesystem; +use League\Flysystem\Local\LocalFilesystemAdapter; test('it writes JSON lines audit records', function () { $logFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('audit_', true) . '.jsonl'; @@ -43,3 +49,40 @@ } } }); + +test('it rejects append-based JSONL auditing on mounted storage', function () { + expect(fn () => new AuditTrail('audit-remote://events.jsonl')) + ->toThrow(UnsupportedStorageOperationException::class, 'requires a local path'); +}); + +test('it sends records to callback sinks', function () { + $records = []; + $audit = new AuditTrail(new CallbackAuditSink(function (array $record) use (&$records): void { + $records[] = $record; + })); + + $audit->log('copy', ['source' => 'a', 'destination' => 'b']); + + expect($audit->getLogFilePath())->toBeNull() + ->and($records)->toHaveCount(1) + ->and($records[0]['operation'])->toBe('copy'); +}); + +test('it writes one immutable object per event to mounted storage', function () { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_audit_partition_', true); + mkdir($root, 0755, true); + FlysystemHelper::mount('audit-partition', new Filesystem(new LocalFilesystemAdapter($root))); + + try { + $audit = new AuditTrail(new PartitionedAuditSink('audit-partition://events')); + $audit->log('create', ['path' => 'one']); + $audit->log('delete', ['path' => 'two']); + $objects = iterator_to_array(FlysystemHelper::listContentsListing('audit-partition://events', true), false); + $files = array_values(array_filter($objects, static fn ($entry): bool => $entry->isFile())); + + expect($files)->toHaveCount(2); + } finally { + FlysystemHelper::unmount('audit-partition'); + (new Infocyph\Pathwise\DirectoryManager\DirectoryOperations($root))->delete(true); + } +}); diff --git a/tests/Feature/ChecksumIndexerTest.php b/tests/Feature/ChecksumIndexerTest.php index c3d746c..9f43174 100644 --- a/tests/Feature/ChecksumIndexerTest.php +++ b/tests/Feature/ChecksumIndexerTest.php @@ -100,6 +100,6 @@ $report = ChecksumIndexer::deduplicateWithHardLinks('chk://'); expect(count($duplicates))->toBe(1) - ->and($report['linked'])->toBe([]) - ->and(count($report['skipped']))->toBeGreaterThan(0); + ->and($report->linked)->toBe([]) + ->and(count($report->skipped))->toBeGreaterThan(0); }); diff --git a/tests/Feature/DirectoryOperationsFlysystemTest.php b/tests/Feature/DirectoryOperationsFlysystemTest.php index 8b22bbd..ce5e111 100644 --- a/tests/Feature/DirectoryOperationsFlysystemTest.php +++ b/tests/Feature/DirectoryOperationsFlysystemTest.php @@ -3,6 +3,9 @@ declare(strict_types=1); use Infocyph\Pathwise\DirectoryManager\DirectoryOperations; +use Infocyph\Pathwise\Core\SyncComparison; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; +use Infocyph\Pathwise\FileManager\FileOperations; use Infocyph\Pathwise\Utils\FlysystemHelper; use League\Flysystem\Filesystem; use League\Flysystem\Local\LocalFilesystemAdapter; @@ -31,7 +34,7 @@ $ops = new DirectoryOperations('mnt://source'); expect($ops->size())->toBe(strlen('hello') + strlen('world')) - ->and($ops->copy('mnt://copied'))->toBeTrue() + ->and($ops->copy('mnt://copied'))->toBe($ops) ->and(FlysystemHelper::fileExists('mnt://copied/file.txt'))->toBeTrue() ->and(FlysystemHelper::fileExists('mnt://copied/nested/inside.txt'))->toBeTrue(); }); @@ -45,8 +48,53 @@ $sourceOps = new DirectoryOperations('mnt://zip-src'); $destinationOps = new DirectoryOperations('mnt://zip-dst'); - expect($sourceOps->zip('mnt://archives/archive.zip'))->toBeTrue() - ->and($destinationOps->unzip('mnt://archives/archive.zip'))->toBeTrue() + expect($sourceOps->zip('mnt://archives/archive.zip'))->toBe($sourceOps) + ->and($destinationOps->unzip('mnt://archives/archive.zip'))->toBe($destinationOps) ->and(FlysystemHelper::read('mnt://zip-dst/a.txt'))->toBe('A') ->and(FlysystemHelper::read('mnt://zip-dst/nested/b.txt'))->toBe('B'); }); + +test('storage-neutral operations work while local-only capabilities are rejected on mounts', function () { + $file = new FileOperations('mnt://neutral/file.txt'); + $file->create('first')->update('second'); + + expect($file->read())->toBe('second') + ->and($file->appendEmulated('-third'))->toBe($file) + ->and($file->read())->toBe('second-third') + ->and(fn () => $file->append('-native'))->toThrow(UnsupportedStorageOperationException::class) + ->and(fn () => $file->getMetadata())->toThrow(UnsupportedStorageOperationException::class) + ->and(fn () => $file->openWithLock())->toThrow(UnsupportedStorageOperationException::class); + + $directory = new DirectoryOperations('mnt://neutral'); + expect(fn () => $directory->getPermissions())->toThrow(UnsupportedStorageOperationException::class) + ->and(fn () => $directory->setPermissions(0755))->toThrow(UnsupportedStorageOperationException::class) + ->and(fn () => $directory->getIterator())->toThrow(UnsupportedStorageOperationException::class); +}); + +test('sync comparisons are explicit and progress totals remain unknown during lazy traversal', function () { + FlysystemHelper::createDirectory('mnt://sync-source'); + FlysystemHelper::createDirectory('mnt://sync-target'); + FlysystemHelper::write('mnt://sync-source/same-size.txt', 'AAAA'); + FlysystemHelper::write('mnt://sync-target/same-size.txt', 'BBBB'); + $totals = []; + + $sizeReport = (new DirectoryOperations('mnt://sync-source'))->syncTo( + 'mnt://sync-target', + false, + function (array $event) use (&$totals): void { + $totals[] = $event['total']; + }, + SyncComparison::SIZE, + ); + $checksumReport = (new DirectoryOperations('mnt://sync-source'))->syncTo( + 'mnt://sync-target', + false, + null, + SyncComparison::CHECKSUM, + ); + + expect($sizeReport->unchanged)->toContain('same-size.txt') + ->and($checksumReport->updated)->toContain('same-size.txt') + ->and(FlysystemHelper::read('mnt://sync-target/same-size.txt'))->toBe('AAAA') + ->and($totals)->each->toBeNull(); +}); diff --git a/tests/Feature/DirectoryOperationsTest.php b/tests/Feature/DirectoryOperationsTest.php index 64c5a5a..56d86b7 100644 --- a/tests/Feature/DirectoryOperationsTest.php +++ b/tests/Feature/DirectoryOperationsTest.php @@ -4,6 +4,8 @@ use Infocyph\Pathwise\DirectoryManager\DirectoryOperations; use Infocyph\Pathwise\Exceptions\DirectoryOperationException; +use Infocyph\Pathwise\Exceptions\UnsafeArchiveEntryException; +use Infocyph\Pathwise\Results\SyncReport; // Helper function to create a temporary directory for testing function createTempDirectory(): string @@ -29,27 +31,25 @@ function createTempDirectory(): string $newDir = $this->tempDir . DIRECTORY_SEPARATOR . uniqid('new_dir_', true); $dirOps = new DirectoryOperations($newDir); expect($dirOps->create()) - ->toBeTrue() + ->toBe($dirOps) ->and(is_dir($newDir))->toBeTrue(); }); test('create is idempotent when directory already exists', function () { expect($this->directoryOperations->create()) - ->toBeTrue() - ->and($this->directoryOperations->create())->toBeTrue() + ->toBe($this->directoryOperations) + ->and($this->directoryOperations->create())->toBe($this->directoryOperations) ->and(is_dir($this->tempDir))->toBeTrue(); }); test('can delete a directory', function () { -// $this->directoryOperations->create(); expect($this->directoryOperations->delete()) - ->toBeTrue() + ->toBe($this->directoryOperations) ->and(is_dir($this->tempDir))->toBeFalse(); }); test('can copy a directory', function () { $destDir = createTempDirectory(); -// $this->directoryOperations->create(); $fileName = uniqid('test_', true) . '.txt'; file_put_contents($this->tempDir . '/' . $fileName, 'sample content'); $this->directoryOperations->copy($destDir); @@ -84,7 +84,7 @@ function createTempDirectory(): string $result = $this->directoryOperations->move($newLocation); expect($result) - ->toBeTrue() + ->toBe($this->directoryOperations) ->and(is_dir($this->tempDir))->toBeFalse() ->and(is_dir($newLocation))->toBeTrue(); }); @@ -212,9 +212,9 @@ function createTempDirectory(): string $events[] = $event; }); - expect($report)->toHaveKeys(['created', 'updated', 'deleted', 'unchanged']) - ->and($report['created'])->toContain('sync.txt') - ->and($report['deleted'])->toContain('old.txt') + expect($report)->toBeInstanceOf(SyncReport::class) + ->and($report->created)->toContain('sync.txt') + ->and($report->deleted)->toContain('old.txt') ->and($events)->not->toBeEmpty(); unlink($destDir . '/' . 'sync.txt'); @@ -252,7 +252,7 @@ function createTempDirectory(): string $dirOps = new DirectoryOperations($unzipDir); expect(fn () => $dirOps->unzip($zipPath)) - ->toThrow(DirectoryOperationException::class, 'Unsafe ZIP entry path'); + ->toThrow(UnsafeArchiveEntryException::class, 'ZIP traversal entry'); expect(file_exists($outsidePath))->toBeFalse(); } finally { diff --git a/tests/Feature/DownloadProcessorTest.php b/tests/Feature/DownloadProcessorTest.php index 9b71024..c4609ec 100644 --- a/tests/Feature/DownloadProcessorTest.php +++ b/tests/Feature/DownloadProcessorTest.php @@ -42,10 +42,10 @@ $this->downloadProcessor->setAllowedRoots([$this->workingDir]); $manifest = $this->downloadProcessor->prepareDownload($path, 'report final.txt'); - expect($manifest['status'])->toBe(200) - ->and($manifest['contentLength'])->toBe(strlen('secure-content')) - ->and($manifest['fileName'])->toBe('report final.txt') - ->and($manifest['headers'])->toHaveKeys([ + expect($manifest->status)->toBe(200) + ->and($manifest->range->contentLength)->toBe(strlen('secure-content')) + ->and($manifest->fileName)->toBe('report final.txt') + ->and($manifest->headers)->toHaveKeys([ 'Accept-Ranges', 'Cache-Control', 'Content-Disposition', @@ -87,7 +87,7 @@ $this->downloadProcessor->setBlockHiddenFiles(false); $manifest = $this->downloadProcessor->prepareDownload($path); - expect($manifest['status'])->toBe(200); + expect($manifest->status)->toBe(200); }); test('it blocks disallowed download extensions', function () { @@ -114,11 +114,11 @@ $manifest = $this->downloadProcessor->prepareDownload($path, '..\\../evil".txt'); - expect($manifest['fileName'])->not->toContain('/') - ->and($manifest['fileName'])->not->toContain('\\') - ->and($manifest['fileName'])->not->toContain('"') - ->and($manifest['fileName'])->toEndWith('.txt') - ->and($manifest['headers']['Content-Disposition'])->toContain('filename='); + expect($manifest->fileName)->not->toContain('/') + ->and($manifest->fileName)->not->toContain('\\') + ->and($manifest->fileName)->not->toContain('"') + ->and($manifest->fileName)->toEndWith('.txt') + ->and($manifest->headers['Content-Disposition'])->toContain('filename='); }); test('it returns partial metadata for valid byte ranges', function () { @@ -127,11 +127,11 @@ $manifest = $this->downloadProcessor->prepareDownload($path, null, 'bytes=6-10'); - expect($manifest['status'])->toBe(206) - ->and($manifest['rangeStart'])->toBe(6) - ->and($manifest['rangeEnd'])->toBe(10) - ->and($manifest['contentLength'])->toBe(5) - ->and($manifest['headers']['Content-Range'])->toBe('bytes 6-10/11'); + expect($manifest->status)->toBe(206) + ->and($manifest->range->start)->toBe(6) + ->and($manifest->range->end)->toBe(10) + ->and($manifest->range->contentLength)->toBe(5) + ->and($manifest->headers['Content-Range'])->toBe('bytes 6-10/11'); }); test('it rejects invalid byte ranges', function () { @@ -149,10 +149,10 @@ $this->downloadProcessor->setRangeRequestsEnabled(false); $manifest = $this->downloadProcessor->prepareDownload($path, null, 'bytes=1-3'); - expect($manifest['status'])->toBe(200) - ->and($manifest['contentLength'])->toBe(11) - ->and($manifest['headers']['Accept-Ranges'])->toBe('none') - ->and($manifest['headers'])->not->toHaveKey('Content-Range'); + expect($manifest->status)->toBe(200) + ->and($manifest->range->contentLength)->toBe(11) + ->and($manifest->headers['Accept-Ranges'])->toBe('none') + ->and($manifest->headers)->not->toHaveKey('Content-Range'); }); test('it streams complete download content', function () { @@ -165,8 +165,8 @@ $downloaded = stream_get_contents($output); fclose($output); - expect($manifest['status'])->toBe(200) - ->and($manifest['bytesSent'])->toBe(strlen('streamed-content')) + expect($manifest->preparation->status)->toBe(200) + ->and($manifest->bytesSent)->toBe(strlen('streamed-content')) ->and($downloaded)->toBe('streamed-content'); }); @@ -180,8 +180,8 @@ $downloaded = stream_get_contents($output); fclose($output); - expect($manifest['status'])->toBe(206) - ->and($manifest['bytesSent'])->toBe(7) + expect($manifest->preparation->status)->toBe(206) + ->and($manifest->bytesSent)->toBe(7) ->and($downloaded)->toBe('content'); }); @@ -220,9 +220,9 @@ $downloaded = stream_get_contents($output); fclose($output); - expect($manifest['status'])->toBe(200) - ->and($manifest['contentLength'])->toBe(strlen('mounted-download-content')) - ->and($streamedManifest['bytesSent'])->toBe(strlen('mounted-download-content')) + expect($manifest->status)->toBe(200) + ->and($manifest->range->contentLength)->toBe(strlen('mounted-download-content')) + ->and($streamedManifest->bytesSent)->toBe(strlen('mounted-download-content')) ->and($downloaded)->toBe('mounted-download-content'); } finally { FlysystemHelper::unmount('mnt'); @@ -246,8 +246,8 @@ $downloaded = stream_get_contents($output); fclose($output); - expect($manifest['status'])->toBe(200) - ->and($manifest['bytesSent'])->toBe(strlen('default-download-content')) + expect($manifest->preparation->status)->toBe(200) + ->and($manifest->bytesSent)->toBe(strlen('default-download-content')) ->and($downloaded)->toBe('default-download-content'); } finally { FlysystemHelper::clearDefaultFilesystem(); diff --git a/tests/Feature/FileCompressionTest.php b/tests/Feature/FileCompressionTest.php index b70e2be..9a2ebd0 100644 --- a/tests/Feature/FileCompressionTest.php +++ b/tests/Feature/FileCompressionTest.php @@ -102,8 +102,11 @@ $failedDecompressor->setPassword('wrongpassword'); expect(fn () => $failedDecompressor->decompress($decompressDir)) - ->toThrow(CompressionException::class, 'Failed to extract ZIP archive'); + ->toThrow(CompressionException::class, 'Unable to extract ZIP entry'); + foreach (glob($decompressDir . DIRECTORY_SEPARATOR . '*') ?: [] as $partialFile) { + unlink($partialFile); + } rmdir($decompressDir); }); diff --git a/tests/Feature/FileFacadeTest.php b/tests/Feature/FileFacadeTest.php index 3e8fc31..1f417c9 100644 --- a/tests/Feature/FileFacadeTest.php +++ b/tests/Feature/FileFacadeTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\Pathwise\PathwiseFacade; +use Infocyph\Pathwise\DirectoryManager\DirectoryOperations; use Infocyph\Pathwise\Storage\StorageFactory; use Infocyph\Pathwise\Utils\FlysystemHelper; @@ -21,7 +22,7 @@ return; } - deleteDirectory($this->workspace); + (new DirectoryOperations($this->workspace))->delete(true); }); test('it provides path-bound file accessors', function () { @@ -31,11 +32,11 @@ $entry->file()->create("line-1\n"); $writer = $entry->writer(true); - $writer->line('line-2'); + $writer->writeLine('line-2'); $writer->close(); $content = $entry->file()->read(); - $lines = iterator_to_array($entry->reader()->line()); + $lines = iterator_to_array($entry->reader()->lines()); expect($entry->exists())->toBeTrue() ->and($entry->path())->toBe($filePath) @@ -109,10 +110,11 @@ ->and(FlysystemHelper::read('facade://data/file.txt'))->toBe('hello') ->and($stats['pending'])->toBe(1) ->and(FlysystemHelper::fileExists($auditFile))->toBeTrue() - ->and($diff['modified'])->toContain($watchPath) + ->and($diff->modified)->toContain($watchPath) ->and($index)->not->toBeEmpty() ->and($duplicates)->not->toBeEmpty() - ->and($retention)->toBe(['deleted' => [], 'kept' => []]); + ->and($retention->deleted)->toBe([]) + ->and($retention->kept)->toBe([]); FlysystemHelper::unmount('facade'); }); diff --git a/tests/Feature/FileJobQueueTest.php b/tests/Feature/FileJobQueueTest.php index 92ce9c8..00c51ba 100644 --- a/tests/Feature/FileJobQueueTest.php +++ b/tests/Feature/FileJobQueueTest.php @@ -25,8 +25,8 @@ $order[] = $job['type']; }); - expect($result['processed'])->toBe(2) - ->and($result['failed'])->toBe(0) + expect($result->processed)->toBe(2) + ->and($result->failed)->toBe(0) ->and($order)->toBe(['high', 'low']); }); @@ -39,8 +39,8 @@ }); $stats = $queue->stats(); - expect($result['processed'])->toBe(0) - ->and($result['failed'])->toBe(1) + expect($result->processed)->toBe(0) + ->and($result->failed)->toBe(1) ->and($stats['failed'])->toBe(1); }); @@ -54,7 +54,8 @@ }, 1); $stats = $queue->stats(); - expect($result)->toBe(['processed' => 0, 'failed' => 1]) + expect($result->processed)->toBe(0) + ->and($result->failed)->toBe(1) ->and($stats)->toMatchArray(['pending' => 1, 'processing' => 0, 'failed' => 1]); }); diff --git a/tests/Feature/FileOperationsAdvancedTest.php b/tests/Feature/FileOperationsAdvancedTest.php index 9284258..36ba424 100644 --- a/tests/Feature/FileOperationsAdvancedTest.php +++ b/tests/Feature/FileOperationsAdvancedTest.php @@ -3,9 +3,14 @@ declare(strict_types=1); use Infocyph\Pathwise\Exceptions\PolicyViolationException; +use Infocyph\Pathwise\Exceptions\TransactionStateException; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\FileManager\FileOperations; use Infocyph\Pathwise\Observability\AuditTrail; use Infocyph\Pathwise\Security\PolicyEngine; +use Infocyph\Pathwise\Utils\FlysystemHelper; +use League\Flysystem\Filesystem; +use League\Flysystem\Local\LocalFilesystemAdapter; beforeEach(function () { $this->filePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('file_ops_adv_', true) . '.txt'; @@ -53,3 +58,78 @@ expect(file_get_contents($this->filePath))->toBe('original'); }); + +test('it removes files created or appended from a missing state during rollback', function (string $operation) { + try { + $this->fileOperations->transaction(function (FileOperations $ops) use ($operation): void { + $operation === 'create' ? $ops->create('new') : $ops->append('new'); + throw new RuntimeException('force rollback'); + }); + } catch (RuntimeException) { + } + + expect(file_exists($this->filePath))->toBeFalse(); +})->with(['create', 'append']); + +test('it restores the original object path and both files after rename rollback', function () { + $renamedPath = $this->filePath . '.renamed'; + $this->fileOperations->create('original'); + + try { + $this->fileOperations->transaction(function (FileOperations $ops) use ($renamedPath): void { + $ops->rename($renamedPath)->update('changed'); + throw new RuntimeException('force rollback'); + }); + } catch (RuntimeException) { + } + + expect($this->fileOperations->read())->toBe('original') + ->and(file_exists($renamedPath))->toBeFalse(); +}); + +test('it restores overwritten copy destinations and deleted large files', function () { + $destination = $this->filePath . '.copy'; + $large = str_repeat('0123456789abcdef', 128 * 1024); + $this->fileOperations->create($large); + file_put_contents($destination, 'destination-before'); + + try { + $this->fileOperations->transaction(function (FileOperations $ops) use ($destination): void { + $ops->copy($destination)->delete(); + throw new RuntimeException('force rollback'); + }); + } catch (RuntimeException) { + } + + expect(file_get_contents($this->filePath))->toBe($large) + ->and(file_get_contents($destination))->toBe('destination-before'); + + unlink($destination); +}); + +test('it rejects nested and invalid transaction states', function () { + expect(fn () => $this->fileOperations->commitTransaction()) + ->toThrow(TransactionStateException::class, 'without an active transaction'); + + $this->fileOperations->beginTransaction(); + expect(fn () => $this->fileOperations->beginTransaction()) + ->toThrow(TransactionStateException::class, 'Nested transactions'); + $this->fileOperations->rollbackTransaction(); + + expect(fn () => $this->fileOperations->rollbackTransaction()) + ->toThrow(TransactionStateException::class, 'without an active transaction'); +}); + +test('it rejects transactions and native local operations on mounted storage', function () { + FlysystemHelper::mount('transaction-remote', new Filesystem(new LocalFilesystemAdapter($this->tempDir ?? sys_get_temp_dir()))); + $mounted = new FileOperations('transaction-remote://file.txt'); + + try { + expect(fn () => $mounted->beginTransaction()) + ->toThrow(UnsupportedStorageOperationException::class) + ->and(fn () => $mounted->append('x')) + ->toThrow(UnsupportedStorageOperationException::class); + } finally { + FlysystemHelper::unmount('transaction-remote'); + } +}); diff --git a/tests/Feature/FileOperationsTest.php b/tests/Feature/FileOperationsTest.php index 3f910ce..9866255 100644 --- a/tests/Feature/FileOperationsTest.php +++ b/tests/Feature/FileOperationsTest.php @@ -45,6 +45,19 @@ expect(file_get_contents($this->filePath))->toBe("Line 1\nLine 2"); }); +test('it appends to a large local file without replacing it', function () { + $initial = str_repeat('0123456789abcdef', 128 * 1024); + $this->fileOperations->create($initial); + $inodeBefore = fileinode($this->filePath); + + expect($this->fileOperations->append('tail'))->toBe($this->fileOperations) + ->and(filesize($this->filePath))->toBe(strlen($initial) + 4); + + if (PHP_OS_FAMILY !== 'Windows') { + expect(fileinode($this->filePath))->toBe($inodeBefore); + } +}); + test('it deletes a file', function () { $this->fileOperations->create(); $this->fileOperations->delete(); diff --git a/tests/Feature/FileWatcherTest.php b/tests/Feature/FileWatcherTest.php index c51319d..1e0e6a5 100644 --- a/tests/Feature/FileWatcherTest.php +++ b/tests/Feature/FileWatcherTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\Pathwise\Utils\FileWatcher; +use Infocyph\Pathwise\Results\SnapshotDiff; use Infocyph\Pathwise\Utils\FlysystemHelper; use League\Flysystem\Filesystem; use League\Flysystem\Local\LocalFilesystemAdapter; @@ -50,8 +51,8 @@ $snapshotB = FileWatcher::snapshot($this->watchDir); $diff = FileWatcher::diff($snapshotA, $snapshotB); - expect($diff['created'])->toContain($fileB) - ->and($diff['modified'])->toContain($fileA); + expect($diff->created)->toContain($fileB) + ->and($diff->modified)->toContain($fileA); }); test('it watches directory changes with callback', function () { @@ -71,7 +72,7 @@ throw new RuntimeException('Failed to terminate child process.'); } - FileWatcher::watch($this->watchDir, function (array $diff) use (&$events) { + FileWatcher::watch($this->watchDir, function (SnapshotDiff $diff) use (&$events) { $events[] = $diff; }, durationSeconds: 2, intervalMilliseconds: 100); @@ -90,6 +91,6 @@ $snapshotB = FileWatcher::snapshot('watch://'); $diff = FileWatcher::diff($snapshotA, $snapshotB); - expect($diff['created'])->toContain('watch://b.txt') - ->and($diff['modified'])->toContain('watch://a.txt'); + expect($diff->created)->toContain('watch://b.txt') + ->and($diff->modified)->toContain('watch://a.txt'); }); diff --git a/tests/Feature/FunctionsFlysystemTest.php b/tests/Feature/FunctionsFlysystemTest.php deleted file mode 100644 index 4d268c5..0000000 --- a/tests/Feature/FunctionsFlysystemTest.php +++ /dev/null @@ -1,65 +0,0 @@ -mountRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_mount_', true); - mkdir($this->mountRoot, 0755, true); - FlysystemHelper::mount('mnt', new Filesystem(new LocalFilesystemAdapter($this->mountRoot))); -}); - -afterEach(function () { - FlysystemHelper::reset(); - if (is_dir($this->mountRoot)) { - deleteDirectory($this->mountRoot); - } -}); - -test('helper functions support mounted scheme paths', function () { - createDirectory('mnt://helpers'); - FlysystemHelper::write('mnt://helpers/a.txt', 'abc'); - - expect(isDirectoryEmpty('mnt://helpers'))->toBeFalse() - ->and(getDirectorySize('mnt://helpers'))->toBe(3) - ->and(listFiles('mnt://helpers'))->toBe(['a.txt']) - ->and(copyDirectory('mnt://helpers', 'mnt://helpers-copy'))->toBeTrue() - ->and(FlysystemHelper::fileExists('mnt://helpers-copy/a.txt'))->toBeTrue() - ->and(deleteDirectory('mnt://helpers-copy'))->toBeTrue(); -}); - -test('storage helper functions build and mount filesystems', function () { - $rootA = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_storage_a_', true); - $rootB = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_storage_b_', true); - mkdir($rootA, 0755, true); - mkdir($rootB, 0755, true); - - try { - $filesystem = createFilesystem([ - 'driver' => 'local', - 'root' => $rootA, - ]); - $filesystem->write('from_factory.txt', 'factory'); - - mountStorage('alpha', ['driver' => 'local', 'root' => $rootA]); - mountStorages([ - 'beta' => ['driver' => 'local', 'root' => $rootB], - ]); - - FlysystemHelper::write('alpha://hello.txt', 'A'); - FlysystemHelper::write('beta://hello.txt', 'B'); - - expect($filesystem->read('from_factory.txt'))->toBe('factory') - ->and(FlysystemHelper::read('alpha://hello.txt'))->toBe('A') - ->and(FlysystemHelper::read('beta://hello.txt'))->toBe('B'); - } finally { - FlysystemHelper::unmount('alpha'); - FlysystemHelper::unmount('beta'); - FlysystemHelper::deleteDirectory($rootA); - FlysystemHelper::deleteDirectory($rootB); - } -}); diff --git a/tests/Feature/FunctionsTest.php b/tests/Feature/FunctionsTest.php deleted file mode 100644 index 896c0fe..0000000 --- a/tests/Feature/FunctionsTest.php +++ /dev/null @@ -1,68 +0,0 @@ -tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_dir_', true); - mkdir($this->tempDir); - $this->tempFile = $this->tempDir . DIRECTORY_SEPARATOR . 'sample.txt'; - file_put_contents($this->tempFile, 'hello'); -}); - -afterEach(function () { - if (is_file($this->tempFile)) { - unlink($this->tempFile); - } - if (is_dir($this->tempDir)) { - rmdir($this->tempDir); - } -}); - -test('it formats file size to human readable text', function () { - expect(getHumanReadableFileSize(1024))->toBe('1.00 KB') - ->and(getHumanReadableFileSize(1024 ** 5))->toBe('1,024.00 TB'); -}); - -test('it reports directory empty state correctly', function () { - expect(isDirectoryEmpty($this->tempDir))->toBeFalse(); - - unlink($this->tempFile); - expect(isDirectoryEmpty($this->tempDir))->toBeTrue(); -}); - -test('it throws for non-directory in isDirectoryEmpty', function () { - expect(fn () => isDirectoryEmpty($this->tempFile))->toThrow(InvalidArgumentException::class); -}); - -test('it calculates directory size', function () { - expect(getDirectorySize($this->tempDir))->toBe(filesize($this->tempFile)); -}); - -test('it lists only files', function () { - mkdir($this->tempDir . DIRECTORY_SEPARATOR . 'nested'); - file_put_contents($this->tempDir . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'inside.txt', 'x'); - - try { - expect(listFiles($this->tempDir))->toBe(['sample.txt']); - } finally { - unlink($this->tempDir . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'inside.txt'); - rmdir($this->tempDir . DIRECTORY_SEPARATOR . 'nested'); - } -}); - -test('it copies and deletes directory recursively', function () { - $destination = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_dest_', true); - - try { - expect(copyDirectory($this->tempDir, $destination))->toBeTrue(); - expect(file_exists($destination . DIRECTORY_SEPARATOR . 'sample.txt'))->toBeTrue(); - expect(deleteDirectory($destination))->toBeTrue(); - } finally { - if (is_file($destination . DIRECTORY_SEPARATOR . 'sample.txt')) { - unlink($destination . DIRECTORY_SEPARATOR . 'sample.txt'); - } - if (is_dir($destination)) { - rmdir($destination); - } - } -}); diff --git a/tests/Feature/GlobalHelpersRemovedTest.php b/tests/Feature/GlobalHelpersRemovedTest.php new file mode 100644 index 0000000..14457d7 --- /dev/null +++ b/tests/Feature/GlobalHelpersRemovedTest.php @@ -0,0 +1,12 @@ +toBeFalse() + ->and(function_exists('createDirectory'))->toBeFalse() + ->and(function_exists('deleteDirectory'))->toBeFalse() + ->and(function_exists('copyDirectory'))->toBeFalse() + ->and(function_exists('createFilesystem'))->toBeFalse() + ->and(function_exists('mountStorage'))->toBeFalse(); +}); diff --git a/tests/Feature/NativeExecutionTest.php b/tests/Feature/NativeExecutionTest.php new file mode 100644 index 0000000..9ae0820 --- /dev/null +++ b/tests/Feature/NativeExecutionTest.php @@ -0,0 +1,60 @@ +nativeRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise native Ω ', true); + mkdir($this->nativeRoot, 0755, true); +}); + +afterEach(function () { + FlysystemHelper::reset(); + if (is_dir($this->nativeRoot)) { + (new Infocyph\Pathwise\DirectoryManager\DirectoryOperations($this->nativeRoot))->delete(true); + } +}); + +test('forced native file copy handles spaces quotes Unicode and shell metacharacters', function () { + if (!NativeOperationsAdapter::canUseNativeFileCopy()) { + $this->markTestSkipped('The platform native file-copy executable is unavailable.'); + } + + $source = $this->nativeRoot . DIRECTORY_SEPARATOR . "source ' Ω ; \$.txt"; + $destination = $this->nativeRoot . DIRECTORY_SEPARATOR . "copied ' Ω ; \$.txt"; + file_put_contents($source, 'native-safe'); + + $operations = new FileOperations($source); + expect($operations->setExecutionStrategy(ExecutionStrategy::NATIVE)->copy($destination))->toBe($operations) + ->and(file_get_contents($destination))->toBe('native-safe'); +}); + +test('native adapters return typed execution results', function () { + $source = $this->nativeRoot . DIRECTORY_SEPARATOR . 'source.txt'; + $destination = $this->nativeRoot . DIRECTORY_SEPARATOR . 'destination.txt'; + file_put_contents($source, 'result'); + + $result = NativeOperationsAdapter::copyFile($source, $destination); + + expect($result)->toBeInstanceOf(NativeExecutionResult::class) + ->and($result->exitCode)->toBeInt() + ->and($result->output)->toBeArray(); +}); + +test('forced native file operations reject mounted paths', function () { + FlysystemHelper::mount('native-mounted', new Filesystem(new LocalFilesystemAdapter($this->nativeRoot))); + FlysystemHelper::write('native-mounted://source.txt', 'mounted'); + $operations = (new FileOperations('native-mounted://source.txt')) + ->setExecutionStrategy(ExecutionStrategy::NATIVE); + + expect(fn () => $operations->copy($this->nativeRoot . DIRECTORY_SEPARATOR . 'copy.txt')) + ->toThrow(UnsupportedStorageOperationException::class, 'local filesystem paths'); +}); diff --git a/tests/Feature/OptionalAdapterContractTest.php b/tests/Feature/OptionalAdapterContractTest.php new file mode 100644 index 0000000..8277bf5 --- /dev/null +++ b/tests/Feature/OptionalAdapterContractTest.php @@ -0,0 +1,78 @@ +adapterRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_adapter_contract_', true); + mkdir($this->adapterRoot, 0755, true); +}); + +afterEach(function () { + FlysystemHelper::reset(); + if (is_dir($this->adapterRoot)) { + (new DirectoryOperations($this->adapterRoot))->delete(true); + } +}); + +test('storage-neutral contracts run against the in-memory adapter', function () { + $adapterClass = StorageFactory::officialDrivers()['inmemory']['adapter_class']; + if (!class_exists($adapterClass)) { + $this->markTestSkipped('Install league/flysystem-memory to run this adapter contract.'); + } + + StorageFactory::mount('memory-contract', ['driver' => 'inmemory']); + $file = new FileOperations('memory-contract://source/file.txt'); + $file->create('memory')->copy('memory-contract://source/copy.txt'); + $report = (new DirectoryOperations('memory-contract://source'))->syncTo('memory-contract://target'); + + expect($file->read())->toBe('memory') + ->and(FlysystemHelper::read('memory-contract://source/copy.txt'))->toBe('memory') + ->and($report->created)->toContain('file.txt', 'copy.txt'); +}); + +test('read-only adapters preserve reads and reject mutations', function () { + $adapterClass = StorageFactory::officialDrivers()['read-only']['adapter_class']; + if (!class_exists($adapterClass)) { + $this->markTestSkipped('Install league/flysystem-read-only to run this adapter contract.'); + } + + $localAdapter = new LocalFilesystemAdapter($this->adapterRoot); + (new Filesystem($localAdapter))->write('readable.txt', 'read-only'); + StorageFactory::mount('read-only-contract', [ + 'driver' => 'read-only', + 'constructor' => [$localAdapter], + ]); + $file = new FileOperations('read-only-contract://readable.txt'); + + expect($file->read())->toBe('read-only') + ->and(fn () => $file->create('blocked'))->toThrow(UnableToWriteFile::class) + ->and(fn () => $file->getMetadata())->toThrow(UnsupportedStorageOperationException::class); +}); + +test('path-prefixing adapters confine storage-neutral writes to their prefix', function () { + $adapterClass = StorageFactory::officialDrivers()['path-prefixing']['adapter_class']; + if (!class_exists($adapterClass)) { + $this->markTestSkipped('Install league/flysystem-path-prefixing to run this adapter contract.'); + } + + StorageFactory::mount('prefix-contract', [ + 'driver' => 'path-prefixing', + 'constructor' => [new LocalFilesystemAdapter($this->adapterRoot), 'tenant-a'], + ]); + $file = new FileOperations('prefix-contract://nested/file.txt'); + $file->create('prefixed'); + + expect($file->read())->toBe('prefixed') + ->and(file_get_contents($this->adapterRoot . DIRECTORY_SEPARATOR . 'tenant-a/nested/file.txt')) + ->toBe('prefixed'); +}); diff --git a/tests/Feature/RetentionManagerTest.php b/tests/Feature/RetentionManagerTest.php index 1f98e16..b93c86f 100644 --- a/tests/Feature/RetentionManagerTest.php +++ b/tests/Feature/RetentionManagerTest.php @@ -61,8 +61,8 @@ $report = RetentionManager::apply($this->retentionDir, keepLast: 2); - expect($report['kept'])->toHaveCount(2) - ->and($report['deleted'])->toHaveCount(1); + expect($report->kept)->toHaveCount(2) + ->and($report->deleted)->toHaveCount(1); }); test('it deletes files older than maxAgeDays', function () { @@ -75,8 +75,8 @@ $report = RetentionManager::apply($this->retentionDir, keepLast: null, maxAgeDays: 1); - expect($report['deleted'])->toContain($old) - ->and($report['kept'])->toContain($new) + expect($report->deleted)->toContain($old) + ->and($report->kept)->toContain($new) ->and(is_file($old))->toBeFalse(); }); @@ -89,8 +89,8 @@ $report = RetentionManager::apply('ret://', keepLast: 2); - expect($report['kept'])->toHaveCount(2) - ->and($report['deleted'])->toHaveCount(1); + expect($report->kept)->toHaveCount(2) + ->and($report->deleted)->toHaveCount(1); }); test('it rejects invalid retention options', function () { diff --git a/tests/Feature/SafeFileReaderTest.php b/tests/Feature/SafeFileReaderTest.php index f3c5a9e..ed1d17e 100644 --- a/tests/Feature/SafeFileReaderTest.php +++ b/tests/Feature/SafeFileReaderTest.php @@ -34,19 +34,19 @@ test('it reads file line by line', function () { $reader = new SafeFileReader($this->tempFilePath); - $lines = array_filter(array_map('trim', iterator_to_array($reader->line(), false)), fn($line) => $line !== ''); + $lines = array_filter(array_map('trim', iterator_to_array($reader->lines(), false)), fn($line) => $line !== ''); expect($lines)->toBe(['Hello', 'World', 'JSON', '{"key": "value"}', 'XML']); }); test('it reads file character by character', function () { $reader = new SafeFileReader($this->tempFilePath); - $chars = iterator_to_array($reader->character(), false); + $chars = iterator_to_array($reader->characters(), false); expect(implode('', $chars))->toBe("Hello\nWorld\nJSON\n{\"key\": \"value\"}\nXML\n"); }); test('it reads file in binary chunks', function () { $reader = new SafeFileReader($this->tempFilePath); - $chunks = iterator_to_array($reader->binary(5), false); + $chunks = iterator_to_array($reader->chunks(5), false); $reconstructedContent = trim(implode('', $chunks)); $expectedContent = "Hello\nWorld\nJSON\n{\"key\": \"value\"}\nXML"; expect($reconstructedContent)->toBe($expectedContent); @@ -62,14 +62,14 @@ test('it handles JSON line-by-line decoding', function () { file_put_contents($this->tempFilePath, "{\"key\":\"value\"}\n{\"key2\":\"value2\"}"); $reader = new SafeFileReader($this->tempFilePath); - $jsonLines = iterator_to_array($reader->json(), false); + $jsonLines = iterator_to_array($reader->jsonLines(), false); expect($jsonLines)->toBe([['key' => 'value'], ['key2' => 'value2']]); }); test('it throws exception on invalid JSON decoding', function () { file_put_contents($this->tempFilePath, "Invalid JSON\n{\"key\":\"value\"}"); $reader = new SafeFileReader($this->tempFilePath); - expect(fn() => iterator_to_array($reader->json(), false))->toThrow(Exception::class); + expect(fn() => iterator_to_array($reader->jsonLines(), false))->toThrow(Exception::class); }); test('it applies and releases lock on file', function () { @@ -83,7 +83,7 @@ file_put_contents($this->tempFilePath, 'Value1Value2'); $reader = new SafeFileReader($this->tempFilePath); - $elements = iterator_to_array($reader->xml('item'), false); + $elements = iterator_to_array($reader->xmlElements('item'), false); expect($elements) ->toHaveCount(2) @@ -96,7 +96,7 @@ $data = serialize(['key' => 'value']); file_put_contents($this->tempFilePath, $data . "\n" . $data); $reader = new SafeFileReader($this->tempFilePath); - $serializedObjects = iterator_to_array($reader->serialized(), false); + $serializedObjects = iterator_to_array($reader->serializedValues(), false); expect($serializedObjects)->toBe([['key' => 'value'], ['key' => 'value']]); }); @@ -105,7 +105,7 @@ file_put_contents($this->tempFilePath, $data); $reader = new SafeFileReader($this->tempFilePath); - expect(fn() => iterator_to_array($reader->serialized(), false)) + expect(fn() => iterator_to_array($reader->serializedValues(), false)) ->toThrow(Exception::class, 'Serialized objects are not allowed'); }); @@ -119,26 +119,26 @@ test('it throws exception if file is not accessible', function () { $invalidFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('invalid_file_', true) . '.txt'; $reader = new SafeFileReader($invalidFilePath); - expect(fn() => $reader->line())->toThrow(FileAccessException::class); + expect(fn() => $reader->lines())->toThrow(FileAccessException::class); }); test('it counts lines correctly', function () { $reader = new SafeFileReader($this->tempFilePath); - iterator_to_array($reader->line()); + iterator_to_array($reader->lines()); expect($reader->count())->toBe(5); }); -test('it resets and seeks to a position in lines', function () { +test('it creates an independent line iterable for each read', function () { $reader = new SafeFileReader($this->tempFilePath); - $reader->seek(2); - $lines = iterator_to_array($reader->line(), false); - expect($lines[0])->toBe("JSON\n"); + $first = iterator_to_array($reader->lines(), false); + $second = iterator_to_array($reader->lines(), false); + expect($second)->toBe($first); }); test('it reads mounted files through local staging', function () { FlysystemHelper::write('reader://remote.txt', "A\nB\n"); $reader = new SafeFileReader('reader://remote.txt'); - $lines = array_map('trim', iterator_to_array($reader->line(), false)); + $lines = array_map('trim', iterator_to_array($reader->lines(), false)); expect($lines)->toBe(['A', 'B']); }); diff --git a/tests/Feature/SafeFileWriterTest.php b/tests/Feature/SafeFileWriterTest.php index 4dee4f7..64e358e 100644 --- a/tests/Feature/SafeFileWriterTest.php +++ b/tests/Feature/SafeFileWriterTest.php @@ -34,7 +34,7 @@ test('it creates a file and writes a single character', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->character('A'); + $writer->writeCharacters('A'); expect(file_get_contents($this->tempFilePath)) ->toBe('A') @@ -43,8 +43,8 @@ test('it appends lines to the file', function () { $writer = new SafeFileWriter($this->tempFilePath, true); - $writer->line('Hello'); - $writer->line('World'); + $writer->writeLine('Hello'); + $writer->writeLine('World'); $fileContent = file_get_contents($this->tempFilePath); $normalizedContent = str_replace(["\r\n", "\r"], "\n", $fileContent); @@ -57,8 +57,8 @@ test('it writes CSV data to the file', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->csv(['Name', 'Age']); - $writer->csv(['John', 30]); + $writer->writeCsv(['Name', 'Age']); + $writer->writeCsv(['John', 30]); $content = file_get_contents($this->tempFilePath); expect($content)->toBe("Name,Age\nJohn,30\n"); @@ -66,14 +66,14 @@ test('it writes binary data to the file', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->binary('BinaryData'); + $writer->writeBinary('BinaryData'); expect(file_get_contents($this->tempFilePath))->toBe('BinaryData'); }); test('it writes JSON data with pretty print', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->json(['key' => 'value'], true); + $writer->writeJson(['key' => 'value'], true); $fileContent = file_get_contents($this->tempFilePath); $normalizedContent = str_replace(["\r\n", "\r"], "\n", $fileContent); @@ -86,15 +86,15 @@ test('it writes XML data to the file', function () { $xml = new SimpleXMLElement('Value'); $writer = new SafeFileWriter($this->tempFilePath); - $writer->xml($xml); + $writer->writeXml($xml); expect(file_get_contents($this->tempFilePath))->toContain('Value'); }); test('it writes serialized data to the file', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->serialized(['key' => 'value']); - $writer->serialized(['another' => 'entry']); + $writer->writeSerialized(['key' => 'value']); + $writer->writeSerialized(['another' => 'entry']); // Deserialize all lines $lines = file($this->tempFilePath, FILE_IGNORE_NEW_LINES); @@ -108,7 +108,7 @@ test('it writes a JSON array to the file', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->jsonArray([['key' => 'value']]); + $writer->writeJsonArray([['key' => 'value']]); $content = json_decode(file_get_contents($this->tempFilePath), true); expect($content)->toBe([['key' => 'value']]); @@ -118,13 +118,13 @@ $invalidPath = '/invalid_path/test_file.txt'; $writer = new SafeFileWriter($invalidPath); - expect(fn () => $writer->line('test'))->toThrow(FileAccessException::class); + expect(fn () => $writer->writeLine('test'))->toThrow(FileAccessException::class); }); test('it locks and unlocks the file', function () { $writer = new SafeFileWriter($this->tempFilePath); $writer->lock(); - $writer->line('Locked Content'); + $writer->writeLine('Locked Content'); $writer->unlock(); $fileContent = file_get_contents($this->tempFilePath); @@ -136,17 +136,17 @@ test('it counts total write operations', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->line('Line 1'); - $writer->line('Line 2'); - $writer->csv(['Name', 'Age']); - $writer->json(['key' => 'value']); + $writer->writeLine('Line 1'); + $writer->writeLine('Line 2'); + $writer->writeCsv(['Name', 'Age']); + $writer->writeJson(['key' => 'value']); expect($writer->count())->toBe(4); }); test('it flushes and truncates the file', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->line('Data before flush'); + $writer->writeLine('Data before flush'); $writer->flush(); $writer->truncate(); @@ -155,7 +155,7 @@ test('it returns file size and modification date', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->line('Size Test'); + $writer->writeLine('Size Test'); expect($writer->getSize()) ->toBeGreaterThan(0) @@ -164,7 +164,7 @@ test('it converts to string and JSON serializes', function () { $writer = new SafeFileWriter($this->tempFilePath); - $writer->line('Test for JSON'); + $writer->writeLine('Test for JSON'); expect((string)$writer) ->toContain($this->tempFilePath) @@ -177,7 +177,7 @@ $writer = (new SafeFileWriter($this->tempFilePath)) ->enableAtomicWrite(); - $writer->line('after'); + $writer->writeLine('after'); expect(file_get_contents($this->tempFilePath))->toBe('before'); $writer->close(); @@ -187,15 +187,15 @@ test('it verifies checksum after writing', function () { $writer = new SafeFileWriter($this->tempFilePath); - $ok = $writer->writeAndVerify('checksum-content'); + $result = $writer->writeAndVerify('checksum-content'); - expect($ok)->toBeTrue() + expect($result)->toBe($writer) ->and($writer->verifyChecksum(hash('sha256', 'checksum-content')))->toBeTrue(); }); test('it writes mounted files through local staging and sync', function () { $writer = new SafeFileWriter('writer://remote.txt'); - $writer->line('hello'); + $writer->writeLine('hello'); $writer->close(); $normalizedContent = str_replace(["\r\n", "\r"], "\n", FlysystemHelper::read('writer://remote.txt')); diff --git a/tests/Feature/UploadProcessorTest.php b/tests/Feature/UploadProcessorTest.php index acbbeae..95676ef 100644 --- a/tests/Feature/UploadProcessorTest.php +++ b/tests/Feature/UploadProcessorTest.php @@ -122,7 +122,7 @@ 'name' => "chunk_{$index}.part", ], $uploadId, $index, count($parts), 'merged.txt'); - expect($result['receivedChunks'])->toBe($index + 1); + expect($result->receivedChunks)->toBe($index + 1); } $finalPath = $this->uploadProcessor->finalizeChunkUpload($uploadId); From 92727196f698ccb98a4c0cee125c0b16a5c3aa54 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sat, 8 Aug 2026 14:30:36 +0600 Subject: [PATCH 2/4] updated doc+fixing code issues --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6a71a85..db4ff1f 100644 --- a/README.md +++ b/README.md @@ -107,19 +107,19 @@ risk but do not replace responsible disclosure or manual review. SecurityCode of ConductContributing
- Issues: + 🗂️ BugFeatureDocumentationQuestionCI failure
- Pull requests: - General • - Bug fix • - Feature • - Refactor • - Performance • - Security & reliability • - Documentation • - Maintenance + 🔀 + General • + Bug fix • + Feature • + Refactor • + Performance • + Security & reliability • + Documentation • + Maintenance From af11fe50ec21e6e227a3b8223a6a318c26f03eb1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sat, 8 Aug 2026 20:30:31 +0600 Subject: [PATCH 3/4] updated doc+fixing code issues --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index db4ff1f..a80fd5e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pathwise -![Security & Standards](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml) +[![Security & Standards](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml) ![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/Pathwise?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2FPathwise) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) ![Packagist Version](https://img.shields.io/packagist/v/infocyph/Pathwise) @@ -59,7 +59,7 @@ Storage-neutral reads, writes, copies, streams, uploads, downloads, ZIP staging, Local `append()` uses native append mode. Mounted stores must opt into `appendEmulated()`, which visibly represents a complete object replacement. Local transactions use a structured, disk-backed rollback journal and reject nesting. -See the [storage capability contract](docs/storage-contracts.rst) for the compatibility matrix, atomicity, locking, sync, native execution, archive security, and performance characteristics. +See the `storage capability contract` in the documentation for the compatibility matrix, atomicity, locking, sync, native execution, archive security, and performance characteristics. ## Synchronization and result types From 33e02974aa70b52a1fe4f4788fc108f76ff8ebfd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sat, 8 Aug 2026 20:47:42 +0600 Subject: [PATCH 4/4] updated doc+fixing code issues --- .github/workflows/security-standards.yml | 4 +- .../UploadProcessorValidationConcern.php | 19 ++-- src/StreamHandler/UploadProcessor.php | 6 +- tests/Feature/ArchiveSecurityTest.php | 11 ++- tests/Feature/FileWatcherTest.php | 11 ++- tests/Feature/MetadataHelperTest.php | 26 ++++- tests/Feature/NativeExecutionTest.php | 12 ++- tests/Feature/OptionalAdapterContractTest.php | 99 ++++++++++--------- tests/Feature/PermissionsHelperTest.php | 60 ++++++++--- tests/Feature/UploadProcessorTest.php | 7 +- 10 files changed, 168 insertions(+), 87 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 973b495..5a905b5 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -24,7 +24,7 @@ jobs: psalm_threads: "1" run_analysis: true run_svg_report: true - fail_on_skipped_tests: false + fail_on_skipped_tests: true run_clean_install: true benchmark_composer_script: "" benchmark_result_file: "" @@ -68,6 +68,8 @@ jobs: adapter-contracts: name: "Optional adapter contracts" runs-on: ubuntu-latest + env: + PATHWISE_REQUIRE_ADAPTER_CONTRACTS: "1" steps: - uses: actions/checkout@v4 - name: "Set up PHP" diff --git a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php index a09f8f5..d168365 100644 --- a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php +++ b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php @@ -224,16 +224,6 @@ private function runSilently(callable $operation): mixed } } - /** - * Sanitize a path to remove invalid characters. - */ - private function sanitizePath(string $path): string - { - $sanitized = preg_replace('/[^a-zA-Z0-9\/\\\\:_.-]/', '', $path) ?? ''; - - return rtrim($sanitized, '/\\'); - } - private function scanForMalware(string $filePath, string $fileType): void { if (!is_callable($this->malwareScanner)) { @@ -279,6 +269,15 @@ private function validateContentTypeIntegrity(string $filePath, string $fileType $this->validateMagicSignatureForExtension($filePath, $normalizedExtension); } + private function validateDirectoryPath(string $path): string + { + if ($path === '' || str_contains($path, "\0")) { + throw new UploadException('Invalid upload directory path.'); + } + + return $path; + } + /** * @param array $file * @return array{error: int, size: int, tmp_name: string, name: string} diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index b142940..680faf6 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -321,10 +321,10 @@ public function setChunkLimits(int $maxChunkCount = 0, int $maxChunkSize = 0): v */ public function setDirectorySettings(string $uploadDir, bool $useDateDirectories = false, ?string $tempDir = null): void { - $this->uploadDir = PathHelper::normalize($this->sanitizePath($uploadDir)); + $this->uploadDir = PathHelper::normalize($this->validateDirectoryPath($uploadDir)); $this->useDateDirectories = $useDateDirectories; - $this->tempDir = $tempDir - ? PathHelper::normalize($this->sanitizePath($tempDir)) + $this->tempDir = $tempDir !== null + ? PathHelper::normalize($this->validateDirectoryPath($tempDir)) : sys_get_temp_dir(); $this->ensureUploadDirectoryExists(); } diff --git a/tests/Feature/ArchiveSecurityTest.php b/tests/Feature/ArchiveSecurityTest.php index e62b558..bc1b293 100644 --- a/tests/Feature/ArchiveSecurityTest.php +++ b/tests/Feature/ArchiveSecurityTest.php @@ -80,7 +80,16 @@ test('archive validation rejects extraction through an existing destination symlink', function () { if (PHP_OS_FAMILY === 'Windows') { - $this->markTestSkipped('Symbolic-link creation is not consistently available on Windows CI.'); + $zip = new ZipArchive(); + expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue(); + $zip->addFromString('linked/escape.txt', '../outside.txt'); + $zip->setExternalAttributesName('linked/escape.txt', ZipArchive::OPSYS_UNIX, 0120777 << 16); + $zip->close(); + + expect(fn () => (new FileCompression($this->archivePath))->decompress($this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class, 'Symbolic-link ZIP entry'); + + return; } $outside = $this->securityRoot . DIRECTORY_SEPARATOR . 'outside'; diff --git a/tests/Feature/FileWatcherTest.php b/tests/Feature/FileWatcherTest.php index 1e0e6a5..9766e03 100644 --- a/tests/Feature/FileWatcherTest.php +++ b/tests/Feature/FileWatcherTest.php @@ -57,7 +57,16 @@ test('it watches directory changes with callback', function () { if (!function_exists('pcntl_fork') || !function_exists('pcntl_waitpid')) { - $this->markTestSkipped('pcntl not available in this environment.'); + $events = []; + $result = FileWatcher::watch($this->watchDir, function (SnapshotDiff $diff) use (&$events) { + $events[] = $diff; + }, durationSeconds: 1, intervalMilliseconds: 10); + + expect($events)->toBeEmpty() + ->and($result->changeSets)->toBe(0) + ->and($result->finalSnapshot)->toBeArray(); + + return; } $events = []; diff --git a/tests/Feature/MetadataHelperTest.php b/tests/Feature/MetadataHelperTest.php index a97c50e..dc9ab95 100644 --- a/tests/Feature/MetadataHelperTest.php +++ b/tests/Feature/MetadataHelperTest.php @@ -95,21 +95,33 @@ }); test('it identifies broken symbolic link', function () { + if (PHP_OS_FAMILY === 'Windows') { + expect(MetadataHelper::isBrokenSymlink($this->missingFilePath))->toBeNull(); + + return; + } + $linkPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'broken_link'; $nonExistentPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'nonexistent_file.txt'; symlink($nonExistentPath, $linkPath); expect(MetadataHelper::isBrokenSymlink($linkPath))->toBeTrue(); unlink($linkPath); -})->skip(PHP_OS_FAMILY === 'Windows');; +}); test('it identifies non-broken symbolic link', function () { + if (PHP_OS_FAMILY === 'Windows') { + expect(MetadataHelper::isBrokenSymlink($this->tempFilePath))->toBeNull(); + + return; + } + $linkPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'valid_link'; symlink($this->tempFilePath, $linkPath); expect(MetadataHelper::isBrokenSymlink($linkPath))->toBeFalse(); unlink($linkPath); -})->skip(PHP_OS_FAMILY === 'Windows');; +}); test('it returns null if path is not a symlink', function () { expect(MetadataHelper::isBrokenSymlink($this->tempFilePath))->toBeNull(); @@ -143,7 +155,7 @@ test('it retrieves file ownership details', function () { $ownership = MetadataHelper::getOwnershipDetails($this->tempFilePath); expect($ownership)->toHaveKeys(['owner', 'group']); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); test('it retrieves last modified by user', function () { $lastModifiedBy = MetadataHelper::getLastModifiedBy($this->tempFilePath); @@ -161,12 +173,18 @@ }); test('it retrieves symlink target', function () { + if (PHP_OS_FAMILY === 'Windows') { + expect(MetadataHelper::getSymlinkTarget($this->tempFilePath))->toBeNull(); + + return; + } + $linkPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'valid_link'; symlink($this->tempFilePath, $linkPath); expect(MetadataHelper::getSymlinkTarget($linkPath))->toBe($this->tempFilePath); unlink($linkPath); -})->skip(PHP_OS_FAMILY === 'Windows');; +}); test('it returns null if path is not a symlink for target retrieval', function () { expect(MetadataHelper::getSymlinkTarget($this->tempFilePath))->toBeNull(); diff --git a/tests/Feature/NativeExecutionTest.php b/tests/Feature/NativeExecutionTest.php index 9ae0820..abd2b19 100644 --- a/tests/Feature/NativeExecutionTest.php +++ b/tests/Feature/NativeExecutionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\Pathwise\Core\ExecutionStrategy; +use Infocyph\Pathwise\Exceptions\NativeExecutionException; use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\FileManager\FileOperations; use Infocyph\Pathwise\Native\NativeOperationsAdapter; @@ -24,15 +25,18 @@ }); test('forced native file copy handles spaces quotes Unicode and shell metacharacters', function () { - if (!NativeOperationsAdapter::canUseNativeFileCopy()) { - $this->markTestSkipped('The platform native file-copy executable is unavailable.'); - } - $source = $this->nativeRoot . DIRECTORY_SEPARATOR . "source ' Ω ; \$.txt"; $destination = $this->nativeRoot . DIRECTORY_SEPARATOR . "copied ' Ω ; \$.txt"; file_put_contents($source, 'native-safe'); $operations = new FileOperations($source); + if (!NativeOperationsAdapter::canUseNativeFileCopy()) { + expect(fn () => $operations->setExecutionStrategy(ExecutionStrategy::NATIVE)->copy($destination)) + ->toThrow(NativeExecutionException::class); + + return; + } + expect($operations->setExecutionStrategy(ExecutionStrategy::NATIVE)->copy($destination))->toBe($operations) ->and(file_get_contents($destination))->toBe('native-safe'); }); diff --git a/tests/Feature/OptionalAdapterContractTest.php b/tests/Feature/OptionalAdapterContractTest.php index 8277bf5..d4ef9ab 100644 --- a/tests/Feature/OptionalAdapterContractTest.php +++ b/tests/Feature/OptionalAdapterContractTest.php @@ -11,6 +11,20 @@ use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\UnableToWriteFile; +$officialDrivers = StorageFactory::officialDrivers(); +$memoryAdapterClass = $officialDrivers['inmemory']['adapter_class']; +$readOnlyAdapterClass = $officialDrivers['read-only']['adapter_class']; +$pathPrefixingAdapterClass = $officialDrivers['path-prefixing']['adapter_class']; +$adapterClasses = [$memoryAdapterClass, $readOnlyAdapterClass, $pathPrefixingAdapterClass]; + +if (getenv('PATHWISE_REQUIRE_ADAPTER_CONTRACTS') === '1') { + foreach ($adapterClasses as $adapterClass) { + if (!class_exists($adapterClass)) { + throw new RuntimeException("Required adapter contract dependency is unavailable: {$adapterClass}"); + } + } +} + beforeEach(function () { FlysystemHelper::reset(); $this->adapterRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_adapter_contract_', true); @@ -24,55 +38,46 @@ } }); -test('storage-neutral contracts run against the in-memory adapter', function () { - $adapterClass = StorageFactory::officialDrivers()['inmemory']['adapter_class']; - if (!class_exists($adapterClass)) { - $this->markTestSkipped('Install league/flysystem-memory to run this adapter contract.'); - } - - StorageFactory::mount('memory-contract', ['driver' => 'inmemory']); - $file = new FileOperations('memory-contract://source/file.txt'); - $file->create('memory')->copy('memory-contract://source/copy.txt'); - $report = (new DirectoryOperations('memory-contract://source'))->syncTo('memory-contract://target'); - - expect($file->read())->toBe('memory') - ->and(FlysystemHelper::read('memory-contract://source/copy.txt'))->toBe('memory') - ->and($report->created)->toContain('file.txt', 'copy.txt'); -}); - -test('read-only adapters preserve reads and reject mutations', function () { - $adapterClass = StorageFactory::officialDrivers()['read-only']['adapter_class']; - if (!class_exists($adapterClass)) { - $this->markTestSkipped('Install league/flysystem-read-only to run this adapter contract.'); - } +if (class_exists($memoryAdapterClass)) { + test('storage-neutral contracts run against the in-memory adapter', function () { + StorageFactory::mount('memory-contract', ['driver' => 'inmemory']); + $file = new FileOperations('memory-contract://source/file.txt'); + $file->create('memory')->copy('memory-contract://source/copy.txt'); + $report = (new DirectoryOperations('memory-contract://source'))->syncTo('memory-contract://target'); - $localAdapter = new LocalFilesystemAdapter($this->adapterRoot); - (new Filesystem($localAdapter))->write('readable.txt', 'read-only'); - StorageFactory::mount('read-only-contract', [ - 'driver' => 'read-only', - 'constructor' => [$localAdapter], - ]); - $file = new FileOperations('read-only-contract://readable.txt'); + expect($file->read())->toBe('memory') + ->and(FlysystemHelper::read('memory-contract://source/copy.txt'))->toBe('memory') + ->and($report->created)->toContain('file.txt', 'copy.txt'); + }); +} - expect($file->read())->toBe('read-only') - ->and(fn () => $file->create('blocked'))->toThrow(UnableToWriteFile::class) - ->and(fn () => $file->getMetadata())->toThrow(UnsupportedStorageOperationException::class); -}); +if (class_exists($readOnlyAdapterClass)) { + test('read-only adapters preserve reads and reject mutations', function () { + $localAdapter = new LocalFilesystemAdapter($this->adapterRoot); + (new Filesystem($localAdapter))->write('readable.txt', 'read-only'); + StorageFactory::mount('read-only-contract', [ + 'driver' => 'read-only', + 'constructor' => [$localAdapter], + ]); + $file = new FileOperations('read-only-contract://readable.txt'); -test('path-prefixing adapters confine storage-neutral writes to their prefix', function () { - $adapterClass = StorageFactory::officialDrivers()['path-prefixing']['adapter_class']; - if (!class_exists($adapterClass)) { - $this->markTestSkipped('Install league/flysystem-path-prefixing to run this adapter contract.'); - } + expect($file->read())->toBe('read-only') + ->and(fn () => $file->create('blocked'))->toThrow(UnableToWriteFile::class) + ->and(fn () => $file->getMetadata())->toThrow(UnsupportedStorageOperationException::class); + }); +} - StorageFactory::mount('prefix-contract', [ - 'driver' => 'path-prefixing', - 'constructor' => [new LocalFilesystemAdapter($this->adapterRoot), 'tenant-a'], - ]); - $file = new FileOperations('prefix-contract://nested/file.txt'); - $file->create('prefixed'); +if (class_exists($pathPrefixingAdapterClass)) { + test('path-prefixing adapters confine storage-neutral writes to their prefix', function () { + StorageFactory::mount('prefix-contract', [ + 'driver' => 'path-prefixing', + 'constructor' => [new LocalFilesystemAdapter($this->adapterRoot), 'tenant-a'], + ]); + $file = new FileOperations('prefix-contract://nested/file.txt'); + $file->create('prefixed'); - expect($file->read())->toBe('prefixed') - ->and(file_get_contents($this->adapterRoot . DIRECTORY_SEPARATOR . 'tenant-a/nested/file.txt')) - ->toBe('prefixed'); -}); + expect($file->read())->toBe('prefixed') + ->and(file_get_contents($this->adapterRoot . DIRECTORY_SEPARATOR . 'tenant-a/nested/file.txt')) + ->toBe('prefixed'); + }); +} diff --git a/tests/Feature/PermissionsHelperTest.php b/tests/Feature/PermissionsHelperTest.php index 2dd3a24..0ae7a4b 100644 --- a/tests/Feature/PermissionsHelperTest.php +++ b/tests/Feature/PermissionsHelperTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Infocyph\Pathwise\Exceptions\MissingExtensionException; use Infocyph\Pathwise\Utils\PermissionsHelper; beforeEach(function () { @@ -18,70 +19,99 @@ test('it retrieves file permissions', function () { $permissions = PermissionsHelper::getPermissions($this->tempFilePath); expect($permissions)->toBeString(); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); // Test setPermissions test('it sets file permissions', function () { - PermissionsHelper::setPermissions($this->tempFilePath, 0740); - expect(PermissionsHelper::getPermissions($this->tempFilePath))->toBe('0740'); -})->skip(PHP_OS_FAMILY === 'Windows'); + $result = PermissionsHelper::setPermissions($this->tempFilePath, 0740); + + expect($result)->toBeInstanceOf(PermissionsHelper::class) + ->and(PermissionsHelper::getPermissions($this->tempFilePath))->toBeString(); + + if (PHP_OS_FAMILY !== 'Windows') { + expect(PermissionsHelper::getPermissions($this->tempFilePath))->toBe('0740'); + } +}); // Test canRead test('it checks if file is readable', function () { expect(PermissionsHelper::canRead($this->tempFilePath))->toBeTrue(); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); // Test canWrite test('it checks if file is writable', function () { expect(PermissionsHelper::canWrite($this->tempFilePath))->toBeTrue(); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); // Test canExecute test('it checks if file is executable', function () { expect(PermissionsHelper::canExecute($this->tempFilePath))->toBeFalse(); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); // Test getOwnership (POSIX only) test('it retrieves file ownership', function () { + if (PHP_OS_FAMILY === 'Windows') { + expect(fn() => PermissionsHelper::getOwnership($this->tempFilePath)) + ->toThrow(MissingExtensionException::class, 'ext-posix'); + + return; + } + $ownership = PermissionsHelper::getOwnership($this->tempFilePath); expect($ownership)->toHaveKeys(['owner', 'group']); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); // Test setOwnership (POSIX only) test('it sets file ownership', function () { + if (PHP_OS_FAMILY === 'Windows') { + expect(fn() => PermissionsHelper::setOwnership($this->tempFilePath, 'owner')) + ->toThrow(MissingExtensionException::class, 'ext-posix'); + + return; + } + $originalOwner = posix_getpwuid(fileowner($this->tempFilePath))['name'] ?? null; PermissionsHelper::setOwnership($this->tempFilePath, $originalOwner); $ownership = PermissionsHelper::getOwnership($this->tempFilePath); expect($ownership['owner'])->toBe($originalOwner); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); // Test isOwnedByCurrentUser (POSIX only) test('it checks if file is owned by current user', function () { - expect(PermissionsHelper::isOwnedByCurrentUser($this->tempFilePath))->toBeTrue(); -})->skip(PHP_OS_FAMILY === 'Windows'); + expect(PermissionsHelper::isOwnedByCurrentUser($this->tempFilePath)) + ->toBe(PHP_OS_FAMILY !== 'Windows'); +}); // Test getHumanReadablePermissions test('it retrieves human-readable file permissions', function () { $permissions = PermissionsHelper::getHumanReadablePermissions($this->tempFilePath); expect($permissions)->toBeString()->toMatch('/^[r-][w-][x-][r-][w-][x-][r-][w-][x-]$/'); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); // Test formatPermissions test('it formats permissions as human-readable string', function () { $permissions = PermissionsHelper::formatPermissions(0755); expect($permissions)->toBe('rwxr-xr-x'); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); test('it formats special permission bits without inventing execute access', function () { expect(PermissionsHelper::formatPermissions(0644 | 04000))->toBe('rwSr--r--') ->and(PermissionsHelper::formatPermissions(0644 | 02000))->toBe('rw-r-Sr--') ->and(PermissionsHelper::formatPermissions(0644 | 01000))->toBe('rw-r--r-T'); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); test('it observes permission changes made outside the helper', function () { + if (PHP_OS_FAMILY === 'Windows') { + expect(PermissionsHelper::setPermissions($this->tempFilePath, 0600)) + ->toBeInstanceOf(PermissionsHelper::class) + ->and(PermissionsHelper::getPermissions($this->tempFilePath))->toBeString(); + + return; + } + chmod($this->tempFilePath, 0644); expect(PermissionsHelper::getPermissions($this->tempFilePath))->toBe('0644'); chmod($this->tempFilePath, 0600); expect(PermissionsHelper::getPermissions($this->tempFilePath))->toBe('0600'); -})->skip(PHP_OS_FAMILY === 'Windows'); +}); diff --git a/tests/Feature/UploadProcessorTest.php b/tests/Feature/UploadProcessorTest.php index 95676ef..7b72012 100644 --- a/tests/Feature/UploadProcessorTest.php +++ b/tests/Feature/UploadProcessorTest.php @@ -12,7 +12,7 @@ beforeEach(function () { FlysystemHelper::reset(); $this->uploadProcessor = new UploadProcessor(); - $this->uploadDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('upload_dir_', true); + $this->uploadDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('upload dir_~', true); }); afterEach(function () { @@ -49,6 +49,11 @@ ->and($info['uploadDir'])->toContain($this->uploadDir); }); +test('it rejects empty and null-byte directory paths', function (string $path) { + expect(fn() => $this->uploadProcessor->setDirectorySettings($path)) + ->toThrow(UploadException::class, 'Invalid upload directory path'); +})->with(['empty path' => '', 'null-byte path' => "invalid\0path"]); + test('it throws for invalid upload parameters', function () { $this->uploadProcessor->setDirectorySettings($this->uploadDir);