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..5a905b5 100644
--- a/.github/workflows/security-standards.yml
+++ b/.github/workflows/security-standards.yml
@@ -24,7 +24,13 @@ 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: ""
+ 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,45 @@ 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
+ env:
+ PATHWISE_REQUIRE_ADAPTER_CONTRACTS: "1"
+ 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..a80fd5e 100644
--- a/README.md
+++ b/README.md
@@ -1,90 +1,47 @@
-# Pathwise: File Management Made Simple
+# Pathwise
[](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml)
-[](https://docs.infocyph.com/projects/pathwise/)
-
+
[](https://opensource.org/licenses/MIT)
-
-
-
-
-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:
+
+
+
+[](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` in the documentation 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.
---
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/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/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..680faf6 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,
+ );
}
/**
@@ -315,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/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..bc1b293
--- /dev/null
+++ b/tests/Feature/ArchiveSecurityTest.php
@@ -0,0 +1,117 @@
+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') {
+ $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';
+ 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..9766e03 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,13 +51,22 @@
$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 () {
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 = [];
@@ -71,7 +81,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 +100,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/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
new file mode 100644
index 0000000..abd2b19
--- /dev/null
+++ b/tests/Feature/NativeExecutionTest.php
@@ -0,0 +1,64 @@
+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 () {
+ $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');
+});
+
+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..d4ef9ab
--- /dev/null
+++ b/tests/Feature/OptionalAdapterContractTest.php
@@ -0,0 +1,83 @@
+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);
+ }
+});
+
+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');
+
+ expect($file->read())->toBe('memory')
+ ->and(FlysystemHelper::read('memory-contract://source/copy.txt'))->toBe('memory')
+ ->and($report->created)->toContain('file.txt', 'copy.txt');
+ });
+}
+
+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');
+
+ expect($file->read())->toBe('read-only')
+ ->and(fn () => $file->create('blocked'))->toThrow(UnableToWriteFile::class)
+ ->and(fn () => $file->getMetadata())->toThrow(UnsupportedStorageOperationException::class);
+ });
+}
+
+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');
+ });
+}
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/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, '- Value1
- Value2
');
$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..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);
@@ -122,7 +127,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);