diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml
index 5a905b5c..239efb68 100644
--- a/.github/workflows/security-standards.yml
+++ b/.github/workflows/security-standards.yml
@@ -11,38 +11,16 @@ on:
jobs:
phpforge:
uses: infocyph/phpforge/.github/workflows/security-standards.yml@main
+ with:
+ fail_on_skipped_tests: true
+ integration_services: '[]'
+ service_topologies: '{}'
+ php_extensions: "fileinfo, pcntl, posix, simplexml, xmlreader, zip"
+ benchmark_composer_script: "benchmark:release"
permissions:
security-events: write
actions: read
contents: read
- with:
- php_versions: '["8.4","8.5"]'
- dependency_versions: '["prefer-lowest","prefer-stable"]'
- php_extensions: "fileinfo, pcntl, posix, simplexml, xmlreader, zip"
- composer_flags: ""
- phpstan_memory_limit: "1G"
- psalm_threads: "1"
- run_analysis: true
- run_svg_report: true
- fail_on_skipped_tests: 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
- enable_postgres_service: false
- enable_mysql_service: false
- enable_scylladb_service: false
- enable_elasticsearch_service: false
- enable_mongodb_service: false
- service_db_name: "phpforge"
- service_db_user: "phpforge"
- service_db_password: "phpforge"
- artifact_retention_days: 61
windows:
name: "Windows / PHP ${{ matrix.php }}"
@@ -52,7 +30,7 @@ jobs:
matrix:
php: ["8.4", "8.5"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: "Set up PHP"
uses: shivammathur/setup-php@v2
with:
@@ -71,7 +49,7 @@ jobs:
env:
PATHWISE_REQUIRE_ADAPTER_CONTRACTS: "1"
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: "Set up PHP"
uses: shivammathur/setup-php@v2
with:
@@ -85,3 +63,21 @@ jobs:
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
+
+ release-stress:
+ name: "Release stress / PHP 8.4"
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - name: "Set up PHP"
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.4"
+ extensions: "fileinfo, pcntl, posix, simplexml, xmlreader, zip"
+ coverage: none
+ tools: composer:v2
+ - name: "Install dependencies"
+ run: composer install --no-interaction --prefer-dist
+ - name: "Run release stress workloads"
+ run: composer stress:release
diff --git a/README.md b/README.md
index 50a8eeb3..81a5ab2f 100644
--- a/README.md
+++ b/README.md
@@ -1,127 +1,157 @@
# Pathwise
-[](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml)
-
-[](https://opensource.org/licenses/MIT)
-
-
-
-[](https://docs.infocyph.com/projects/Pathwise/en/latest/)
+Pathwise 4 is a framework-neutral PHP 8.4+ filesystem toolkit built on Flysystem 3. It combines safe local file operations with instance-scoped storage topology, hardened upload/download pipelines, archive controls, file-backed queueing, observability, retention, indexing, policy enforcement, and bounded native execution.
-High-level PHP filesystem workflows powered by Flysystem, including safe I/O, uploads, downloads, archives, directory synchronization, retention, policy enforcement, auditing and storage adapters.
+## Requirements
-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.
+- PHP `>=8.4`
+- `ext-fileinfo`
+- `league/flysystem ^3.35.2`
+- `psr/log ^3.0.2`
-## Installation
+ZIP, POSIX ownership, XML parsing, and remote Flysystem adapters are optional capabilities. Install only the extensions/adapters your application uses.
```bash
-composer require infocyph/pathwise
+composer require infocyph/pathwise:^4.0
```
-`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.
+## Storage topology
-## Quick start
+Use `StorageContext` for applications, workers, long-lived runtimes, or any process that can host more than one storage topology. Contexts do not register process-global mounts.
```php
-use Infocyph\Pathwise\FileManager\FileOperations;
-use Infocyph\Pathwise\FileManager\SafeFileReader;
-use Infocyph\Pathwise\FileManager\SafeFileWriter;
+use Infocyph\Pathwise\Storage\StorageContext;
-$file = new FileOperations('/tmp/example.txt');
-$file->create('hello')->append("\nworld");
+$storage = new StorageContext([
+ 'primary' => ['driver' => 'local', 'root' => '/srv/app/storage'],
+ 'archive' => ['driver' => 'local', 'root' => '/srv/app/archive'],
+], 'primary');
-foreach ((new SafeFileReader('/tmp/example.txt'))->lines() as $line) {
- echo $line;
-}
+[$filesystem, $location] = $storage->resolve('archive://reports/q1.txt');
+$filesystem->write($location, "ready\n");
-$writer = new SafeFileWriter('/tmp/events.json');
-$writer->writeJson(['status' => 'ready']);
-$writer->close();
+$local = $storage->localPath('documents/readme.txt');
```
-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.
+`StorageFactory::createFilesystem()` remains the stateless constructor for built-in/official Flysystem adapters. Custom driver factories belong to a `StorageContext`, not a global registry.
-## Storage model
+## File and directory operations
```php
-use Infocyph\Pathwise\Storage\StorageFactory;
-use Infocyph\Pathwise\Utils\FlysystemHelper;
+use Infocyph\Pathwise\PathwiseFacade;
-StorageFactory::mount('assets', [
- 'driver' => 'local',
- 'root' => '/srv/storage/assets',
-]);
+$file = PathwiseFacade::at('/tmp/example.txt')->file();
+$file->create("v1\n")->append("v2\n");
-FlysystemHelper::write('assets://reports/a.txt', 'hello');
+$report = PathwiseFacade::at('/tmp/source')
+ ->directory()
+ ->syncTo('/tmp/backup', deleteOrphans: true);
```
-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.
+The facade is stateless convenience. Persistent storage topology belongs to `StorageContext`.
+
+## Framework-neutral uploads
+
+```php
+use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
+use Infocyph\Pathwise\StreamHandler\UploadProcessor;
+use Infocyph\Pathwise\StreamHandler\UploadSource;
+
+$uploader = new UploadProcessor();
+$uploader->setStorageContext($storage);
+$uploader->setDirectorySettings('primary://uploads', tempDir: sys_get_temp_dir());
+$uploader->setValidationProfile('document');
+$uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
+$uploader->setMalwareScanner($scanner);
+
+$source = UploadSource::fromMover(
+ mover: fn (string $target): void => $uploadedFile->moveTo($target),
+ clientFilename: $uploadedFile->getClientFilename() ?? 'upload.bin',
+ size: $uploadedFile->getSize(),
+ clientMediaType: $uploadedFile->getClientMediaType(),
+ error: $uploadedFile->getError(),
+);
-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.
+$path = $uploader->ingestSource($source);
+```
-See the `storage capability contract` in the documentation for the compatibility matrix, atomicity, locking, sync, native execution, archive security, and performance characteristics.
+Pathwise owns the staging file created for `UploadSource`, cleans it on success/failure, scans before content parsing, and fails closed when malware scanning is required.
-## Synchronization and result types
+## Secure downloads and ranges
```php
-use Infocyph\Pathwise\Core\SyncComparison;
-use Infocyph\Pathwise\DirectoryManager\DirectoryOperations;
+use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
+
+$downloads = new DownloadProcessor();
+$downloads->setStorageContext($storage);
+$downloads->setAllowedRoots(['primary://downloads']);
-$report = (new DirectoryOperations('/srv/source'))->syncTo(
- '/srv/target',
- deleteOrphans: true,
- comparison: SyncComparison::SIZE_AND_MODIFIED_TIME,
+$prepared = $downloads->prepareDownload(
+ 'primary://downloads/video.mp4',
+ rangeHeader: $_SERVER['HTTP_RANGE'] ?? null,
);
+
+foreach ($downloads->streamChunks($prepared) as $chunk) {
+ echo $chunk;
+}
```
-`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.
+`DownloadPreparation` carries status/headers/range metadata. `streamChunks()` revalidates the preparation, reads exactly the prepared range, and closes the source stream even when iteration ends early.
-## Secure archives
+## Durable local file queue
-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`. Default entry-count, per-entry size, total uncompressed-size, and compression-ratio limits mitigate ZIP bombs and can be configured explicitly.
+```php
+use Infocyph\Pathwise\Queue\FileJobQueue;
-`FileJobQueue` is direct-local-only and intended for bounded, lightweight single-host workloads—not as a remote or distributed broker.
+$queue = new FileJobQueue('/var/lib/app/jobs.json');
+$queue->enqueue('thumbnail', ['id' => 'asset-42']);
-## Auditing
+$reservation = $queue->reserve();
+if ($reservation !== null) {
+ // Work with $reservation->payload, renew long jobs when required.
+ $queue->acknowledge($reservation);
+}
+```
+
+The queue is intentionally direct-local: it uses typed opaque leases, stale-worker rejection, strict versioned state, locking, and crash-safe persistence. It is not a distributed broker.
+
+## Security model
+
+Pathwise 4 includes explicit controls for:
-`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.
+- extension/MIME/signature validation and optional malware scanning;
+- path/root restrictions, hidden-file blocking, safe symlink management;
+- ZIP manifest validation, traversal/collision/special-entry rejection and extraction limits;
+- bounded native commands with timeout/output ceilings and deterministic cleanup;
+- safe serialization boundaries that do not instantiate untrusted objects;
+- queue state size/payload/job limits and lease ownership;
+- policy enforcement, audit sinks, retention, indexing, and watcher workloads.
-## Native execution
+Security-sensitive behavior is fail-closed where a configured capability is required. Adapter/native/metadata capabilities remain explicit rather than silently emulated.
-`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; commands are executed as argument arrays without a shell, and execution results retain command, output, and exit code.
+## Documentation
-## Security
+The Sphinx documentation is the canonical user guide and is built in CI with warnings treated as errors. Start with:
-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.
+- `docs/quickstart.rst`
+- `docs/storage-context.rst`
+- `docs/upload-processing.rst`
+- `docs/download-processing.rst`
+- `docs/security.rst`
+- `docs/migration-4.0.rst`
+- `docs/api-reference.rst`
+- `docs/performance-portability.rst`
+
+## Development
+
+```bash
+composer install
+composer ic:test:code
+composer ic:qa
+```
-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.
+The release matrix covers PHP 8.4/8.5, stable and lowest dependencies, Windows, optional adapter contracts, static analysis/quality gates, clean install, documentation, and release workloads.
----
+## License
-
+MIT
diff --git a/benchmarks/Pathwise4ReleaseBench.php b/benchmarks/Pathwise4ReleaseBench.php
new file mode 100644
index 00000000..0a4dad79
--- /dev/null
+++ b/benchmarks/Pathwise4ReleaseBench.php
@@ -0,0 +1,356 @@
+baseDirectory = PathHelper::join(
+ sys_get_temp_dir(),
+ 'pathwise4_release_bench_' . bin2hex(random_bytes(8)),
+ );
+ $this->adapterRoot = PathHelper::join($this->baseDirectory, 'adapter');
+ $contextRoot = PathHelper::join($this->baseDirectory, 'context');
+ $this->dedupDirectory = PathHelper::join($this->baseDirectory, 'dedup');
+ $this->indexDirectory = PathHelper::join($this->baseDirectory, 'index');
+ $this->uploadDirectory = PathHelper::join($this->baseDirectory, 'uploads');
+ $this->uploadTempDirectory = PathHelper::join($this->baseDirectory, 'upload-temp');
+
+ foreach ([
+ $this->baseDirectory,
+ $this->adapterRoot,
+ $contextRoot,
+ $this->dedupDirectory,
+ $this->indexDirectory,
+ $this->uploadDirectory,
+ $this->uploadTempDirectory,
+ ] as $directory) {
+ if (!mkdir($directory, 0700, true) && !is_dir($directory)) {
+ throw new \RuntimeException("Unable to create release benchmark fixture: {$directory}");
+ }
+ }
+
+ $this->payloadFile = PathHelper::join($this->baseDirectory, 'payload.txt');
+ $this->largeFile = PathHelper::join($this->baseDirectory, 'large.bin');
+ $this->archivePath = PathHelper::join($this->baseDirectory, 'fixture.zip');
+
+ file_put_contents($this->payloadFile, str_repeat('pathwise-release\n', 65_536));
+ file_put_contents($this->largeFile, str_repeat('0123456789abcdef', 524_288));
+
+ $this->createIndexFixture();
+ $this->createDedupFixture();
+ $this->createArchiveFixture();
+
+ $this->storageContext = new StorageContext([
+ 'bench' => [
+ 'driver' => 'local',
+ 'root' => $contextRoot,
+ ],
+ ], 'bench');
+ $this->storageContext->filesystem();
+
+ FlysystemHelper::setDefaultFilesystem(
+ new Filesystem(new LocalFilesystemAdapter($this->adapterRoot)),
+ );
+ }
+
+ public function tearDown(): void
+ {
+ FlysystemHelper::reset();
+ $this->deleteLocalTree($this->baseDirectory);
+ }
+
+ public function benchAdapterStagedWriter(): void
+ {
+ $writer = new SafeFileWriter('staged-writer.bin');
+ $writer->writeBinary(str_repeat('r', 1_048_576));
+ $writer->close();
+ }
+
+ public function benchArchiveValidationAndExtraction(): void
+ {
+ $destination = PathHelper::join($this->baseDirectory, 'extract-' . bin2hex(random_bytes(4)));
+ $archive = new FileCompression($this->archivePath);
+ $archive->setExtractionLimits(
+ maxEntries: 500,
+ maxEntryUncompressedBytes: 1_048_576,
+ maxTotalUncompressedBytes: 16_777_216,
+ maxCompressionRatio: 1_000.0,
+ );
+ $archive->decompress($destination);
+ }
+
+ public function benchAtomicLocalWriter(): void
+ {
+ $writer = new SafeFileWriter(PathHelper::join($this->baseDirectory, 'atomic-writer.bin'));
+ $writer->enableAtomicWrite();
+ $writer->writeBinary(str_repeat('l', 1_048_576));
+ $writer->close();
+ }
+
+ public function benchChecksumIteration500Files(): void
+ {
+ $count = 0;
+ foreach (ChecksumIndexer::iterate($this->indexDirectory) as $_entry) {
+ $count++;
+ }
+
+ if ($count !== 500) {
+ throw new \RuntimeException("Release checksum benchmark expected 500 entries, got {$count}.");
+ }
+ }
+
+ public function benchDownloadRangeIteration(): void
+ {
+ $download = new DownloadProcessor();
+ $download->setChunkSize(65_536);
+ $preparation = $download->prepareDownload(
+ $this->largeFile,
+ rangeHeader: 'bytes=1048576-2097151',
+ );
+
+ $bytes = 0;
+ foreach ($download->streamChunks($preparation) as $chunk) {
+ $bytes += strlen($chunk);
+ }
+
+ if ($bytes !== 1_048_576) {
+ throw new \RuntimeException("Release download benchmark streamed {$bytes} bytes.");
+ }
+ }
+
+ public function benchHardLinkDeduplication200Files(): void
+ {
+ $result = ChecksumIndexer::deduplicateWithHardLinks($this->dedupDirectory);
+ if (count($result->linked) !== 100) {
+ throw new \RuntimeException('Release deduplication benchmark did not link every duplicate pair.');
+ }
+ }
+
+ public function benchMalwareStagingWithoutExternalEngine(): void
+ {
+ $uploader = new UploadProcessor();
+ $uploader->setDirectorySettings($this->uploadDirectory, false, $this->uploadTempDirectory);
+ $uploader->setExtensionPolicy(['txt']);
+ $uploader->setStrictContentTypeValidation(false);
+ $uploader->setValidationSettings([], 4_194_304);
+ $uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
+ $uploader->setMalwareScanner(new class implements MalwareScannerInterface {
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ if (!is_file($request->localPath)) {
+ throw new \RuntimeException('Release benchmark scanner did not receive a local file.');
+ }
+
+ return MalwareScanVerdict::CLEAN;
+ }
+ });
+
+ $uploader->ingestSource(UploadSource::fromPath(
+ $this->payloadFile,
+ 'payload.txt',
+ filesize($this->payloadFile) ?: null,
+ 'text/plain',
+ ));
+ }
+
+ public function benchNativeRunnerBoundedOverhead(): void
+ {
+ if (!NativeCommandRunner::supportsBoundedExecution()) {
+ return;
+ }
+
+ $result = NativeCommandRunner::run(
+ [PHP_BINARY, '-r', 'fwrite(STDOUT, "ok");'],
+ limits: new NativeExecutionLimits(
+ timeoutSeconds: 5.0,
+ stdoutBytes: 65_536,
+ stderrBytes: 65_536,
+ terminationGraceSeconds: 0.25,
+ pollIntervalMicroseconds: 1_000,
+ ),
+ );
+
+ if (!$result->success) {
+ throw new \RuntimeException('Release native-runner benchmark failed.');
+ }
+ }
+
+ public function benchQueueReserveReleaseRenewAcknowledge(): void
+ {
+ $queue = new FileJobQueue(PathHelper::join($this->baseDirectory, 'lease-queue.json'));
+ $queue->enqueue('release-benchmark', ['value' => 'x'], priority: 10);
+
+ $reservation = $queue->reserve();
+ if ($reservation === null) {
+ throw new \RuntimeException('Release queue benchmark could not reserve its job.');
+ }
+
+ $queue->release($reservation);
+ $reservation = $queue->reserve();
+ if ($reservation === null) {
+ throw new \RuntimeException('Release queue benchmark could not re-reserve its job.');
+ }
+
+ $reservation = $queue->renew($reservation);
+ $queue->acknowledge($reservation);
+ }
+
+ public function benchStorageContextHotResolve(): void
+ {
+ for ($index = 0; $index < 1_000; $index++) {
+ [$filesystem, $location] = $this->storageContext->resolve('bench://nested/file.txt');
+ if ($location !== 'nested/file.txt' || $filesystem !== $this->storageContext->filesystem('bench')) {
+ throw new \RuntimeException('StorageContext hot resolution returned an unexpected result.');
+ }
+ }
+ }
+
+ public function benchUploadStreamMaterialization(): void
+ {
+ $stream = fopen($this->payloadFile, 'rb');
+ if (!is_resource($stream)) {
+ throw new \RuntimeException('Unable to open release upload fixture.');
+ }
+
+ try {
+ $source = UploadSource::fromStream(
+ $stream,
+ 'payload.txt',
+ filesize($this->payloadFile) ?: null,
+ 'text/plain',
+ );
+ $materialization = $source->materialize($this->uploadTempDirectory);
+
+ try {
+ if ($materialization->size !== filesize($this->payloadFile)) {
+ throw new \RuntimeException('Upload materialization size mismatch.');
+ }
+ } finally {
+ $materialization->cleanup();
+ }
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ private function createArchiveFixture(): void
+ {
+ $archive = new ZipArchive();
+ if ($archive->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ throw new \RuntimeException('Unable to create release archive fixture.');
+ }
+
+ try {
+ for ($index = 0; $index < 250; $index++) {
+ if (!$archive->addFromString(
+ sprintf('folder/entry-%04d.txt', $index),
+ str_repeat((string) ($index % 10), 1_024),
+ )) {
+ throw new \RuntimeException('Unable to add release archive fixture entry.');
+ }
+ }
+ } finally {
+ $archive->close();
+ }
+ }
+
+ private function createDedupFixture(): void
+ {
+ for ($index = 0; $index < 100; $index++) {
+ $content = 'duplicate-group-' . $index . '-' . str_repeat((string) ($index % 10), 1_024);
+ file_put_contents(PathHelper::join($this->dedupDirectory, sprintf('a-%03d.bin', $index)), $content);
+ file_put_contents(PathHelper::join($this->dedupDirectory, sprintf('b-%03d.bin', $index)), $content);
+ }
+ }
+
+ private function createIndexFixture(): void
+ {
+ for ($index = 0; $index < 500; $index++) {
+ file_put_contents(
+ PathHelper::join($this->indexDirectory, sprintf('entry-%04d.txt', $index)),
+ str_repeat((string) ($index % 10), 1_024),
+ );
+ }
+ }
+
+ private function deleteLocalTree(string $path): void
+ {
+ if (!is_dir($path)) {
+ return;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+
+ foreach ($iterator as $entry) {
+ if (!$entry instanceof \SplFileInfo) {
+ continue;
+ }
+
+ if ($entry->isDir() && !$entry->isLink()) {
+ rmdir($entry->getPathname());
+ } else {
+ unlink($entry->getPathname());
+ }
+ }
+
+ rmdir($path);
+ }
+}
diff --git a/composer.json b/composer.json
index cb021e3a..8e888d39 100644
--- a/composer.json
+++ b/composer.json
@@ -12,7 +12,8 @@
"require": {
"php": ">=8.4",
"ext-fileinfo": "*",
- "league/flysystem": "^3.35.2"
+ "league/flysystem": "^3.35.2",
+ "psr/log": "^3.0.2"
},
"require-dev": {
"infocyph/phpforge": "dev-main@dev"
@@ -51,5 +52,9 @@
},
"optimize-autoloader": true,
"sort-packages": true
+ },
+ "scripts": {
+ "benchmark:release": "@php vendor/bin/phpbench run benchmarks/Pathwise4ReleaseBench.php --report=aggregate --output=json --progress=none --bootstrap=vendor/autoload.php --revs=1 --iterations=1",
+ "stress:release": "@php vendor/bin/phpbench run benchmarks/stress/ReleaseStress.php --report=aggregate --progress=none --bootstrap=vendor/autoload.php --revs=1 --iterations=1"
}
}
diff --git a/docs/api-reference.rst b/docs/api-reference.rst
new file mode 100644
index 00000000..35fcf05e
--- /dev/null
+++ b/docs/api-reference.rst
@@ -0,0 +1,338 @@
+Pathwise 4 API and Error Reference
+==================================
+
+This page is the compact map of the public 4.0 surface. Feature guides remain
+the source for workflow semantics and examples.
+
+Facade and Core Types
+---------------------
+
+``Infocyph\Pathwise\PathwiseFacade``
+ Stateless convenience entry point for file/directory/compression/read/write,
+ upload/download, policy, queue, audit, retention, watcher and indexing
+ helpers. It does **not** own persistent storage topology.
+
+``Infocyph\Pathwise\Core\ExecutionStrategy``
+ Native/PHP execution strategy enum used by operations that can select an
+ implementation path.
+
+``Infocyph\Pathwise\Core\SyncComparison``
+ Directory synchronization comparison enum.
+
+Storage
+-------
+
+``Storage\StorageContext``
+ Instance-scoped named filesystem registry/resolver. Key methods:
+ ``filesystem()``, ``configuration()``, ``filesystemNames()``,
+ ``defaultFilesystem()``, ``resolve()``, ``path()``, ``localPath()``,
+ ``isLocal()``, ``hasFilesystem()``, ``hasDriver()``.
+
+``Storage\StorageFactory``
+ Stateless Flysystem constructor/driver metadata. Key methods:
+ ``createFilesystem()``, ``officialDrivers()``, ``isOfficialDriver()``,
+ ``suggestedPackage()``.
+
+File Management
+---------------
+
+``FileManager\FileOperations``
+ General file lifecycle, checksums, policy/audit hooks, copy/move/delete and
+ transactional operations.
+
+``FileManager\SafeFileReader``
+ Bounded/locked safe reader workflows.
+
+``FileManager\SafeFileWriter``
+ Safe writing/append/verification and write-oriented transaction behavior.
+
+``FileManager\FileCompression``
+ ZIP compression/decompression with archive validation, filters, progress and
+ bounded native/PHP execution paths.
+
+``FileManager\FileTransactionJournal``
+ Direct-local transaction journal/rollback support used by transactional file
+ mutation. Rollback failure is explicit.
+
+``FileManager\SafeSymlinkManager``
+ Direct-local safe symbolic-link create/remove/status operations with allowed
+ link and target roots.
+
+See :doc:`file-manager` and :doc:`symlink-management`.
+
+Directories
+-----------
+
+``DirectoryManager\DirectoryOperations``
+ Directory creation/listing/copy/move/delete, synchronization and ZIP
+ workflows. Synchronization returns ``Results\SyncReport``.
+
+See :doc:`directory-manager`.
+
+Uploads and Malware Scanning
+----------------------------
+
+``StreamHandler\UploadProcessor``
+ Upload validation/publication and resumable chunk workflow. Important
+ methods include ``setStorageContext()``, ``setDirectorySettings()``,
+ ``processUpload()``, ``ingestFile()``, ``ingestSource()``,
+ ``processChunkUpload()``, ``processChunkUploadSource()``,
+ ``finalizeChunkUpload()``, validation/scanner/extension/chunk setters, and
+ ``getInfo()``.
+
+``StreamHandler\UploadSource``
+ Framework-neutral source factory: ``fromMover()``, ``fromPath()``,
+ ``fromStream()``.
+
+``StreamHandler\UploadMaterialization``
+ Owned staging representation used to carry materialized upload metadata and
+ deterministic cleanup.
+
+``StreamHandler\MalwareScannerInterface``
+ Scanner contract receiving ``MalwareScanRequest`` and returning
+ ``MalwareScanVerdict``.
+
+``StreamHandler\MalwareScannerProviderInterface``
+ Optional provider identity contract surfaced through uploader status/info.
+
+``StreamHandler\MalwareScanRequest``
+ Immutable local scan request metadata.
+
+``StreamHandler\MalwareScanMode``
+ ``OFF``, ``WHEN_CONFIGURED``, ``REQUIRED``.
+
+``StreamHandler\MalwareScanStatus``
+ Runtime configuration/readiness status exposed by uploader info.
+
+``StreamHandler\MalwareScanVerdict``
+ Scanner verdict enum; only ``CLEAN`` is accepted for publication.
+
+``StreamHandler\Scanner\ClamAvDaemonScanner``
+ Bounded ClamAV daemon scanner implementation.
+
+See :doc:`upload-processing` and :doc:`malware-scanning`.
+
+Downloads
+---------
+
+``StreamHandler\DownloadProcessor``
+ Secure metadata/range preparation and streaming. Important methods:
+ ``setStorageContext()``, ``setAllowedRoots()``, policy setters,
+ ``prepareDownload()``, ``streamChunks()``, ``streamDownload()``.
+
+See :doc:`download-processing`.
+
+Queue
+-----
+
+``Queue\FileJobQueue``
+ Direct-local durable file queue. Entry points: ``enqueue()``, ``reserve()``,
+ ``renew()``, ``acknowledge()``, ``release()``, ``fail()``, ``process()``,
+ ``stats()``.
+
+``Queue\QueueReservation``
+ Typed opaque lease returned by ``reserve()`` and renewal.
+
+``Queue\FileQueueStateStore``
+ Direct-local locked/versioned/crash-safe queue state store used by the queue.
+
+See :doc:`queue`.
+
+Observability
+-------------
+
+``Observability\AuditSink``
+ Sink interface for structured audit events.
+
+``Observability\AuditTrail``
+ Audit event entry point; accepts a local JSONL path or an ``AuditSink``.
+
+``Observability\LocalJsonlAuditSink``
+ Direct-local locked JSONL sink with bounded event behavior.
+
+``Observability\CallbackAuditSink``
+ Adapter sink for application callbacks/observability pipelines.
+
+``Observability\PartitionedAuditSink``
+ Partitioning wrapper for bounding individual audit files/segments.
+
+See :doc:`observability`.
+
+Security and Archives
+---------------------
+
+``Security\PolicyEngine``
+ Deny-by-default path/operation policy with allow/deny rules, optional
+ conditions, and last-match-wins evaluation.
+
+``Security\ZipEntryValidator``
+ Archive-entry normalization and safety/limit validation.
+
+``Security\ZipArchiveManifestEntry``
+ Validated immutable ZIP manifest entry metadata.
+
+``Security\ZipArchiveExtractor``
+ Manifest-driven extraction with collision/type/size/ratio/write-time checks.
+
+See :doc:`security`.
+
+Indexing, Retention and Watchers
+--------------------------------
+
+``Indexing\ChecksumIndexer``
+ Content-hash indexing, duplicate detection, and local hard-link
+ deduplication.
+
+``Retention\RetentionManager``
+ Bounded retention by count/age/sort policy.
+
+``Utils\FileWatcher``
+ Snapshot/diff/polling watch workflows with typed ``SnapshotDiff`` and
+ ``WatchResult`` results.
+
+See :doc:`indexing`, :doc:`retention`, and :doc:`utilities`.
+
+Native Execution
+----------------
+
+``Native\NativeCommandRunner``
+ Non-blocking bounded argv execution with deterministic termination/cleanup.
+
+``Native\NativeExecutionLimits``
+ Immutable timeout/output/grace/poll limits.
+
+``Native\NativeExecutionFailure``
+ Typed native failure classification.
+
+``Native\NativeOperationsAdapter``
+ Capability-aware native filesystem/archive operation adapter.
+
+See :doc:`native-execution`.
+
+Utilities
+---------
+
+``Utils\PathHelper``
+ Path normalization/join/validation/relative/temp helpers.
+
+``Utils\FlysystemHelper``
+ Low-level storage-neutral helper with direct-local/default/mount routing.
+ It is not the recommended persistent-runtime topology registry; use
+ ``StorageContext`` for that role.
+
+``Utils\FlysystemPathResolver``
+ Low-level Flysystem path resolution support.
+
+``Utils\MetadataHelper``
+ Metadata/MIME/ownership/permission helpers across explicit capabilities.
+
+``Utils\PermissionsHelper``
+ Local permission operations and normalization.
+
+``Utils\ExtensionPolicy``
+ Shared extension allow/block validation.
+
+``Utils\ReadablePathLocalizer``
+ Localizes readable adapter-backed data when a local-only consumer requires a
+ real path, with explicit cleanup ownership.
+
+``Utils\StreamTransferHelper``
+ Stream-copy helper for bounded storage transfers.
+
+``Utils\SerializedValueValidator``
+ Defensive serialized-value validation without untrusted object
+ instantiation.
+
+``Utils\LocalFileIterator``
+ Local iteration helper used by filesystem traversal workloads.
+
+``Utils\Ownership\OwnershipResolverInterface``
+ Ownership lookup contract.
+
+``Utils\Ownership\OwnershipResolverFactory``
+ Platform-capability resolver selection.
+
+``Utils\Ownership\PosixOwnershipResolver``
+ POSIX ownership implementation when the capability is available.
+
+``Utils\Ownership\WindowsOwnershipResolver``
+ Windows ownership implementation.
+
+``Utils\Ownership\FallbackOwnershipResolver``
+ Capability-safe fallback metadata resolver.
+
+Typed Result Objects
+--------------------
+
+Pathwise 4 exposes these immutable/typed workflow results:
+
+``Results\ChunkUploadState``
+ ``uploadId``, ``receivedChunks``, ``totalChunks``, ``complete``.
+
+``Results\DeduplicationResult``
+ Hard-link deduplication outcome including linked/skipped entries.
+
+``Results\DownloadPreparation``
+ Path/name/MIME/size/mtime/ETag/status/range/headers.
+
+``Results\RangeDownloadMetadata``
+ Range start/end/content length/partial state.
+
+``Results\DownloadStreamResult``
+ Preparation plus ``bytesSent``.
+
+``Results\NativeExecutionResult``
+ Native command completion/output/result metadata.
+
+``Results\QueueProcessResult``
+ Processed/failed counts for ``FileJobQueue::process()``.
+
+``Results\RetentionResult``
+ Retention kept/deleted outcome.
+
+``Results\SnapshotDiff``
+ Created/modified/deleted snapshot changes.
+
+``Results\SymlinkStatus``
+ Link existence/validity/target status.
+
+``Results\SyncReport``
+ Directory synchronization created/updated/deleted outcome.
+
+``Results\WatchResult``
+ File-watcher execution/change outcome.
+
+Exception Hierarchy
+-------------------
+
+Pathwise-specific failures derive from ``Exceptions\PathwiseException`` where
+appropriate. Public exception types are:
+
+* ``AuditException`` — audit sink/event persistence failure;
+* ``CompressionException`` — compression/archive workflow failure;
+* ``DirectoryOperationException`` — directory operation/sync failure;
+* ``DownloadException`` — download policy/range/stream failure;
+* ``FileAccessException`` — file access/read/write failure;
+* ``FileNotFoundException`` — required path missing;
+* ``FileSizeExceededException`` — configured size limit exceeded;
+* ``InvalidPathException`` — invalid/unsafe path;
+* ``MalwareScannerException`` — scanner implementation/protocol failure;
+* ``MissingExtensionException`` — required PHP extension missing;
+* ``NativeExecutionException`` — bounded native execution failure;
+* ``PolicyViolationException`` — policy rejected an operation;
+* ``QueueException`` — queue state/lease/durability failure;
+* ``StorageCapabilityException`` — storage lacks a required capability;
+* ``TransactionRollbackException`` — rollback itself failed;
+* ``TransactionStateException`` — invalid transaction lifecycle/state;
+* ``UnsafeArchiveEntryException`` — unsafe ZIP manifest/entry;
+* ``UnsupportedStorageOperationException`` — operation cannot be represented by
+ the selected storage;
+* ``UploadException`` — upload validation/materialization/scanner/publication
+ failure.
+
+Standard ``InvalidArgumentException``/``UnexpectedValueException`` are also used
+for programmer/configuration errors where a Pathwise operational exception
+would be misleading.
+
+Failure handling should depend on exception type and stable semantics, not on
+backend-specific message strings.
diff --git a/docs/capabilities.rst b/docs/capabilities.rst
index 8e59375c..018714cd 100644
--- a/docs/capabilities.rst
+++ b/docs/capabilities.rst
@@ -1,141 +1,155 @@
Capabilities
============
-This page answers one question: **what does Pathwise include today?**
+Pathwise 4 combines storage-neutral filesystem I/O with explicit local-only
+capabilities and hardened workflow primitives.
-At a Glance
------------
+Runtime Model
+-------------
-Pathwise combines two layers:
+* ``PathwiseFacade`` is stateless convenience for common operations.
+* ``StorageFactory`` is a stateless Flysystem constructor/driver metadata helper.
+* ``StorageContext`` owns persistent named storage topology for one application
+ runtime/generation.
+* Direct absolute paths remain local. Context relative/scheme paths resolve
+ through the injected context.
+* Low-level ``FlysystemHelper`` routing exists for standalone utility use, not
+ as the recommended multi-application registry.
-* Storage-safe file operations (local paths and mounted scheme paths).
-* Higher-level workflows (upload pipeline, compression, retention, queue, audit, policy).
+File and Directory Operations
+-----------------------------
-Primary Modules
----------------
+``Infocyph\Pathwise\FileManager``
+ ``FileOperations``, ``SafeFileReader``, ``SafeFileWriter``,
+ ``FileCompression``, ``SafeSymlinkManager`` and local transaction support.
-Unified Facade (``Infocyph\Pathwise\PathwiseFacade``)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+``Infocyph\Pathwise\DirectoryManager``
+ ``DirectoryOperations`` for recursive lifecycle, listing/filtering, sync and
+ archive workflows with typed ``SyncReport`` output.
-Class:
+Capabilities include checksums, verified writes/copies, locking, atomic local
+replacement, safe rollback, ZIP hardening, filters/progress, and explicit
+storage/platform capability failures.
-* ``PathwiseFacade``
+Storage
+-------
-What you get:
+``Infocyph\Pathwise\Storage``
+ ``StorageContext`` and ``StorageFactory``.
-* One path-bound entry to ``FileOperations``, ``DirectoryOperations``,
- ``SafeFileReader``, ``SafeFileWriter`` and ``FileCompression``.
-* Static gateways for ``UploadProcessor``, ``DownloadProcessor``,
- ``StorageFactory``, ``PolicyEngine``, ``FileJobQueue``, ``AuditTrail``,
- ``RetentionManager``, ``ChecksumIndexer`` and ``FileWatcher``.
+Flysystem adapter construction covers local, FTP, memory, read-only,
+path-prefixing, S3 variants, Azure, Google Cloud Storage, GridFS, SFTP, WebDAV
+and ZIP adapter packages when installed. Custom driver factories are scoped to
+one ``StorageContext``.
-File IO (``Infocyph\Pathwise\FileManager``)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Uploads
+-------
-Classes:
+``UploadProcessor`` provides:
-* ``FileOperations``
-* ``SafeFileReader``
-* ``SafeFileWriter``
-* ``FileCompression``
+* genuine PHP HTTP upload provenance checking;
+* trusted application/CLI ingestion;
+* framework-neutral ``UploadSource`` mover/path/stream ingestion;
+* private owned staging and deterministic cleanup;
+* validation profiles, MIME/size/extension/signature/image checks;
+* hash/timestamp naming with deterministic collision checks;
+* resumable chunk manifests/state/finalization;
+* typed malware scanner contracts and explicit scan modes;
+* direct ``StorageContext`` integration.
-What you get:
+Downloads
+---------
-* Create/read/update/delete, stream reads/writes, checksum verify/copy verify.
-* Atomic-safe writer mode, lock support, structured read/write helpers.
-* ZIP workflows with password/encryption, include/exclude patterns, progress callbacks.
+``DownloadProcessor`` provides:
-Directory Workflows (``Infocyph\Pathwise\DirectoryManager``)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+* allowed-root/extension/hidden-file/max-size policy;
+* safe filenames and response-oriented metadata;
+* byte range parsing and typed ``DownloadPreparation``;
+* range-aware iterable ``streamChunks()`` with source cleanup;
+* direct output-stream copying through ``streamDownload()``;
+* stale-preparation revalidation;
+* direct ``StorageContext`` integration.
-Class:
+Security
+--------
-* ``DirectoryOperations``
+``PolicyEngine``
+ Deny-by-default path/operation policy with conditions and last-match-wins
+ rules.
-What you get:
+Archive security
+ Manifest-driven ZIP validation/extraction with path, collision, entry-type,
+ size, compression-ratio, source-symlink and write-time checks.
-* Idempotent create, recursive copy/move/delete.
-* Listing, flattening, find/filter, size/depth metrics.
-* Lazy directory sync with readonly ``SyncReport`` and explicit comparison strategy.
-* Zip/unzip helpers for local and mounted paths.
+Symbolic links
+ ``SafeSymlinkManager`` validates allowed link/target roots and existing
+ targets for direct-local link lifecycle.
-Uploads (``Infocyph\Pathwise\StreamHandler``)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Serialization
+ Defensive serialized-value validation without object instantiation.
-Class:
+Native execution
+ Bounded argv execution with timeout/output limits and deterministic
+ termination/cleanup.
-* ``UploadProcessor``
+Operations and Data Management
+------------------------------
-What you get:
+``Queue\FileJobQueue``
+ Direct-local durable queue with typed opaque leases, renewal, stale-worker
+ rejection, strict versioned state and crash-safe persistence.
-* Standard upload handling and destination strategy.
-* Validation presets (image/video/document), MIME and size rules.
-* Chunked/resumable upload flow.
-* Extension allowlist/blocklist controls.
-* Upload ID validation for chunk/session identifiers.
-* Strict content checks (MIME-extension agreement and file signature checks).
-* Optional or required malware scan callback.
+``Observability``
+ ``AuditTrail`` plus local JSONL, callback and partitioned sinks.
-Downloads (``Infocyph\Pathwise\StreamHandler``)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+``Indexing\ChecksumIndexer``
+ Content index, duplicate detection and local hard-link deduplication.
-Class:
+``Retention\RetentionManager``
+ Count/age-based retention with typed result.
-* ``DownloadProcessor``
+``Utils\FileWatcher``
+ Snapshot, diff and bounded polling watcher behavior.
-What you get:
+Typed Results
+-------------
-* Secure download metadata generation for HTTP adapters.
-* Extension allowlist/blocklist controls.
-* Allowed-root restriction to prevent path breakout.
-* Hidden-file blocking and max-size limits.
-* Optional range request handling and partial-download metadata.
-* Stream copy into caller-provided output resources.
+Important public results include ``ChunkUploadState``, ``DownloadPreparation``,
+``RangeDownloadMetadata``, ``DownloadStreamResult``, ``QueueProcessResult``,
+``SyncReport``, ``SymlinkStatus``, ``SnapshotDiff``, ``WatchResult``,
+``RetentionResult``, ``DeduplicationResult`` and ``NativeExecutionResult``.
-Security and Operations
-^^^^^^^^^^^^^^^^^^^^^^^
+Local-Only Capabilities
+-----------------------
-Classes:
+Pathwise does not pretend that object storage can provide OS primitives. These
+remain direct-local/platform dependent:
-* ``PolicyEngine`` (allow/deny + conditions)
-* ``AuditTrail`` (JSONL audit logging)
-* ``FileJobQueue`` (file-backed queue)
-* ``ChecksumIndexer`` (duplicate/index workflows)
-* ``RetentionManager`` (keep-last and age-based cleanup)
-* ``FileWatcher`` (snapshot/diff/watch)
+* ``flock`` queue/session coordination;
+* native subprocess paths;
+* POSIX/Windows ownership operations;
+* symlink/hard-link semantics;
+* local transaction/atomic rename guarantees.
-Storage and Path Model
-----------------------
+See :doc:`performance-portability` for the complete capability table.
-Pathwise accepts:
-
-* Local paths (absolute or relative).
-* Mounted scheme paths like ``assets://images/logo.png``.
-
-Mounting is done through ``FlysystemHelper::mount()``. Once mounted, most
-high-level modules can use the scheme path directly.
-
-For config-driven adapter bootstrap, use
-``Infocyph\Pathwise\Storage\StorageFactory`` (see ``storage-adapters``).
-
-Runtime and Extensions
-----------------------
+Requirements
+------------
Required:
* PHP 8.4+
-* ``league/flysystem`` 3.x
* ``ext-fileinfo``
+* ``league/flysystem`` 3.x
+* ``psr/log`` 3.x
-Optional:
-
-* ``ext-zip`` (archive features)
-* ``ext-posix`` (richer Unix ownership data)
-* ``ext-xmlreader``, ``ext-simplexml`` (XML helpers)
+Optional extensions/adapters are installed only for selected capabilities.
-What to Read Next
------------------
+Read Next
+---------
-* ``file-facade`` for unified usage style.
-* ``quickstart`` for first-use examples.
-* ``recipes`` for end-to-end flows.
+* :doc:`quickstart`
+* :doc:`storage-context`
+* :doc:`storage-adapters`
+* :doc:`security`
+* :doc:`api-reference`
diff --git a/docs/conf.py b/docs/conf.py
index 6188155b..d9f247e2 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -33,6 +33,7 @@
myst_heading_anchors = 3
autosectionlabel_prefix_document = True
todo_include_todos = False
+exclude_patterns = ["_build", "plans/**"]
html_theme = "sphinx_book_theme"
html_theme_options = {
diff --git a/docs/directory-manager.rst b/docs/directory-manager.rst
index d64aab29..67ca1be1 100644
--- a/docs/directory-manager.rst
+++ b/docs/directory-manager.rst
@@ -3,30 +3,56 @@ Directory Manager
Namespace: ``Infocyph\Pathwise\DirectoryManager``
-Where it fits:
+Use ``DirectoryOperations`` for folder-level workflows such as recursive
+copy/move/delete, discovery, synchronization and archive publication.
-* Use this module when working with folder-level workflows like mirroring,
- recursive copies, reporting, and archive staging.
+Capabilities
+------------
-``DirectoryOperations`` provides directory-level workflows:
+``DirectoryOperations`` provides:
-* Idempotent ``create()``.
-* Recursive ``copy()``, ``move()``, ``delete()``.
-* Listing and discovery: ``listContents()``, ``flatten()``, ``find()``.
-* Metrics and structure helpers: ``size()``, ``getDepth()``.
-* Lazy sync API returning ``SyncReport`` with configurable ``SyncComparison``.
-* Archive helpers: ``zip()`` and ``unzip()``.
+* idempotent ``create()``;
+* recursive ``copy()``, ``move()`` and ``delete()``;
+* listing/discovery through ``listContents()``, ``flatten()`` and ``find()``;
+* metrics/structure helpers such as ``size()`` and ``getDepth()``;
+* lazy ``syncTo()`` returning the typed ``SyncReport``;
+* explicit ``SyncComparison`` strategies;
+* ZIP helpers through ``zip()`` and ``unzip()``;
+* optional ``ExecutionStrategy`` native acceleration for supported direct-local
+ operations.
-Flysystem-aware behavior:
+Storage Semantics
+-----------------
-* 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.
+Storage-neutral operations work with direct local paths and adapter-backed
+paths when the selected Flysystem adapter provides the required capability.
+POSIX permissions, direct iterators/handles, transactions, and native process
+execution remain direct-local capabilities.
-Native acceleration:
+For application-owned named storage, resolve topology through
+``StorageContext``. Low-level static/default/mount routing remains a separate
+standalone utility surface; a logical adapter path is not treated as a native
+local path merely because its implementation happens to use local disk.
-* Optional via ``ExecutionStrategy`` for local copy/zip/unzip paths.
+Archive operations may localize/stream adapter-backed data through Pathwise-owned
+temporary state when a local ZIP implementation requires it. The same hardened
+archive manifest validation and publication checks apply before extracted data
+is committed.
+
+Synchronization
+---------------
+
+``syncTo()`` traverses source listings lazily and returns ``SyncReport``.
+Comparison modes are explicit through ``SyncComparison``:
+
+* size + modified time for efficient direct-local synchronization;
+* size-only for the portable adapter-backed default;
+* checksum for integrity-first comparison with additional I/O;
+* always-copy when comparison should be bypassed.
+
+Progress totals may be ``null`` when calculating them would require buffering or
+a second traversal. Deleting destination orphans requires destination
+materialization/reverse ordering so child paths are removed before parents.
Example
-------
@@ -38,5 +64,14 @@ Example
$ops = new DirectoryOperations('/tmp/source');
$ops->create();
- $diff = $ops->syncTo('/tmp/target', deleteOrphans: true);
+ $report = $ops->syncTo('/tmp/target', deleteOrphans: true);
$ops->zip('/tmp/source.zip');
+
+ foreach ($report->created as $path) {
+ // Observe the synchronization result.
+ }
+
+Native acceleration is an optimization, not a different correctness model.
+``AUTO`` falls back when the capability is unavailable; forced ``NATIVE`` fails
+explicitly. See :doc:`native-execution`, :doc:`storage-contracts`, and
+:doc:`performance-portability`.
diff --git a/docs/download-processing.rst b/docs/download-processing.rst
index e2acbe92..6777ca00 100644
--- a/docs/download-processing.rst
+++ b/docs/download-processing.rst
@@ -3,59 +3,128 @@ Download Processing
Namespace: ``Infocyph\Pathwise\StreamHandler``
-Where it fits:
+``DownloadProcessor`` separates secure download preparation from body delivery.
+That makes it suitable for framework adapters without making Pathwise an HTTP
+response implementation.
-* Use this module when you need secure download metadata and controlled stream
- delivery for local or mounted filesystems.
+Core Flow
+---------
-``DownloadProcessor`` supports:
+1. Configure storage and policy.
+2. Call ``prepareDownload()`` to validate the path and produce typed metadata.
+3. Bridge ``DownloadPreparation::status`` and ``headers`` to the framework.
+4. Stream exactly that preparation through ``streamChunks()`` or use
+ ``streamDownload()`` for a writable PHP resource.
-* Download metadata generation with headers suitable for HTTP adapters.
-* Safe download filename handling for ``Content-Disposition``.
-* Extension allowlist/blocklist controls.
-* Allowed-root restrictions to prevent serving files outside trusted paths.
-* Hidden-file blocking.
-* Optional max download size enforcement.
-* Optional range requests with byte-range parsing and partial metadata.
-* Stream copy to caller-provided output resource.
-* Mounted/default filesystem paths (e.g. ``s3://...``) via Flysystem routing.
+.. code-block:: php
+
+ use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
+
+ $downloads = new DownloadProcessor();
+ $downloads->setStorageContext($storage);
+ $downloads->setAllowedRoots(['objects://downloads']);
+ $downloads->setExtensionPolicy(['pdf', 'zip', 'mp4']);
+
+ $prepared = $downloads->prepareDownload(
+ path: 'objects://downloads/video.mp4',
+ downloadName: 'video.mp4',
+ rangeHeader: $_SERVER['HTTP_RANGE'] ?? null,
+ );
+
+ foreach ($downloads->streamChunks($prepared) as $chunk) {
+ echo $chunk;
+ }
+
+StorageContext Integration
+--------------------------
+
+``setStorageContext()`` gives the processor an instance-owned resolver. Configure
+it before path/root policy.
-Security controls
+* direct absolute paths remain local;
+* relative paths use the context default filesystem;
+* ``name://path`` selects a configured context filesystem;
+* no global mount is registered.
+
+The same logical storage name can therefore be used by processors belonging to
+different applications in one process without cross-talk.
+
+Security Controls
-----------------
-``DownloadProcessor`` exposes explicit hardening options:
+``DownloadProcessor`` exposes:
-* ``setAllowedRoots(array $roots)``
-* ``setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = [])``
-* ``setBlockHiddenFiles(bool $block = true)``
-* ``setMaxDownloadSize(int $maxDownloadSize = 0)``
-* ``setRangeRequestsEnabled(bool $enabled = true)``
-* ``setForceAttachment(bool $enabled = true)``
-* ``setDefaultDownloadName(string $name)``
-* ``setChunkSize(int $chunkSize)``
+* ``setAllowedRoots(array $roots)``;
+* ``setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = [])``;
+* ``setBlockHiddenFiles(bool $block = true)``;
+* ``setMaxDownloadSize(int $maxDownloadSize = 0)``;
+* ``setRangeRequestsEnabled(bool $enabled = true)``;
+* ``setForceAttachment(bool $enabled = true)``;
+* ``setDefaultDownloadName(string $name)``;
+* ``setChunkSize(int $chunkSize)``.
-Examples
---------
+Hidden files are blocked by default. The default blocked extension set covers
+common executable/server-side script types. Allowed-root checks compare paths
+inside the same resolved filesystem; a path on another context filesystem
+cannot satisfy a root merely by sharing a textual prefix.
-Prepare secure metadata:
+DownloadPreparation
+-------------------
-.. code-block:: php
+``prepareDownload()`` returns ``DownloadPreparation`` containing:
- use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
+* canonical Pathwise path;
+* safe download filename;
+* MIME type;
+* size and last-modified metadata;
+* weak ETag;
+* status (``200`` or ``206``);
+* ``RangeDownloadMetadata``;
+* response-oriented headers.
- $downloads = new DownloadProcessor();
- $downloads->setAllowedRoots(['/srv/app/downloads']);
- $downloads->setExtensionPolicy(['pdf', 'zip'], ['php', 'phar', 'exe']);
+Headers include safe ``Content-Disposition``, ``Content-Length``,
+``Content-Type``, ``Last-Modified``, ``ETag``, ``Accept-Ranges``,
+``Cache-Control`` and ``X-Content-Type-Options``. Framework/application code
+still owns conditional request policy such as If-None-Match/If-Modified-Since.
+
+Range Semantics
+---------------
+
+Pathwise accepts one byte range in the standard ``bytes=start-end``, open-ended,
+or suffix form. Invalid/unsatisfiable ranges raise ``DownloadException`` rather
+than silently degrading to a full response.
+
+``RangeDownloadMetadata`` exposes the resolved start/end, content length, and
+partial flag. Empty files have a zero content length and no numeric start/end.
- $manifest = $downloads->prepareDownload(
- path: '/srv/app/downloads/report.pdf',
- downloadName: 'monthly-report.pdf',
- rangeHeader: null,
+Prepared Streaming and Revalidation
+-----------------------------------
+
+``streamChunks(DownloadPreparation $preparation)`` opens the source lazily,
+positions seekable streams directly or discards bytes on non-seekable streams,
+reads no more than the prepared range, and closes the source in a ``finally``
+block. Disposing the generator early therefore closes its input resource.
+
+A preparation is metadata, **not** an authorization capability. Before opening
+the body Pathwise re-applies current path/root/hidden-file/extension/max-size
+policy and verifies that current size/last-modified metadata still matches the
+preparation. Stale or manually-constructed preparations cannot bypass policy.
+
+.. code-block:: php
+
+ $prepared = $downloads->prepareDownload(
+ '/srv/app/downloads/report.pdf',
+ 'monthly-report.pdf',
);
- // Use $manifest->status, $manifest->headers, and $manifest->range.
+ foreach ($downloads->streamChunks($prepared) as $chunk) {
+ // yield/write chunk to your framework response
+ }
+
+Direct Output Stream
+--------------------
-Stream output with range support:
+``streamDownload()`` shares the same preparation and chunk-streaming core:
.. code-block:: php
@@ -68,19 +137,19 @@ Stream output with range support:
rangeHeader: $_SERVER['HTTP_RANGE'] ?? null,
);
- // $result->preparation contains status, headers, and range metadata.
- // $result->bytesSent is the number of bytes written to the output stream.
+ // $result->preparation
+ // $result->bytesSent
-Mounted storage example:
+Writes are completed fully or fail with ``DownloadException``; partial
+``fwrite()`` results are retried until the current chunk is complete.
-.. code-block:: php
+Framework Boundary
+------------------
- use Infocyph\Pathwise\Storage\StorageFactory;
- use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
-
- StorageFactory::mount('s3', ['adapter' => $myS3Adapter]);
-
- $downloads = new DownloadProcessor();
- $downloads->setAllowedRoots(['s3://downloads']);
+Pathwise owns filesystem/range mechanics. An HTTP framework owns request
+conditionals, response object creation, server offload features such as
+X-Sendfile/X-Accel-Redirect, and connection lifecycle. This boundary is
+intentional and is the integration model used by Foundation 3.
- $manifest = $downloads->prepareDownload('s3://downloads/report.pdf');
+See :doc:`storage-context`, :doc:`security`, and
+:doc:`performance-portability`.
diff --git a/docs/file-facade.rst b/docs/file-facade.rst
index 312935f1..9904424f 100644
--- a/docs/file-facade.rst
+++ b/docs/file-facade.rst
@@ -3,18 +3,10 @@ Unified Pathwise Facade
Namespace: ``Infocyph\Pathwise``
-Pathwise provides ``PathwiseFacade`` as a convenience facade when you want one entry
-point instead of importing many classes directly.
-
-Use this when:
-
-* you want path-bound access to file/directory/compression/read/write APIs
-* you want static gateways for upload/download/storage/policy/queue/audit/etc.
-
-Keep direct classes when:
-
-* you prefer explicit class-level imports for large codebases
-* you need very focused dependencies per module
+``PathwiseFacade`` is a **stateless convenience facade** in Pathwise 4. It is
+useful for compact direct-local operations and factory-style access to common
+workflow objects. Persistent storage topology belongs to
+``Storage\StorageContext`` instead.
Path-Bound Access
-----------------
@@ -27,8 +19,7 @@ Path-Bound Access
$entry->file()->create('hello')->append("\nworld");
- $reader = $entry->reader();
- foreach ($reader->lines() as $line) {
+ foreach ($entry->reader()->lines() as $line) {
// ...
}
@@ -38,12 +29,14 @@ Path-Bound Access
$metadata = $entry->metadata();
-Directory + Compression via Same Entry
---------------------------------------
+Path-bound methods include ``file()``, ``directory()``, ``compression()``,
+``reader()``, ``writer()``, ``exists()``, ``metadata()``, ``mimeType()`` and
+``path()``.
-.. code-block:: php
+Directory + Compression
+-----------------------
- use Infocyph\Pathwise\PathwiseFacade;
+.. code-block:: php
PathwiseFacade::at('/tmp/source')->directory()->create();
@@ -52,46 +45,44 @@ Directory + Compression via Same Entry
->compress('/tmp/source')
->save();
-Static Gateways
----------------
+Static Convenience
+------------------
.. code-block:: php
- use Infocyph\Pathwise\PathwiseFacade;
-
$upload = PathwiseFacade::upload();
$download = PathwiseFacade::download();
$policy = PathwiseFacade::policy();
$queue = PathwiseFacade::queue('/tmp/jobs.json');
$audit = PathwiseFacade::audit('/tmp/audit.jsonl');
-Storage from Facade
--------------------
+Other stateless helpers include:
-``PathwiseFacade`` delegates storage creation/mounting to ``StorageFactory``.
-
-.. code-block:: php
+* ``createFilesystem(array $config)`` — delegates to ``StorageFactory``;
+* ``retain(...)`` — retention;
+* ``index(...)``, ``duplicates(...)``, ``deduplicate(...)`` — checksum indexer;
+* ``snapshot(...)``, ``diffSnapshots(...)``, ``watch(...)`` — watcher helpers.
- use Infocyph\Pathwise\PathwiseFacade;
+Persistent Storage Is Not Facade State
+--------------------------------------
- PathwiseFacade::mountStorage('assets', [
- 'driver' => 'local',
- 'root' => '/srv/storage/assets',
- ]);
+Pathwise 4 deliberately removed facade/global storage-mount gateways. Do not
+store application topology in ``PathwiseFacade``.
- // For other adapters, pass adapter/constructor config:
- // PathwiseFacade::mountStorage('s3', ['driver' => 's3', 'adapter' => $adapter]);
+.. code-block:: php
-Operational Tooling from Facade
--------------------------------
+ use Infocyph\Pathwise\Storage\StorageContext;
-Available helpers:
+ $storage = new StorageContext([
+ 'files' => ['driver' => 'local', 'root' => '/srv/app/files'],
+ ], 'files');
-* ``PathwiseFacade::retain(...)`` -> ``RetentionManager``
-* ``PathwiseFacade::index(...)`` / ``PathwiseFacade::duplicates(...)`` / ``PathwiseFacade::deduplicate(...)`` -> ``ChecksumIndexer``
-* ``PathwiseFacade::snapshot(...)`` / ``PathwiseFacade::diffSnapshots(...)`` / ``PathwiseFacade::watch(...)`` -> ``FileWatcher``
+ $uploader = PathwiseFacade::upload();
+ $uploader->setStorageContext($storage);
+ $uploader->setDirectorySettings('files://uploads');
-See also:
+Use direct module classes instead of the facade when explicit constructor/
+dependency injection makes the application architecture clearer.
-* ``storage-adapters`` for adapter bootstrap
-* ``upload-processing`` and ``download-processing`` for stream workflows
+See :doc:`storage-context`, :doc:`storage-adapters`,
+:doc:`upload-processing` and :doc:`download-processing`.
diff --git a/docs/file-manager.rst b/docs/file-manager.rst
index 64db595c..5e3badd3 100644
--- a/docs/file-manager.rst
+++ b/docs/file-manager.rst
@@ -3,24 +3,26 @@ File Manager
Namespace: ``Infocyph\Pathwise\FileManager``
-Where it fits:
-
-* Use this module when your main workload is file-level IO, transformation,
- integrity checks, and archive handling.
+Use this module for file-level I/O, transformation, integrity checks,
+transactions, and archive handling. Storage-neutral operations can use direct
+local paths or the low-level Flysystem routing surface; capabilities that depend
+on real local filesystem semantics fail explicitly when a path is
+adapter-backed.
``FileOperations``
------------------
-Brief capabilities:
-
-* Create/read/update/append/delete/rename/copy.
-* Checksum helpers: ``verifyChecksum()``, ``writeAndVerify()``, ``copyWithVerification()``.
-* Stream APIs: ``readStream()``, ``writeStream()``.
-* Visibility/URL passthrough where adapter supports it.
-* Local-only structured transaction rollback and policy enforcement.
-* Native local append plus explicit ``appendEmulated()`` object replacement for mounts.
+Capabilities include:
-Example:
+* create/read/update/delete/rename/copy;
+* checksum helpers such as ``verifyChecksum()``, ``writeAndVerify()`` and
+ ``copyWithVerification()``;
+* stream APIs through ``readStream()`` and ``writeStream()``;
+* visibility/public-URL passthrough where the selected adapter supports it;
+* direct-local structured transactions and rollback;
+* policy and audit hooks;
+* native local append plus explicit ``appendEmulated()`` whole-object
+ replacement for adapter-backed storage.
.. code-block:: php
@@ -31,18 +33,25 @@ Example:
$file->writeAndVerify("v2\n", 'sha256');
$file->copyWithVerification('/tmp/report-copy.txt');
+Transactions are direct-local, process-local mutation journals rather than a
+database-style isolation mechanism. Nested transactions are rejected, rollback
+state is private where supported, invalid lifecycle operations are typed, and a
+rollback failure is reported explicitly rather than hiding it behind the
+original operation error.
+
``SafeFileReader``
------------------
-Brief capabilities:
+Capabilities include:
-* Streaming line, character, binary chunk, CSV, JSON Lines, and XML modes.
-* Whole-document ``jsonArray()`` decoding for complete JSON arrays (memory use
- is proportional to the document size).
-* Lock-aware reads for safer concurrent usage.
-* Explicit generator APIs; the reader itself implements ``Countable``, not ``Iterator``.
-
-Example:
+* streaming line, character, binary chunk, CSV, JSON Lines and XML modes;
+* whole-document ``jsonArray()`` decoding when the complete JSON array is
+ intentionally needed;
+* lock-aware reads for direct-local concurrent usage;
+* explicit generator APIs; the reader itself is ``Countable``, not an
+ ``Iterator``;
+* defensive serialized-value reading that disables PHP class instantiation and
+ rejects unsafe decoded object/resource-like values.
.. code-block:: php
@@ -50,20 +59,23 @@ Example:
$reader = new SafeFileReader('/tmp/report.txt');
foreach ($reader->lines() as $line) {
- // process line
+ // Process incrementally.
}
+Prefer the streaming APIs for large data. Whole-document helpers necessarily
+consume memory proportional to the document.
+
``SafeFileWriter``
------------------
-Brief capabilities:
+Capabilities include:
-* Structured writers for text/CSV/JSON/XML/binary.
-* Lock support.
-* Atomic write mode (temp file + rename).
-* Checksum verification support.
-
-Example:
+* structured text/CSV/JSON/XML/binary writing;
+* direct-local lock support;
+* checksum verification;
+* direct-local atomic replacement through ``enableAtomicWrite()``;
+* ordinary staged adapter writes where atomic rename semantics cannot be
+ promised.
.. code-block:: php
@@ -75,20 +87,26 @@ Example:
$writer->writeLine('finished');
$writer->close();
+Atomic mode is intentionally a local guarantee: Pathwise stages beside the
+local destination and requires the final rename to succeed. Adapter-backed
+publication is not described as atomic merely because Pathwise can stage data
+before the final write.
+
``FileCompression``
-------------------
-Brief capabilities:
+Capabilities include:
-* ZIP compress/decompress.
-* Password + AES modes.
-* 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:
+* ZIP creation and extraction;
+* password/AES modes where the ZIP capability supports them;
+* include/exclude glob patterns and ignore-file support;
+* hook/progress callbacks;
+* shared manifest-based archive validation used by the extraction APIs;
+* entry-count, per-entry/total expanded-size, compression-ratio and actual
+ streamed-byte enforcement;
+* rejection of traversal/absolute/drive/UNC/null paths, canonical collisions,
+ ZIP symlinks, unsupported special entries and destination breakout;
+* deterministic local/adapter staging cleanup and publication checks.
.. code-block:: php
@@ -98,3 +116,8 @@ Example:
$zip->setGlobPatterns(includePatterns: ['*.txt'], excludePatterns: ['*.tmp'])
->compress('/tmp/source')
->save();
+
+Archive creation rejects source symlinks rather than following them into
+content outside the selected source tree. See :doc:`storage-contracts`,
+:doc:`security`, and :doc:`performance-portability` for the guarantee and
+scaling boundaries.
diff --git a/docs/index.rst b/docs/index.rst
index 7611cee3..f4c35aa0 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,31 +1,49 @@
Pathwise Documentation
======================
-Pathwise is a PHP 8.4+ toolkit built as **Flysystem + higher-level workflows**.
-It provides safe file IO, directory automation, upload pipelines, policy checks,
-queue/audit tooling, and operational helpers.
+Pathwise 4 is a framework-neutral PHP filesystem toolkit built on Flysystem 3,
+with instance-scoped storage topology, hardened upload/download workflows,
+local filesystem operations, archive controls, queueing, observability,
+retention, indexing, policy enforcement, and bounded native execution.
.. toctree::
:maxdepth: 2
- :caption: Contents
+ :caption: Start Here
overview
installation
capabilities
+ quickstart
+ migration-4.0
+ release-4.0
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Storage and Workflows
+
storage-contracts
+ storage-context
storage-adapters
file-facade
- quickstart
- recipes
file-manager
+ symlink-management
directory-manager
upload-processing
+ malware-scanning
download-processing
- security
queue
observability
indexing
retention
utilities
native-execution
+ recipes
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Reference
+
+ security
+ performance-portability
+ api-reference
release-3.0
diff --git a/docs/indexing.rst b/docs/indexing.rst
index 636e965e..3983f731 100644
--- a/docs/indexing.rst
+++ b/docs/indexing.rst
@@ -3,26 +3,58 @@ Indexing
Namespace: ``Infocyph\Pathwise\Indexing``
-``ChecksumIndexer`` builds and uses checksum maps for directories.
+``ChecksumIndexer`` supports checksum iteration, complete checksum indexes,
+duplicate detection, and direct-local hard-link deduplication.
-Brief capabilities:
+Capabilities
+------------
-* Build checksum index per file.
-* Detect duplicate files by hash.
-* Attempt deduplication workflows (for example hard-link strategy where supported).
+``iterate()``
+ Streams ``checksum``/``path`` pairs without retaining the complete index in
+ memory. Prefer this API for large inventory/integrity workloads.
-Use cases:
+``buildIndex()``
+ Materializes a checksum-to-path-list map. Memory therefore grows with the
+ number of indexed files.
-* Duplicate detection.
-* Content-based integrity scans.
-* Pre-cleanup analysis for storage optimization.
+``findDuplicates()``
+ Materializes the complete index and returns checksum groups containing more
+ than one path.
-Example
--------
+``deduplicateWithHardLinks()``
+ Uses duplicate groups to replace verified direct-local duplicates with hard
+ links where safe/supported. Adapter-backed targets are skipped rather than
+ being treated as native files.
+
+SHA-256 is the default because checksum indexing is also used for
+integrity-oriented workflows. A caller that only needs a non-security
+fingerprint may explicitly select another algorithm supported by PHP.
+
+Streaming Example
+-----------------
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\Indexing\ChecksumIndexer;
+
+ foreach (ChecksumIndexer::iterate('/tmp/assets', 'sha256') as $entry) {
+ printf("%s %s\n", $entry['checksum'], $entry['path']);
+ }
+
+Duplicate Example
+-----------------
.. code-block:: php
use Infocyph\Pathwise\Indexing\ChecksumIndexer;
- $index = ChecksumIndexer::buildIndex('/tmp/assets', 'sha256');
$duplicates = ChecksumIndexer::findDuplicates('/tmp/assets', 'sha256');
+ $result = ChecksumIndexer::deduplicateWithHardLinks('/tmp/assets', 'sha256');
+
+ foreach ($result->linked as $path) {
+ // The verified duplicate was replaced by a hard link.
+ }
+
+Hashing adapter-backed content can require a complete remote read per object.
+Measure checksum workflows against realistic storage latency/request costs. See
+:doc:`performance-portability` for the Pathwise 4 release workload guidance.
diff --git a/docs/installation.rst b/docs/installation.rst
index b3e4a3a7..ddd3ed09 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -11,8 +11,13 @@ Requirements:
* PHP 8.4+
* ``league/flysystem`` 3.x
+* ``psr/log`` 3.x for Pathwise's PSR-3 logger integration surface
* ``ext-fileinfo``
+``psr/log`` supplies the PSR-3 interfaces Pathwise exposes in its production
+API; it does not install a concrete logger implementation. Applications may
+provide any PSR-3-compatible logger implementation when logging is desired.
+
Optional extensions:
* ``ext-zip`` for ZIP features.
diff --git a/docs/malware-scanning.rst b/docs/malware-scanning.rst
new file mode 100644
index 00000000..4ee9d703
--- /dev/null
+++ b/docs/malware-scanning.rst
@@ -0,0 +1,259 @@
+Malware Scanning
+================
+
+Pathwise treats malware scanning as a typed upload security boundary rather than
+as a shell-command hook. The upload pipeline owns a private local scan copy and
+a configured scanner decides whether that copy is clean.
+
+Core types
+----------
+
+The scanner contract lives in ``Infocyph\Pathwise\StreamHandler``:
+
+* ``MalwareScannerInterface`` — scans one Pathwise-owned local file.
+* ``MalwareScanRequest`` — local path, normalized extension, and authoritative
+ byte size.
+* ``MalwareScanVerdict`` — ``CLEAN``, ``MALICIOUS``, ``SUSPICIOUS``, or
+ ``UNKNOWN``.
+* ``MalwareScanMode`` — controls whether scanning is disabled, opportunistic,
+ or mandatory.
+* ``MalwareScannerProviderInterface`` — optional diagnostics metadata for
+ scanners that can expose a stable provider identifier.
+
+Only ``MalwareScanVerdict::CLEAN`` allows the upload to continue. Every other
+verdict fails closed.
+
+Scan modes
+----------
+
+``MalwareScanMode::WHEN_CONFIGURED`` is the default.
+
+``OFF``
+~~~~~~~
+
+Scanning is skipped even when a scanner object is registered.
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
+
+ $uploader->setMalwareScanMode(MalwareScanMode::OFF);
+
+``WHEN_CONFIGURED``
+~~~~~~~~~~~~~~~~~~~
+
+If a scanner is configured, it is enforced. If no scanner is configured, the
+upload continues without malware scanning.
+
+.. code-block:: php
+
+ $uploader->setMalwareScanMode(MalwareScanMode::WHEN_CONFIGURED);
+
+``REQUIRED``
+~~~~~~~~~~~~
+
+A scanner must be configured. Missing scanner configuration, scanner/backend
+failure, or any non-clean verdict rejects the upload.
+
+.. code-block:: php
+
+ $uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
+
+Runtime status
+--------------
+
+``UploadProcessor::getInfo()`` exposes scanner readiness without executing a
+scan:
+
+.. code-block:: php
+
+ $info = $uploader->getInfo();
+
+ echo $info['malwareScanMode']; // when_configured, required, off
+ echo $info['malwareScanStatus']; // configured, unconfigured, ...
+ var_dump($info['hasMalwareScanner']);
+ var_dump($info['malwareScannerProvider']); // clamav or null
+
+The status values are:
+
+* ``disabled``
+* ``unconfigured``
+* ``configured``
+* ``required_unconfigured``
+* ``required_ready``
+
+ClamAV daemon scanner
+---------------------
+
+Pathwise ships ``ClamAvDaemonScanner``. It talks directly to a running
+``clamd`` service with the ClamAV ``INSTREAM`` protocol and does not execute a
+shell command.
+
+The scanner streams the Pathwise-owned scan file in bounded chunks. It does not
+ask ``clamd`` to open an application path, so ``clamd`` does not need access to
+the original upload location.
+
+Unix socket example
+~~~~~~~~~~~~~~~~~~~
+
+A Unix socket is the preferred Linux deployment when the PHP worker and clamd
+run on the same host.
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
+ use Infocyph\Pathwise\StreamHandler\Scanner\ClamAvDaemonScanner;
+
+ $scanner = new ClamAvDaemonScanner(
+ endpoint: 'unix:///run/clamav/clamd.ctl',
+ connectTimeoutSeconds: 2.0,
+ ioTimeoutSeconds: 30.0,
+ chunkSize: 64 * 1024,
+ maxResponseBytes: 8 * 1024,
+ maxStreamBytes: 256 * 1024 * 1024,
+ );
+
+ $uploader->setMalwareScanner($scanner);
+ $uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
+
+The socket itself must be accessible to the PHP worker's service account. This
+does not require running PHP as root.
+
+Loopback TCP example
+~~~~~~~~~~~~~~~~~~~~
+
+.. code-block:: php
+
+ $scanner = new ClamAvDaemonScanner(
+ endpoint: 'tcp://127.0.0.1:3310',
+ ioTimeoutSeconds: 20.0,
+ maxStreamBytes: 128 * 1024 * 1024,
+ );
+
+Remote TCP is rejected by default. ``clamd`` TCP does not provide transport
+encryption or authentication, so exposing it across an untrusted network is
+not recommended. If an administrator deliberately places clamd behind a trusted
+private transport, remote TCP requires ``allowRemoteTcp: true``.
+
+Scanner limits
+~~~~~~~~~~~~~~
+
+``ClamAvDaemonScanner`` enforces client-side bounds before or during protocol
+execution:
+
+* connection timeout;
+* read/write timeout;
+* INSTREAM chunk size;
+* maximum protocol response bytes;
+* maximum file bytes that Pathwise will stream to clamd.
+
+Set ``maxStreamBytes`` no higher than the deployment's intended upload/scanner
+limit and align it with the clamd ``StreamMaxLength`` policy. If the Pathwise
+limit is exceeded, scanning fails before a daemon connection is required.
+
+LMD / Linux Malware Detect
+--------------------------
+
+The recommended Linux production model is:
+
+**Pathwise -> ClamAvDaemonScanner -> clamd with LMD signatures integrated on
+the host.**
+
+Linux Malware Detect (LMD/maldet) can maintain additional malware signatures
+that are consumed by the host's ClamAV deployment. In that model Pathwise still
+speaks only the clamd protocol; the host administrator owns signature updates,
+clamd/LMD service configuration, quarantine policy, and filesystem privilege.
+
+This is preferred over launching ``maldet`` from a PHP request worker because:
+
+* the PHP process does not need root or sudo;
+* request latency does not depend on spawning a privileged scanner process;
+* host-specific LMD paths and quarantine directories do not leak into the
+ library API;
+* service lifecycle and signature refresh remain operational concerns rather
+ than application-library concerns.
+
+Pathwise must not be configured to run ``sudo maldet ...`` from a web worker.
+If a deployment truly requires direct LMD execution, implement
+``MalwareScannerInterface`` in an application-owned adapter that talks to a
+separate privileged/asynchronous scanner service.
+
+Other scanner engines
+---------------------
+
+AMWScan, ICAP gateways, commercial scanners, cloud malware APIs, and internal
+engines can implement the same scanner contract.
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\StreamHandler\MalwareScannerInterface;
+ use Infocyph\Pathwise\StreamHandler\MalwareScannerProviderInterface;
+ use Infocyph\Pathwise\StreamHandler\MalwareScanRequest;
+ use Infocyph\Pathwise\StreamHandler\MalwareScanVerdict;
+
+ final class IcapScanner implements MalwareScannerInterface, MalwareScannerProviderInterface
+ {
+ public function providerId(): string
+ {
+ return 'icap';
+ }
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ // Stream $request->localPath to the application-owned ICAP client.
+ // Map the engine result to one of the Pathwise verdicts.
+ return MalwareScanVerdict::CLEAN;
+ }
+ }
+
+``MalwareScannerProviderInterface`` is optional. Custom scanners that only
+implement ``MalwareScannerInterface`` remain valid; their provider identifier is
+reported as ``null``.
+
+Failure semantics
+-----------------
+
+Scanner/backend exceptions are wrapped as a stable upload error:
+``Malware scanner failed.`` The original throwable is retained as the previous
+exception for internal diagnostics.
+
+Pathwise intentionally does not expose raw scanner/backend error text to the
+public upload failure message.
+
+Validation order
+----------------
+
+When scanning is active, the hardened upload order is:
+
+#. upload/error and HTTP provenance checks;
+#. metadata size rejection;
+#. authoritative actual-size validation;
+#. extension allow/block policy;
+#. private local scan-copy materialization;
+#. malware scan;
+#. MIME detection and MIME allowlist;
+#. extension-to-MIME and magic-signature checks;
+#. image/format-specific parsing;
+#. final naming and publication.
+
+This keeps attacker-controlled content away from deeper MIME/signature/image
+parsers until the configured malware boundary has accepted it.
+
+Scan-copy integrity
+-------------------
+
+The scan input is created inside a private ``0700`` directory and forced to
+``0600`` permissions. Pathwise checks the scan copy before and after scanner
+execution and rejects replacement, symlink conversion, size change, or byte
+mutation. The scan copy is removed in a ``finally`` path.
+
+Mounted or remote upload sources are copied to this private local scan file
+before a scanner is invoked.
+
+Resumable uploads
+-----------------
+
+Chunks are not individually treated as safe files. Pathwise scans the fully
+assembled staging object during ``finalizeChunkUpload()`` before deeper content
+validation and final publication. This avoids per-chunk scanner cost and avoids
+false assurance when malicious structure spans chunk boundaries.
diff --git a/docs/migration-4.0.rst b/docs/migration-4.0.rst
new file mode 100644
index 00000000..9797bba5
--- /dev/null
+++ b/docs/migration-4.0.rst
@@ -0,0 +1,255 @@
+Migrating from Pathwise 3.x to 4.0
+==================================
+
+Pathwise 4 is a major release. Compatibility was not preserved where the 3.x
+surface encouraged process-global state, ambiguous security behavior, or unsafe
+ownership assumptions. Migrate deliberately rather than changing only the
+Composer constraint.
+
+Runtime Requirements
+--------------------
+
+Pathwise 4 requires PHP 8.4+ and declares its production interfaces directly,
+including ``psr/log ^3``. Optional Flysystem adapters and optional PHP
+extensions remain capability-specific.
+
+Storage: Global Topology -> StorageContext
+------------------------------------------
+
+The most important architecture change is storage topology ownership.
+
+Removed/de-emphasized 3.x patterns include:
+
+* ``StorageFactory::mount()``;
+* ``StorageFactory::mountMany()``;
+* process-global ``StorageFactory`` custom-driver registration;
+* facade mount gateways that owned persistent topology.
+
+Pathwise 4 keeps ``StorageFactory::createFilesystem()`` as a stateless
+constructor and uses ``StorageContext`` for named application storage.
+
+Before (3.x):
+
+.. code-block:: php
+
+ StorageFactory::mount('files', [
+ 'driver' => 'local',
+ 'root' => '/srv/app/files',
+ ]);
+
+After (4.0):
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\Storage\StorageContext;
+
+ $storage = new StorageContext([
+ 'files' => [
+ 'driver' => 'local',
+ 'root' => '/srv/app/files',
+ ],
+ ], 'files');
+
+ [$filesystem, $location] = $storage->resolve('files://documents/a.txt');
+
+``StorageContext`` owns its operators and custom drivers. It never registers a
+global mount, so multiple applications/generations can reuse logical names
+without cross-talk.
+
+Upload/Download Processors and StorageContext
+---------------------------------------------
+
+If a processor uses relative or ``name://`` paths, inject the context **before**
+path-dependent settings:
+
+.. code-block:: php
+
+ $uploader->setStorageContext($storage);
+ $uploader->setDirectorySettings('files://uploads', tempDir: sys_get_temp_dir());
+
+ $downloads->setStorageContext($storage);
+ $downloads->setAllowedRoots(['files://downloads']);
+
+Direct absolute paths remain local. Processor context routing does not mutate
+``FlysystemHelper`` global state.
+
+Framework Uploads: Synthetic Arrays -> UploadSource
+---------------------------------------------------
+
+``processUpload()`` remains for genuine PHP HTTP uploads and keeps
+``is_uploaded_file()`` provenance. Framework uploaded-file abstractions should
+use ``UploadSource`` instead of first materializing a temporary file in the
+framework integration layer.
+
+.. code-block:: php
+
+ $source = UploadSource::fromMover(
+ fn (string $target): void => $uploadedFile->moveTo($target),
+ $uploadedFile->getClientFilename() ?? 'upload.bin',
+ $uploadedFile->getSize(),
+ $uploadedFile->getClientMediaType(),
+ $uploadedFile->getError(),
+ );
+
+ $path = $uploader->ingestSource($source);
+
+Ownership is explicit: borrowed paths survive, owned paths are consumed after
+successful staging, caller streams are never closed, and Pathwise-owned staging
+state is cleaned after success/failure.
+
+Malware Scanner: Callable -> Typed Contract
+-------------------------------------------
+
+Pathwise 4 uses ``MalwareScannerInterface``:
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
+ use Infocyph\Pathwise\StreamHandler\MalwareScannerInterface;
+
+ $uploader->setMalwareScanner($scanner); // MalwareScannerInterface
+ $uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
+
+Scanner behavior is now explicit through ``MalwareScanRequest`` and
+``MalwareScanVerdict``. A required scanner fails closed. Scanner exceptions are
+not copied into the public upload error text, and Pathwise verifies that a
+scanner did not mutate its scan input.
+
+PolicyEngine Is Deny-by-Default
+-------------------------------
+
+``PolicyEngine`` now starts with ``defaultAllow: false``. An unmatched operation
+is denied.
+
+.. code-block:: php
+
+ $policy = (new PolicyEngine())
+ ->allow('read', '/srv/public/*')
+ ->deny('read', '/srv/public/private/*');
+
+Rules use last-match-wins precedence. If an intentionally permissive policy is
+required, construct ``new PolicyEngine(defaultAllow: true)`` explicitly.
+
+Queue API: Raw Jobs -> Typed Leases
+-----------------------------------
+
+File queue reservations are ownership capabilities in 4.0. ``reserve()``
+returns ``?QueueReservation``. Pass that reservation to ``renew()``,
+``acknowledge()``, ``release()``, or ``fail()``.
+
+.. code-block:: php
+
+ $reservation = $queue->reserve();
+ if ($reservation !== null) {
+ try {
+ handle($reservation->payload);
+ $queue->acknowledge($reservation);
+ } catch (Throwable $failure) {
+ $queue->fail($reservation, $failure);
+ }
+ }
+
+Expired/stale lease tokens cannot mutate a job after a new worker owns it. Queue
+state is strict/versioned and stored with bounded size/payload/job limits. The
+queue is direct-local only; do not point it at object storage.
+
+Downloads: Preparation + Iterable Body
+--------------------------------------
+
+Use ``prepareDownload()`` to obtain ``DownloadPreparation`` and
+``streamChunks()`` for framework response bodies. The iterator owns source
+stream closure and exact range accounting.
+
+.. code-block:: php
+
+ $prepared = $downloads->prepareDownload($path, rangeHeader: $range);
+ foreach ($downloads->streamChunks($prepared) as $chunk) {
+ yield $chunk;
+ }
+
+A preparation is revalidated before streaming; it is not an authorization
+capability.
+
+Native Execution Is Bounded
+---------------------------
+
+Native optimization is constrained by ``NativeExecutionLimits``. Defaults are
+finite: 300-second timeout, 4 MiB stdout, 4 MiB stderr, a termination grace
+period, and bounded polling. Execution uses argv-style invocation rather than
+shell command composition. Limit/termination failures are typed and cleanup is
+deterministic.
+
+Review applications that assumed unbounded native output or indefinite command
+runtime.
+
+Archives Are Manifest-Validated
+-------------------------------
+
+ZIP extraction/processing now validates an entry manifest before publication
+and enforces path, collision, entry-type, per-entry/total-size, compression-ratio
+and write-time checks. Unsafe special entries and source symlinks are rejected.
+Do not rely on permissive extraction of ambiguous or malformed archives.
+
+Serialization Is a Trust Boundary
+---------------------------------
+
+Serialized-value validation is defensive parsing, not object reconstruction.
+Untrusted serialized data must not be treated as a safe way to instantiate
+classes. Applications that relied on implicit object creation should move that
+logic outside the Pathwise validation boundary.
+
+File Transactions and Symlinks
+------------------------------
+
+Transactional file mutation and safe symlink operations are direct-local
+capabilities. Rollback failures are reported explicitly rather than being
+hidden behind the original exception. ``SafeSymlinkManager`` validates allowed
+link/target roots and existing targets before activation/removal.
+
+Typed Results
+-------------
+
+Several workflows return typed result objects rather than associative arrays.
+Important examples:
+
+* ``ChunkUploadState`` — use ``$state->complete``;
+* ``DownloadPreparation`` / ``RangeDownloadMetadata``;
+* ``DownloadStreamResult``;
+* ``QueueProcessResult``;
+* ``SyncReport``;
+* ``SymlinkStatus``;
+* ``SnapshotDiff`` / ``WatchResult``;
+* ``RetentionResult`` / ``DeduplicationResult``;
+* ``NativeExecutionResult``.
+
+Audit application code for array access such as ``$state['isComplete']``.
+
+Facade and Global Helpers
+-------------------------
+
+``PathwiseFacade`` is stateless convenience in 4.0. It creates operation
+objects/results but does not own persistent storage topology. Use direct module
+classes when dependency injection or explicit lifecycle ownership is clearer.
+
+Low-level ``FlysystemHelper`` default/mount APIs remain storage-neutral utility
+mechanics for direct standalone use, but they are not the persistent-runtime
+registry model. Application frameworks should use ``StorageContext`` instead.
+
+Recommended Migration Order
+---------------------------
+
+1. Raise the runtime to PHP 8.4+ and install declared production dependencies.
+2. Replace global storage topology with one ``StorageContext`` per application
+ runtime/generation.
+3. Inject that context into upload/download processors before path settings.
+4. Replace framework upload materialization with ``UploadSource``.
+5. Implement ``MalwareScannerInterface`` and choose an explicit scan mode.
+6. Update policy configuration for deny-by-default semantics.
+7. Convert queue consumers to typed reservations/lease renewal.
+8. Convert download adapters to preparation + iterable streaming where useful.
+9. Review native/archive/transaction/local-only assumptions.
+10. Run the PHP 8.4/8.5, lowest/stable, Windows, optional-adapter, docs and
+ release-workload gates before deployment.
+
+See :doc:`api-reference`, :doc:`security`, and
+:doc:`performance-portability` for final 4.0 contracts.
diff --git a/docs/native-execution.rst b/docs/native-execution.rst
index 7def184e..77f779a4 100644
--- a/docs/native-execution.rst
+++ b/docs/native-execution.rst
@@ -3,34 +3,55 @@ Native Execution
Namespaces: ``Infocyph\Pathwise\Core`` and ``Infocyph\Pathwise\Native``
-Pathwise can use OS-native commands for selected workflows via ``ExecutionStrategy``:
+Pathwise can use OS-native commands for selected direct-local workflows through
+``ExecutionStrategy``:
-* ``PHP``: force pure PHP implementation.
-* ``NATIVE``: require a local path and available executable; throw on failure.
-* ``AUTO``: attempt native first when supported, then fall back to PHP.
+* ``PHP`` — force the portable PHP implementation and never start a native tool;
+* ``AUTO`` — use a supported native capability when available, otherwise fall
+ back to PHP;
+* ``NATIVE`` — require the native capability and fail explicitly when it is not
+ available or execution fails.
-``NativeOperationsAdapter`` covers:
+``NativeOperationsAdapter`` provides native acceleration for selected file,
+directory, and archive operations. The actual tool is capability/platform
+dependent: Unix-like systems may use tools such as ``cp``, ``rsync``, ``zip``
+and ``unzip``; Windows may use ``cmd``, ``robocopy`` or PowerShell capabilities.
+Do not depend on one executable being present merely because the OS family is
+known.
-* file copy acceleration
-* directory copy acceleration
-* zip/unzip acceleration
+Safety Limits
+-------------
-Platform behavior:
+Native execution is bounded by ``NativeExecutionLimits``. Defaults are finite:
-* Windows: ``robocopy``, ``cmd copy``, PowerShell archive commands.
-* Unix-like: ``rsync``, ``cp``, ``zip``/``unzip``.
+* timeout: 300 seconds;
+* stdout cap: 4 MiB;
+* stderr cap: 4 MiB;
+* termination grace: 1 second;
+* polling interval: 10,000 microseconds.
-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.
+Applications may provide stricter limits for their workload. Invalid limits are
+rejected at configuration time. Runtime timeout, output-limit, startup, exit and
+unsupported-capability failures are represented through the typed native
+execution failure/exception surface.
-Where to use
-------------
+The command runner uses non-blocking pipe handling, bounded termination and
+deterministic cleanup. Pathwise starts argument-vector commands; caller-provided
+shell fragments are not accepted as an execution API.
-Enable native mode when you are operating on large local trees/archives and OS
-tools are available in the runtime environment.
+Storage Boundary
+----------------
+
+Native mode is direct-local-filesystem-only. ``StorageContext`` logical paths,
+low-level mounted/default Flysystem paths and object-storage paths are rejected
+for native execution even when a particular adapter happens to use a local
+directory internally. Pathwise does not unwrap adapters to manufacture a native
+filesystem guarantee.
+
+In ``AUTO`` mode, an unavailable native capability is a reason to use the PHP
+implementation. In forced ``NATIVE`` mode, it is an explicit typed failure.
+Once a native command has started and fails, Pathwise does not silently mask the
+failure by rerunning the operation through a different implementation.
Example
-------
@@ -43,3 +64,7 @@ Example
$ops = new DirectoryOperations('/tmp/source');
$ops->setExecutionStrategy(ExecutionStrategy::AUTO)
->copy('/tmp/target');
+
+Use native acceleration for large local workloads only after measuring it on
+the deployment platform. See :doc:`performance-portability` and
+:doc:`storage-contracts` for the capability and release-workload guidance.
diff --git a/docs/observability.rst b/docs/observability.rst
index ef427df6..0fca7031 100644
--- a/docs/observability.rst
+++ b/docs/observability.rst
@@ -3,21 +3,31 @@ Observability
Namespace: ``Infocyph\Pathwise\Observability``
-``AuditTrail`` delegates records to an ``AuditSink``.
+``AuditTrail`` delegates structured operation records to an ``AuditSink``.
+Pathwise deliberately separates local append durability from remote/application
+transport semantics instead of pretending every storage backend supports the
+same append primitive.
-Brief capabilities:
+Audit Sinks
+-----------
-* Log timestamped operation events with context.
-* ``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.
+``LocalJsonlAuditSink``
+ Direct-local line-delimited JSON. Writes use an exclusive lock, full-write
+ handling and flush/synchronization of committed state. Pathwise-owned local
+ audit state is private where the platform exposes POSIX permissions.
-Typical fields:
+``PartitionedAuditSink``
+ Stores separate bounded event objects/segments on writable storage. Prefer
+ this shape for adapter-backed/object storage because it avoids hidden
+ read-modify-write append of a complete remote object.
-* operation name
-* path/source/destination
-* bytes/checksum/visibility context
+``CallbackAuditSink``
+ Forwards structured events into an application logger, collector, message
+ pipeline or other application-owned observability system.
+
+Typical event context may include operation, path/source/destination,
+bytes/checksum/visibility and caller-provided metadata. Consumers should treat
+that context as audit data, not as an authorization decision.
Example
-------
@@ -34,6 +44,14 @@ Example
->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.
+Storage Boundary
+----------------
+
+A logical/adapter-backed path cannot be passed as though it were a native JSONL
+append target. Portable Flysystem does not define atomic append/locking.
+Choose a partitioned or callback sink for those workloads; Pathwise does not
+hide a complete remote audit-object rewrite behind a local-looking API.
+
+For long-running services, bound partition/event size according to the selected
+sink and ship/rotate audit state through application operations. See
+:doc:`storage-contracts` and :doc:`performance-portability`.
diff --git a/docs/overview.rst b/docs/overview.rst
index ac7b5787..7a5a4225 100644
--- a/docs/overview.rst
+++ b/docs/overview.rst
@@ -1,39 +1,96 @@
Overview
========
+Pathwise 4 is a framework-neutral filesystem/workflow toolkit for PHP 8.4+.
+It deliberately separates three concerns:
+
+* **storage topology** — Flysystem operators and logical names owned by
+ ``StorageContext``;
+* **filesystem/workflow mechanics** — file, directory, upload, download,
+ archive, queue and utility classes;
+* **application policy/integration** — supplied by the consuming framework or
+ application rather than hidden inside Pathwise globals.
+
Why Pathwise
------------
-Pathwise gives you a single API surface for common file-system workflows that
-normally require stitching many low-level PHP calls together.
+Filesystem code often becomes a collection of subtly different path checks,
+stream loops, temporary-file conventions, locks, archive rules, queue files,
+scanner callbacks and adapter-specific workarounds. Pathwise centralizes those
+generic mechanics and gives them typed failure/resource-ownership semantics.
-It focuses on three layers:
+The 4.0 design emphasizes:
-* Storage operations powered by Flysystem.
-* Workflow primitives (uploads, compression, sync, validation).
-* Operational building blocks (queue, audit, retention, indexing, policy).
+* instance-scoped persistent storage topology;
+* streaming and bounded resource use;
+* fail-closed security policy where a capability is required;
+* explicit local-only/platform-specific capabilities;
+* typed results instead of ambiguous arrays;
+* deterministic cleanup on success and failure;
+* portability across PHP 8.4/8.5 and Windows/Linux release matrices.
-Main namespaces:
+Primary Namespaces
+------------------
-* ``Infocyph\Pathwise`` (unified ``PathwiseFacade`` facade)
-* ``Infocyph\Pathwise\FileManager``
-* ``Infocyph\Pathwise\DirectoryManager``
-* ``Infocyph\Pathwise\StreamHandler``
-* ``Infocyph\Pathwise\Storage``
-* ``Infocyph\Pathwise\Security``
-* ``Infocyph\Pathwise\Queue``
-* ``Infocyph\Pathwise\Observability``
-* ``Infocyph\Pathwise\Indexing``
-* ``Infocyph\Pathwise\Retention``
-* ``Infocyph\Pathwise\Utils``
+* ``Infocyph\Pathwise`` — stateless ``PathwiseFacade``;
+* ``Infocyph\Pathwise\Storage`` — ``StorageContext`` / ``StorageFactory``;
+* ``Infocyph\Pathwise\FileManager``;
+* ``Infocyph\Pathwise\DirectoryManager``;
+* ``Infocyph\Pathwise\StreamHandler``;
+* ``Infocyph\Pathwise\Security``;
+* ``Infocyph\Pathwise\Queue``;
+* ``Infocyph\Pathwise\Observability``;
+* ``Infocyph\Pathwise\Indexing``;
+* ``Infocyph\Pathwise\Retention``;
+* ``Infocyph\Pathwise\Native``;
+* ``Infocyph\Pathwise\Results``;
+* ``Infocyph\Pathwise\Utils``.
-Path style support:
+Path Models
+-----------
-* Local absolute/relative paths.
-* Mounted Flysystem scheme paths like ``mnt://reports/file.csv``.
+There are two distinct path contexts.
-Quick Start
------------
+Direct paths
+ Absolute/direct-local paths used by local file operations and OS-level
+ capabilities.
+
+StorageContext logical paths
+ Relative paths select a context default; ``name://path`` selects a named
+ context filesystem. These paths do not require a process-global mount.
+
+.. code-block:: php
+
+ $storage = new StorageContext([
+ 'primary' => ['driver' => 'local', 'root' => '/srv/app/storage'],
+ 'archive' => ['filesystem' => $archiveFilesystem],
+ ], 'primary');
+
+ [$filesystem, $location] = $storage->resolve('archive://2026/report.pdf');
+
+Low-level ``FlysystemHelper`` default/mount routing remains available for direct
+standalone utility use. It is not the recommended persistent-runtime registry.
+
+Framework Boundary
+------------------
+
+Pathwise is not an HTTP framework, dependency-injection container, distributed
+broker, log service, or application path/config system. For example:
+
+* Pathwise prepares and streams download ranges; the framework creates the HTTP
+ response and handles request conditionals/offload policy.
+* Pathwise materializes/validates upload sources; the framework extracts the
+ uploaded-file object and application policy.
+* Pathwise provides a direct-local queue; applications needing multi-host
+ messaging should use a broker.
+* Pathwise provides storage contexts; application-specific base/public/storage
+ path conventions belong to the consuming framework.
+
+This boundary lets projects such as Foundation reuse Pathwise mechanics without
+copying them or coupling Pathwise to a specific HTTP/runtime framework.
+
+Quick Example
+-------------
.. code-block:: php
@@ -51,7 +108,9 @@ Quick Start
Read Next
---------
-* ``capabilities`` for the full module map.
-* ``file-facade`` for the unified entry style.
-* ``quickstart`` for copy/paste examples.
-* ``recipes`` for end-to-end workflow patterns.
+* :doc:`capabilities` — module map;
+* :doc:`quickstart` — recommended entry patterns;
+* :doc:`storage-context` — persistent runtime storage;
+* :doc:`security` — trust/capability model;
+* :doc:`migration-4.0` — breaking changes from 3.x;
+* :doc:`api-reference` — public types and errors.
diff --git a/docs/performance-portability.rst b/docs/performance-portability.rst
new file mode 100644
index 00000000..fa3d0ad4
--- /dev/null
+++ b/docs/performance-portability.rst
@@ -0,0 +1,196 @@
+Performance, Memory and Portability
+===================================
+
+Pathwise prefers streaming, bounded state, and explicit capabilities over hidden
+buffering or platform emulation. The fastest safe implementation depends on the
+storage and operating-system capabilities available to a workload.
+
+Streaming Uploads
+-----------------
+
+``UploadSource`` always stages framework-neutral input into a private local file
+before upload validation. This is intentional: MIME/signature/image/scanner
+checks need a stable readable payload, and scanner integrations receive a real
+local path.
+
+Guidelines:
+
+* pass streams/movers rather than reading an entire request into a PHP string;
+* keep ``tempDir`` on fast local storage;
+* configure realistic max file/chunk sizes;
+* use local chunk staging even when the final destination is object storage;
+* understand that malware scanning may require one additional bounded local copy
+ of the payload, but not a full PHP string copy.
+
+The final destination transfer uses streams where the source/destination storage
+requires cross-filesystem copying.
+
+Streaming Downloads
+-------------------
+
+``DownloadProcessor::streamChunks()`` is the framework-neutral large-file body
+path. It:
+
+* opens the source lazily;
+* seeks directly when possible;
+* otherwise discards bytes incrementally to a range offset;
+* reads at most the configured chunk size;
+* stops exactly at the prepared range length;
+* closes the source on completion, exception, or early generator disposal.
+
+Do not replace this with ``read()``/full-string buffering in an HTTP integration.
+The default chunk size is 64 KiB and can be changed with ``setChunkSize()``.
+
+StorageContext Cost Model
+-------------------------
+
+``StorageContext`` normalizes configuration once and lazily creates each
+``FilesystemOperator`` on first use. Subsequent resolutions reuse the same
+operator inside that context. Two contexts intentionally do not share operator
+instances or custom driver factories merely because names match.
+
+For hot request/worker paths, construct the context once per application runtime
+or configuration generation and inject it into processors/services. Do not
+rebuild remote SDK clients for every file operation.
+
+Local vs Remote Capabilities
+----------------------------
+
+Flysystem makes storage-neutral I/O portable; it cannot make OS primitives
+portable. Pathwise therefore keeps these distinctions explicit:
+
+.. list-table::
+ :header-rows: 1
+
+ * - Capability
+ - Local filesystem
+ - Remote/object storage
+ * - Read/write/copy streams
+ - Yes
+ - Adapter-dependent, generally yes
+ * - Atomic native rename semantics
+ - Available where filesystem supports it
+ - Not assumed
+ * - ``flock`` queue/session locking
+ - Yes
+ - Not emulated
+ * - POSIX/Windows ownership metadata
+ - Platform-dependent
+ - Not assumed
+ * - Symbolic links
+ - Platform/filesystem-dependent
+ - Not emulated
+ * - Native command path
+ - Yes when executable/capability exists
+ - Requires localization first
+ * - Hard-link deduplication
+ - Filesystem-dependent
+ - Not supported as object-store semantics
+
+A workflow that needs a local-only capability should use a local working area
+and move the resulting artifact to/from remote storage explicitly.
+
+Native Execution Limits
+-----------------------
+
+Native execution is an optimization/capability path, not an excuse for
+unbounded subprocesses. ``NativeExecutionLimits`` defaults to:
+
+* timeout: 300 seconds;
+* stdout cap: 4 MiB;
+* stderr cap: 4 MiB;
+* termination grace: 1 second;
+* poll interval: 10,000 microseconds.
+
+Applications may choose tighter limits for request paths. Pathwise uses argv
+execution rather than shell command concatenation and terminates/cleans child
+processes deterministically when limits fail.
+
+Directory Workloads
+-------------------
+
+Directory sync/indexing cost scales with both entry count and the selected
+comparison strategy. Size/mtime comparisons avoid content reads; checksum
+comparison reads file contents and is therefore more expensive but stronger.
+
+For very large trees:
+
+* choose the weakest comparison that satisfies correctness;
+* avoid unnecessary checksum re-scans;
+* keep destination-outside-source validation enabled;
+* expect network-backed adapters to amplify per-entry metadata costs;
+* benchmark against the production adapter, not only local tmpfs/SSD behavior.
+
+Archives
+--------
+
+Archive safety checks add deliberate work: manifest construction, normalized
+entry validation, duplicate/collision checks, type checks, size/ratio limits,
+and write-time revalidation. These checks are release requirements and should
+not be disabled to improve benchmark numbers.
+
+Large archives should be constrained by entry count, individual expanded size,
+total expanded size, and compression ratio according to application policy.
+
+Queue Scaling
+-------------
+
+``FileJobQueue`` is a durable **local file queue**, not a distributed broker.
+Every mutation locks and validates bounded versioned state and persists it
+crash-safely. This is appropriate for lightweight local coordination and worker
+state, not very high-throughput multi-host messaging.
+
+Constructor limits bound jobs, queue bytes, payload bytes, and lease timeout.
+If workload scale no longer fits those limits efficiently, move the queueing
+responsibility to a purpose-built broker rather than weakening Pathwise's state
+validation/durability.
+
+Audit, Retention and Watchers
+-----------------------------
+
+* Prefer ``PartitionedAuditSink`` when audit volume could make one JSONL file
+ unbounded.
+* Retention scans filesystem metadata and should be scheduled at a cadence
+ appropriate to directory size.
+* ``FileWatcher`` is polling/snapshot based. Its interval/duration directly
+ trade CPU/I/O for detection latency; it is not an OS-native event stream.
+
+Cross-Platform Notes
+--------------------
+
+The release matrix covers Linux and Windows on PHP 8.4/8.5. Still account for:
+
+* Windows path case/drive/UNC behavior;
+* availability/permissions for symbolic and hard links;
+* absence of POSIX functions on Windows;
+* executable availability for native archive/filesystem commands;
+* adapter-specific metadata/checksum/MIME support;
+* filesystem semantics around atomic replacement and durability.
+
+Pathwise surfaces unsupported capabilities explicitly instead of claiming
+identical semantics everywhere.
+
+Release Workloads
+-----------------
+
+The repository carries benchmark/stress workloads under ``benchmarks/``:
+
+* ``ReleaseWorkloadsBench`` — large reader/download chunks, 1,000-entry sync,
+ chunk assembly, queue and transaction workloads;
+* ``WorkflowContractsBench`` — workflow-level contract costs;
+* ``StreamHandlerBench`` — upload/download hot paths;
+* ``FileAndDirectoryBench`` / ``DataManagementBench`` / helper benches;
+* ``benchmarks/stress/ReleaseStress.php`` — opt-in higher-volume queue, chunk,
+ and transaction stress.
+
+Run ordinary benchmarks with the project's PHPBench configuration and explicit
+stress with:
+
+.. code-block:: bash
+
+ vendor/bin/phpbench run benchmarks/stress/ReleaseStress.php --bootstrap=vendor/autoload.php
+
+Release acceptance is based primarily on correctness, bounded memory/state, and
+no regressions that indicate accidental full-file buffering. Wall-clock values
+are recorded as baselines rather than brittle universal pass/fail thresholds,
+because CI hardware and storage vary.
diff --git a/docs/queue.rst b/docs/queue.rst
index 2a8d3224..372b318a 100644
--- a/docs/queue.rst
+++ b/docs/queue.rst
@@ -3,40 +3,162 @@ Queue
Namespace: ``Infocyph\Pathwise\Queue``
-``FileJobQueue`` is a lightweight file-backed queue.
+``FileJobQueue`` is a lightweight, durable, single-host file-backed queue with
+explicit lease ownership.
-Brief capabilities:
+Capabilities
+------------
* Enqueue jobs with payload and priority.
-* Process jobs with a handler callback.
+* Reserve a job through a typed ``QueueReservation``.
+* Renew an active lease for long-running manual workers.
+* Acknowledge, release, or fail a reservation only while its lease is current.
+* Process jobs through a convenience handler loop.
* Track ``pending``, ``processing``, and ``failed`` buckets.
* Return queue statistics via ``stats()``.
-* Coordinate concurrent local readers and writers with file locks.
+* Coordinate concurrent local workers through a stable lock file.
+* Replace committed queue state through a same-directory temporary file rather
+ than truncating the live JSON document in place.
-Queue job IDs use cryptographically secure random values. Invalid queue JSON is
-reported as an error instead of being silently replaced, and ``maxJobs`` limits
-all attempted jobs, including failures. Queue files must be direct-local paths;
-mounted and default-Flysystem paths are rejected.
+Queue job and lease identifiers use cryptographically secure random values.
+The on-disk state is versioned and validated strictly. Invalid JSON, empty or
+truncated state, duplicate job identifiers, malformed bucket state, and an
+unsupported state version are reported explicitly rather than being silently
+replaced.
-``FileJobQueue`` is intended for lightweight single-host workloads. It uses
-local file locks and bounded payload/job/file sizes; it is not a remote or
-distributed broker.
+``FileJobQueue`` requires a direct-local path. Mounted/default Flysystem paths
+are rejected because queue coordination relies on local locking and atomic
+same-directory state replacement. Pathwise-created queue/lock state is private
+by default where POSIX permissions are available.
+
+``FileJobQueue`` is intended for lightweight single-host workloads. It is not a
+replacement for a distributed broker such as RabbitMQ, NATS, Kafka, or a
+managed queue service.
Good fit:
-* Small background workflows without external brokers.
-* Deterministic local job orchestration in scripts/tools.
+* Small background workflows without an external broker.
+* Local worker processes sharing one filesystem host.
+* Deterministic local job orchestration in scripts and tools.
-Example
--------
+Simple processing
+-----------------
.. code-block:: php
use Infocyph\Pathwise\Queue\FileJobQueue;
+ use Infocyph\Pathwise\Queue\QueueReservation;
- $queue = new FileJobQueue('/tmp/jobs.json');
+ $queue = new FileJobQueue('/var/lib/my-app/jobs.json');
$queue->enqueue('thumbnail.generate', ['id' => 12], priority: 10);
- $result = $queue->process(function (array $job): void {
- // handle $job['type'] and $job['payload']
+ $result = $queue->process(function (QueueReservation $reservation): void {
+ $type = $reservation->type;
+ $payload = $reservation->payload;
+
+ // Process the job. Returning successfully acknowledges the current lease.
});
+
+``process()`` reserves one job at a time. A successful handler is acknowledged;
+a thrown exception moves the current lease to the failed bucket. If the lease
+has expired and another worker has reclaimed it before acknowledgement, the
+stale worker cannot remove or fail the newer worker's reservation.
+
+Manual lease lifecycle
+----------------------
+
+For workers that need explicit lifecycle control, use ``reserve()`` directly:
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\Queue\FileJobQueue;
+
+ $queue = new FileJobQueue(
+ '/var/lib/my-app/jobs.json',
+ reservationTimeout: 60,
+ );
+
+ $reservation = $queue->reserve();
+ if ($reservation === null) {
+ return;
+ }
+
+ try {
+ // For work that may approach the lease timeout, renew before expiry.
+ $reservation = $queue->renew($reservation);
+
+ // Perform work using $reservation->type and $reservation->payload.
+
+ $queue->acknowledge($reservation);
+ } catch (Throwable $failure) {
+ $queue->fail($reservation, $failure);
+ }
+
+Available lease operations:
+
+``reserve()``
+ Claims the highest-priority pending job and returns a new lease token.
+
+``renew($reservation)``
+ Verifies current ownership, advances the reservation timestamp, and returns
+ an updated ``QueueReservation`` with the same lease token and new expiry.
+
+``acknowledge($reservation)``
+ Removes the job only if the supplied job ID and lease token still identify
+ the current processing reservation.
+
+``release($reservation)``
+ Returns the currently owned job to the pending bucket and clears its lease.
+ A later reservation receives a different lease token.
+
+``fail($reservation, $failure)``
+ Moves the currently owned job to the failed bucket with bounded failure
+ detail.
+
+Stale workers
+-------------
+
+A lease token is ownership, while ``reservedAt`` is only the expiry clock.
+Consider two workers:
+
+1. Worker A reserves job ``J`` and receives lease ``A``.
+2. A runs beyond the reservation timeout.
+3. Worker B reserves the reclaimed job and receives lease ``B``.
+4. A later calls ``acknowledge()``, ``release()``, ``renew()``, or ``fail()``
+ using lease ``A``.
+5. Pathwise rejects A as stale; B remains the owner and can complete safely.
+
+This prevents an expired worker from deleting or mutating a newer worker's
+reservation merely because both workers refer to the same job ID.
+
+Persistence model
+-----------------
+
+The queue uses two local files:
+
+* ``jobs.json`` — versioned committed queue state.
+* ``jobs.json.lock`` — stable coordination lock that is never replaced during a
+ state commit.
+
+Writers hold the stable lock, encode and flush a private same-directory
+temporary file, then replace the committed state. A crash before replacement
+leaves the previous committed state intact and may leave an orphan Pathwise
+temporary file; the next initialization removes such orphan temporary state
+under the stable exclusive lock. Pathwise does not guess recovery from a
+corrupt live state: corrupt, unsupported, empty, or truncated committed state
+fails explicitly.
+
+Operational notes
+-----------------
+
+* Keep the queue on a local filesystem with reliable file locking and rename
+ semantics.
+* Set ``reservationTimeout`` longer than the normal interval between lease
+ renewals for manual workers.
+* A handler used with ``process()`` should normally complete within the
+ reservation timeout because synchronous ``process()`` cannot heartbeat a
+ handler while user code is executing.
+* For long-running work requiring heartbeats, use the manual reservation API and
+ renew the lease from the worker's own execution model.
+* Queue payload, total state size, and total job count remain bounded by the
+ constructor limits.
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index de760a60..130b1b6f 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -1,14 +1,15 @@
Quickstart
==========
-This quickstart shows the fastest way to understand what Pathwise can do.
+This page shows the recommended Pathwise 4 entry points. Persistent storage
+configuration is instance-scoped; the facade remains stateless convenience.
1) Install
------------
+----------
.. code-block:: bash
- composer require infocyph/pathwise
+ composer require infocyph/pathwise:^4.0
2) Basic File Lifecycle
-----------------------
@@ -24,24 +25,35 @@ This quickstart shows the fastest way to understand what Pathwise can do.
$content = $file->read();
-3) Mount a Storage and Use Scheme Paths
----------------------------------------
+3) Create an Instance-Scoped Storage Context
+--------------------------------------------
.. code-block:: php
- use Infocyph\Pathwise\Storage\StorageFactory;
- use Infocyph\Pathwise\Utils\FlysystemHelper;
+ use Infocyph\Pathwise\Storage\StorageContext;
- StorageFactory::mount('assets', [
- 'driver' => 'local',
- 'root' => '/srv/storage/assets',
- ]);
+ $storage = new StorageContext([
+ 'assets' => [
+ 'driver' => 'local',
+ 'root' => '/srv/storage/assets',
+ ],
+ 'archive' => [
+ 'driver' => 'local',
+ 'root' => '/srv/storage/archive',
+ ],
+ ], 'assets');
- FlysystemHelper::write('assets://reports/a.txt', "hello\n");
- $text = FlysystemHelper::read('assets://reports/a.txt');
+ [$filesystem, $location] = $storage->resolve('reports/a.txt');
+ $filesystem->write($location, "hello\n");
-4) Directory Sync with Diff Report
-----------------------------------
+ [$archive, $archivePath] = $storage->resolve('archive://2026/a.txt');
+ $archive->write($archivePath, "archived\n");
+
+No global mount is registered. Two contexts may safely reuse the same logical
+filesystem names with different roots/operators.
+
+4) Directory Sync with a Typed Report
+-------------------------------------
.. code-block:: php
@@ -50,52 +62,81 @@ This quickstart shows the fastest way to understand what Pathwise can do.
$source = new DirectoryOperations('/tmp/source');
$report = $source->syncTo('/tmp/backup', deleteOrphans: true);
- // $report has created/updated/deleted entries
+ foreach ($report->created as $path) {
+ // newly-created entry
+ }
-5) Upload Validation and Chunk Finalization
--------------------------------------------
+5) Framework-Neutral Upload
+---------------------------
.. code-block:: php
use Infocyph\Pathwise\StreamHandler\UploadProcessor;
+ use Infocyph\Pathwise\StreamHandler\UploadSource;
$uploader = new UploadProcessor();
- $uploader->setDirectorySettings('/tmp/uploads');
+ $uploader->setStorageContext($storage);
+ $uploader->setDirectorySettings('assets://uploads', tempDir: sys_get_temp_dir());
$uploader->setValidationProfile('document');
- // Single upload:
- // $finalPath = $uploader->processUpload($_FILES['file']);
+ $source = UploadSource::fromStream(
+ stream: $inputStream,
+ clientFilename: 'report.pdf',
+ size: $knownSize,
+ clientMediaType: 'application/pdf',
+ );
+
+ $finalPath = $uploader->ingestSource($source);
+
+For a real PHP HTTP upload, ``processUpload($_FILES['file'])`` preserves the
+``is_uploaded_file()`` provenance check. ``UploadSource`` is the preferred
+framework-neutral bridge for uploaded-file abstractions, streams, and paths.
+
+6) Resumable Chunk Upload
+-------------------------
+
+.. code-block:: php
- // Chunked:
- $state = $uploader->processChunkUpload(
- chunkFile: $_FILES['chunk'],
+ $state = $uploader->processChunkUploadSource(
+ source: $chunkSource,
uploadId: 'session-42',
chunkIndex: 0,
totalChunks: 3,
originalFilename: 'video.mp4',
);
- if ($state['isComplete']) {
+ if ($state->complete) {
$finalPath = $uploader->finalizeChunkUpload('session-42');
}
-6) Compression with Filters + Progress
---------------------------------------
+``ChunkUploadState`` is a typed result; final publication happens only through
+``finalizeChunkUpload()``.
+
+7) Prepare and Stream a Download
+--------------------------------
.. code-block:: php
- use Infocyph\Pathwise\FileManager\FileCompression;
+ use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
+
+ $downloads = new DownloadProcessor();
+ $downloads->setStorageContext($storage);
+ $downloads->setAllowedRoots(['assets://downloads']);
+
+ $prepared = $downloads->prepareDownload(
+ path: 'assets://downloads/video.mp4',
+ rangeHeader: $_SERVER['HTTP_RANGE'] ?? null,
+ );
+
+ foreach ($downloads->streamChunks($prepared) as $chunk) {
+ echo $chunk;
+ }
- $zip = new FileCompression('/tmp/out.zip', true);
- $zip->setGlobPatterns(includePatterns: ['*.txt'], excludePatterns: ['*.tmp'])
- ->setProgressCallback(function (array $event): void {
- // operation, path, current, total
- })
- ->compress('/tmp/source')
- ->save();
+Use ``$prepared->status``, ``$prepared->headers`` and ``$prepared->range`` when
+bridging the metadata to an HTTP framework.
-7) Observability and Guardrails
--------------------------------
+8) Observability and Policy
+---------------------------
.. code-block:: php
@@ -113,3 +154,6 @@ This quickstart shows the fastest way to understand what Pathwise can do.
->setPolicyEngine($policy)
->setAuditTrail($audit)
->create('hello');
+
+Read :doc:`storage-context`, :doc:`upload-processing`,
+:doc:`download-processing`, :doc:`security`, and :doc:`migration-4.0` next.
diff --git a/docs/recipes.rst b/docs/recipes.rst
index 9310bfc7..17e1c406 100644
--- a/docs/recipes.rst
+++ b/docs/recipes.rst
@@ -1,46 +1,71 @@
Recipes
=======
-Short end-to-end examples for common real workloads.
+Short Pathwise 4 workflow examples. These intentionally preserve the boundary
+between direct-local OS capabilities and instance-scoped Flysystem storage.
-Recipe 1: Ingest -> Audit -> Retain
------------------------------------
-
-Goal:
-
-* Validate uploads.
-* Record operations.
-* Keep only latest artifacts.
+Recipe 1: Framework Upload -> Scan -> Object Storage
+----------------------------------------------------
.. code-block:: php
- use Infocyph\Pathwise\Observability\AuditTrail;
- use Infocyph\Pathwise\Retention\RetentionManager;
+ use Infocyph\Pathwise\Storage\StorageContext;
+ use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
use Infocyph\Pathwise\StreamHandler\UploadProcessor;
+ use Infocyph\Pathwise\StreamHandler\UploadSource;
+
+ $storage = new StorageContext([
+ 'objects' => ['filesystem' => $objectFilesystem],
+ ], 'objects');
$uploader = new UploadProcessor();
- $uploader->setDirectorySettings('/tmp/uploads');
+ $uploader->setStorageContext($storage);
+ $uploader->setDirectorySettings('objects://uploads', tempDir: sys_get_temp_dir());
$uploader->setValidationProfile('document');
+ $uploader->setMalwareScanner($scanner);
+ $uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
- $audit = new AuditTrail('/tmp/audit.jsonl');
+ $source = UploadSource::fromMover(
+ fn (string $target): void => $uploadedFile->moveTo($target),
+ $uploadedFile->getClientFilename() ?? 'upload.bin',
+ $uploadedFile->getSize(),
+ $uploadedFile->getClientMediaType(),
+ $uploadedFile->getError(),
+ );
- $finalPath = $uploader->processUpload($_FILES['file']);
- $audit->log('upload.processed', ['path' => $finalPath]);
+ $finalPath = $uploader->ingestSource($source);
- $retention = RetentionManager::apply('/tmp/uploads', keepLast: 50, maxAgeDays: 30);
- $audit->log('retention.applied', [
- 'deleted' => $retention->deleted,
- 'kept' => $retention->kept,
- ]);
+The framework does not create a synthetic ``$_FILES`` record. Pathwise owns the
+local staging copy and scanner lifecycle, while the destination remains an
+instance-scoped object filesystem.
-Recipe 2: Mirror + Zip + Checksum
----------------------------------
+Recipe 2: Prepare a Framework Download
+--------------------------------------
-Goal:
+.. code-block:: php
-* Mirror a working directory to backup.
-* Create archive.
-* Verify archive checksum.
+ use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
+
+ $downloads = new DownloadProcessor();
+ $downloads->setStorageContext($storage);
+ $downloads->setAllowedRoots(['objects://downloads']);
+
+ $prepared = $downloads->prepareDownload(
+ 'objects://downloads/report.pdf',
+ 'report.pdf',
+ $requestRange,
+ );
+
+ // Framework owns the response object.
+ $status = $prepared->status;
+ $headers = $prepared->headers;
+ $body = $downloads->streamChunks($prepared);
+
+The body iterator is range-aware and closes its source if the consumer stops
+early.
+
+Recipe 3: Local Mirror -> Archive -> Verify
+-------------------------------------------
.. code-block:: php
@@ -48,57 +73,101 @@ Goal:
use Infocyph\Pathwise\FileManager\FileOperations;
$source = new DirectoryOperations('/tmp/project-output');
- $syncReport = $source->syncTo('/tmp/project-backup', deleteOrphans: true);
-
+ $sync = $source->syncTo('/tmp/project-backup', deleteOrphans: true);
$source->zip('/tmp/project-output.zip');
- $zipFile = new FileOperations('/tmp/project-output.zip');
- $expected = hash('sha256', $zipFile->read());
- $isValid = $zipFile->verifyChecksum($expected, 'sha256');
+ $archive = new FileOperations('/tmp/project-output.zip');
+ $expected = hash_file('sha256', '/tmp/project-output.zip');
+ $verified = is_string($expected)
+ && $archive->verifyChecksum($expected, 'sha256');
+
+Archive creation/extraction applies Pathwise's archive-entry validation and
+configured safety limits. Native optimization remains bounded and falls back
+only where the operation contract permits it.
-Recipe 3: Duplicate Scan + Optional Dedupe
-------------------------------------------
+Recipe 4: Audit -> Retain
+-------------------------
-Goal:
+.. code-block:: php
-* Find duplicate files by content hash.
-* Optionally deduplicate via hard links where possible.
+ use Infocyph\Pathwise\Observability\AuditTrail;
+ use Infocyph\Pathwise\Retention\RetentionManager;
+
+ $audit = new AuditTrail('/var/log/app/pathwise.jsonl');
+ $audit->log('artifact.published', ['path' => $finalPath]);
+
+ $retention = RetentionManager::apply(
+ '/srv/app/artifacts',
+ keepLast: 50,
+ maxAgeDays: 30,
+ );
+
+ $audit->log('retention.applied', [
+ 'deleted' => $retention->deleted,
+ 'kept' => $retention->kept,
+ ]);
+
+For high-volume audit output, use ``PartitionedAuditSink`` to bound individual
+files instead of treating one unbounded JSONL file as a log service.
+
+Recipe 5: File Queue Lease Worker
+---------------------------------
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\Queue\FileJobQueue;
+
+ $queue = new FileJobQueue('/var/lib/app/work.json');
+ $queue->enqueue('render', ['asset' => '42'], priority: 10);
+
+ while (($reservation = $queue->reserve()) !== null) {
+ try {
+ render($reservation->payload);
+ $queue->acknowledge($reservation);
+ } catch (Throwable $failure) {
+ $queue->fail($reservation, $failure);
+ }
+ }
+
+For long-running work, call ``renew()`` before the reservation timeout. A stale
+reservation cannot acknowledge/release/fail a job after another worker has
+acquired a new lease.
+
+Recipe 6: Duplicate Scan and Local Dedupe
+-----------------------------------------
.. code-block:: php
use Infocyph\Pathwise\Indexing\ChecksumIndexer;
- $duplicates = ChecksumIndexer::findDuplicates('/tmp/media', 'sha256');
+ $duplicates = ChecksumIndexer::findDuplicates('/srv/media', 'sha256');
if ($duplicates !== []) {
- $dedupeReport = ChecksumIndexer::deduplicateWithHardLinks('/tmp/media');
- // linked[] and skipped[] in report
+ $result = ChecksumIndexer::deduplicateWithHardLinks('/srv/media', 'sha256');
}
-Recipe 4: Mounted Storage Workflow
-----------------------------------
+Hard-link deduplication is intentionally local and filesystem-dependent. Treat
+``DeduplicationResult::skipped`` as an expected portability signal rather than
+assuming every platform/filesystem can link every candidate.
-Goal:
-
-* Work against mounted storage with the same Pathwise APIs.
+Recipe 7: Two Applications, Same Logical Disk Name
+--------------------------------------------------
.. code-block:: php
- use Infocyph\Pathwise\FileManager\FileCompression;
- use Infocyph\Pathwise\Storage\StorageFactory;
- use Infocyph\Pathwise\Utils\FlysystemHelper;
+ $appA = new StorageContext([
+ 'files' => ['driver' => 'local', 'root' => '/srv/app-a'],
+ ], 'files');
- StorageFactory::mount('mnt', [
- 'driver' => 'local',
- 'root' => '/srv/storage',
- ]);
+ $appB = new StorageContext([
+ 'files' => ['driver' => 'local', 'root' => '/srv/app-b'],
+ ], 'files');
- FlysystemHelper::write('mnt://source/a.txt', 'A');
- FlysystemHelper::write('mnt://source/b.txt', 'B');
+ $uploadA->setStorageContext($appA);
+ $uploadB->setStorageContext($appB);
- (new FileCompression('mnt://archives/source.zip', true))
- ->compress('mnt://source')
- ->save();
+ $uploadA->setDirectorySettings('files://uploads');
+ $uploadB->setDirectorySettings('files://uploads');
- (new FileCompression('mnt://archives/source.zip'))
- ->decompress('mnt://restored');
+Both processors use ``files://uploads`` without sharing operators, roots, or
+process-global registration.
diff --git a/docs/release-4.0.rst b/docs/release-4.0.rst
new file mode 100644
index 00000000..7a828b4c
--- /dev/null
+++ b/docs/release-4.0.rst
@@ -0,0 +1,103 @@
+Pathwise 4.0
+============
+
+Pathwise 4.0 is the runtime-architecture and hardening major. It keeps Pathwise
+framework-neutral while making ownership, storage topology, security policy,
+resource bounds, and platform capabilities explicit.
+
+Major Changes
+-------------
+
+Storage/runtime
+~~~~~~~~~~~~~~~
+
+* ``StorageContext`` is the recommended persistent-runtime storage registry and
+ resolver.
+* Named filesystems, defaults, lazy operators and custom drivers are isolated
+ per context.
+* ``StorageFactory`` is stateless; process-global custom-driver/mount gateways
+ were removed from that layer/facade.
+* Upload/download processors can consume ``StorageContext`` directly without
+ global mount mutation.
+
+Uploads/scanning
+~~~~~~~~~~~~~~~~
+
+* ``UploadSource`` provides typed framework-neutral mover/path/stream ingestion
+ with explicit ownership and deterministic staging cleanup.
+* ``MalwareScannerInterface`` / request / verdict / mode/status types replace
+ ambiguous scanner callbacks.
+* Required scanning fails closed; scanner backend text is not leaked as the
+ stable upload error; scan input integrity is checked.
+* Chunk sessions use strict IDs/manifests, explicit finalization and typed
+ ``ChunkUploadState``.
+
+Downloads
+~~~~~~~~~
+
+* ``DownloadPreparation`` and ``RangeDownloadMetadata`` separate metadata from
+ delivery.
+* ``streamChunks()`` owns range seek/discard/accounting and source cleanup for
+ framework iterable bodies.
+* Preparations are revalidated before streaming so stale/manually-created
+ metadata cannot bypass current download policy.
+
+Security/reliability
+~~~~~~~~~~~~~~~~~~~~
+
+* ``PolicyEngine`` is deny-by-default with explicit permissive opt-in.
+* ZIP extraction is manifest-driven with traversal/collision/special-entry,
+ size/ratio and write-time validation.
+* Serialization validation does not instantiate untrusted objects.
+* ``SafeSymlinkManager`` owns generic direct-local link safety.
+* File transaction rollback/state failures are explicit.
+* Native execution is argv-based, non-blocking and bounded by timeout/output/
+ termination limits.
+
+Queue/operations
+~~~~~~~~~~~~~~~~
+
+* ``FileJobQueue`` uses typed opaque leases, expiry/renewal ownership checks,
+ stale-worker rejection, strict versioned state and crash-safe local
+ persistence.
+* Audit sinks include callback, local JSONL and partitioned options.
+* Retention, checksum indexing/deduplication and file-watcher workflows return
+ typed results and apply bounded/validated behavior.
+
+Dependencies and Platforms
+--------------------------
+
+* PHP 8.4+
+* PHP 8.4 and 8.5 are release-matrix targets.
+* ``ext-fileinfo`` is required.
+* ``league/flysystem ^3.35.2`` is required.
+* ``psr/log ^3.0.2`` is a direct production dependency because public APIs
+ type-hint PSR-3 interfaces.
+* ZIP/POSIX/XML capabilities and remote Flysystem adapters remain optional.
+
+Breaking Changes
+----------------
+
+The major intentionally removes/changes unsafe or ambiguous 3.x behavior. See
+:doc:`migration-4.0` for storage topology, scanner, policy, queue, native,
+archive, serialization, result-object, and local-capability migration steps.
+
+Release Validation
+------------------
+
+The 4.0 release gate requires:
+
+* complete unit/feature/regression suite;
+* PHP 8.4/8.5 on supported CI platforms;
+* stable and prefer-lowest dependency matrices;
+* PHPStan/Psalm and PHPForge quality/security gates;
+* Windows platform tests;
+* optional adapter contract suite;
+* clean-install validation;
+* Sphinx documentation build with warnings treated as errors;
+* release benchmark/stress workloads covering streaming, directories, archives,
+ queue leases, observability and transaction paths;
+* final security/capability review.
+
+See :doc:`performance-portability` for workload interpretation and portability
+limits.
diff --git a/docs/retention.rst b/docs/retention.rst
index bcb8f750..8b7b5c5f 100644
--- a/docs/retention.rst
+++ b/docs/retention.rst
@@ -3,36 +3,60 @@ Retention
Namespace: ``Infocyph\Pathwise\Retention``
-``RetentionManager`` applies cleanup policies to directories.
+``RetentionManager`` evaluates deterministic count/age cleanup policy for a
+directory and returns the typed ``RetentionResult`` with ``deleted`` and
+``kept`` lists.
-Brief capabilities:
+Capabilities
+------------
-* Keep only latest N files.
-* Delete files older than configured age threshold.
-* Combine count-based and age-based pruning.
-* Return a readonly ``RetentionResult`` with ``deleted`` and ``kept`` lists.
-* Use ``mtime`` for adapter-backed storage; ``ctime`` is a direct-local-only
- capability and is rejected for mounted paths.
+* ``preview()`` returns the exact decision without mutating storage;
+* ``apply()`` uses the same decision engine and then deletes the selected files;
+* ``keepLast`` preserves the newest N entries according to the selected sort;
+* ``maxAgeDays`` removes entries older than the calculated cutoff;
+* count and age rules combine with OR semantics for deletion;
+* ties are resolved deterministically by path;
+* ``mtime`` works for direct-local and adapter-backed listings;
+* ``ctime`` is a direct-local-only capability and is rejected for
+ adapter-backed storage.
-Use cases:
+Preview First
+-------------
-* Rotating backups/log exports.
-* Enforcing disk usage windows for generated artifacts.
-
-Example
--------
+Use ``preview()`` when an operator/application should inspect or audit the exact
+cleanup set before mutation:
.. code-block:: php
use Infocyph\Pathwise\Retention\RetentionManager;
- $report = RetentionManager::apply(
+ $preview = RetentionManager::preview(
directory: '/tmp/backups',
keepLast: 7,
maxAgeDays: 30,
sortBy: 'mtime',
);
- foreach ($report->deleted as $deletedPath) {
- // Record or report the deleted path.
+ foreach ($preview->deleted as $candidate) {
+ // Report the candidate before applying the same policy.
}
+
+ $result = RetentionManager::apply(
+ directory: '/tmp/backups',
+ keepLast: 7,
+ maxAgeDays: 30,
+ sortBy: 'mtime',
+ );
+
+The filesystem may of course change between preview and apply. Pathwise
+therefore guarantees policy parity, not a distributed snapshot/isolation
+transaction across those two calls.
+
+Scaling
+-------
+
+Retention must collect and sort the candidate file set to make deterministic
+newest/age decisions. Memory therefore grows with the number of entries in the
+selected directory tree. For very large object stores, partition retention
+workloads by prefix/time bucket instead of treating one unbounded namespace as a
+single retention set. See :doc:`performance-portability`.
diff --git a/docs/security.rst b/docs/security.rst
index 9390df43..f4078261 100644
--- a/docs/security.rst
+++ b/docs/security.rst
@@ -1,40 +1,166 @@
-Security
-========
+Security Model
+==============
-Namespace: ``Infocyph\Pathwise\Security``
+Pathwise 4 treats filesystem input as a trust boundary. Security-sensitive
+workflows prefer explicit rejection to silent fallback when a required
+capability, ownership check, or validation step cannot be satisfied.
-``PolicyEngine`` provides operation-level allow/deny rules.
+PolicyEngine
+------------
-Brief capabilities:
+``Infocyph\Pathwise\Security\PolicyEngine`` is deny-by-default:
-* Register policy rules per operation/path pattern.
-* Support conditional callbacks for context-aware checks.
-* Enforce policy with explicit violations via ``PolicyViolationException``.
+.. code-block:: php
+
+ use Infocyph\Pathwise\Security\PolicyEngine;
+
+ $policy = (new PolicyEngine())
+ ->allow('read', '/srv/public/*')
+ ->allow('write', '/srv/work/*')
+ ->deny('write', '/srv/work/protected/*');
-Rules are evaluated in registration order and the **last matching rule wins**.
-This makes it possible to establish a broad default and then add narrower
-exceptions. Direct-local Windows paths are matched case-insensitively, while
-mounted and object-storage paths retain their adapter's case semantics.
+Rules are evaluated in registration order and the last matching rule wins.
+Rules may include a condition callback receiving operation, path and context.
+``assertAllowed()`` throws ``PolicyViolationException`` when denied.
-Typical use:
+Use ``new PolicyEngine(defaultAllow: true)`` only when unmatched operations are
+intentionally permissive.
-* Restrict write/delete to approved roots.
-* Block sensitive operations for specific runtime contexts.
-* Centralize file-operation authorization logic.
+Path and Root Safety
+--------------------
-Example
+Pathwise normalizes paths before sensitive operations and rejects null-byte and
+traversal forms at logical/storage boundaries. ``StorageContext`` accepts only
+relative logical paths or a configured ``name://`` scheme. Download allowed-root
+checks resolve paths inside the same filesystem rather than trusting textual
+prefixes across different storage operators.
+
+Windows direct-local policy matching is case-insensitive; scheme/object paths
+retain their storage semantics.
+
+Uploads
-------
-.. code-block:: php
+The upload pipeline layers:
- use Infocyph\Pathwise\FileManager\FileOperations;
- use Infocyph\Pathwise\Security\PolicyEngine;
+* real HTTP provenance through ``is_uploaded_file()`` for ``processUpload()``;
+* owned local staging for framework-neutral ``UploadSource``;
+* actual-size enforcement rather than caller metadata trust;
+* extension allow/block policy;
+* MIME/profile checks;
+* extension-to-MIME agreement and supported magic signatures;
+* optional image-dimension limits;
+* optional/required malware scanning;
+* deterministic naming/collision validation;
+* strict resumable-session IDs/manifests.
- $policy = (new PolicyEngine())
- ->allow('*', '*')
- ->deny('delete', '/var/app/protected/*');
+When malware scanning is ``REQUIRED``, a missing scanner or any non-clean
+verdict rejects publication. Scanner exceptions map to a stable upload failure
+while retaining the original exception. Pathwise verifies the scanner did not
+mutate its private scan copy and removes scan state on every path.
+
+Downloads
+---------
+
+``DownloadProcessor`` can enforce allowed roots, hidden-file blocking,
+extension policy, maximum size, safe download names, and single-range parsing.
+A ``DownloadPreparation`` is revalidated before the stream opens; callers cannot
+use stale/manually-created preparation metadata as an authorization token.
+
+The framework remains responsible for HTTP authorization/session logic and
+conditional request policy.
+
+ZIP and Archive Extraction
+--------------------------
+
+Archive processing is manifest-driven. Pathwise validates entries before
+publication and rejects unsafe archive behavior including:
+
+* absolute/traversal paths;
+* normalized path collisions and duplicate output targets;
+* unsupported/special entry types;
+* source symlinks where the workflow requires regular-file input;
+* configured entry-count, per-entry expanded-size, total expanded-size and
+ compression-ratio violations;
+* changes that invalidate assumptions during extraction/write.
+
+Validation is repeated at the write boundary where necessary so an archive
+cannot pass an early manifest check and then publish a different unsafe target.
+Do not disable these controls for benchmark performance.
+
+Safe Symbolic Links
+-------------------
+
+``FileManager\SafeSymlinkManager`` is a direct-local capability. Callers provide
+allowed link and target roots explicitly. The manager validates containment,
+existing link targets, replacement state and activation/removal rather than
+blindly calling ``symlink()``/``unlink()``.
+
+Application-specific public/storage path policy remains outside Pathwise; pass
+the resolved roots to the manager.
+
+Transactions and Atomicity
+--------------------------
+
+Local transactional mutation journals prior state and attempts rollback when a
+transaction fails. Rollback failure is surfaced through
+``TransactionRollbackException`` rather than hiding a potentially inconsistent
+result. Invalid transaction lifecycle/state uses ``TransactionStateException``.
+
+Remote/object storage must not be assumed to provide the same atomic rename,
+locking, or rollback semantics as a direct local filesystem.
+
+File Queue Integrity
+--------------------
+
+``FileJobQueue`` is direct-local and uses:
+
+* a stable lock file;
+* cryptographically random job/lease identifiers;
+* typed lease ownership and stale-worker rejection;
+* strict versioned JSON state validation;
+* job/payload/total byte limits;
+* same-directory temporary commit and replacement;
+* orphan temporary cleanup under the exclusive lock.
+
+Corrupt/empty/truncated/unsupported committed state fails explicitly. Pathwise
+does not guess a replacement state after corruption.
+
+Native Execution
+----------------
+
+Native commands use argv-style execution rather than shell command string
+composition. ``NativeExecutionLimits`` bounds timeout, stdout, stderr,
+termination grace and polling. Timeout/output-limit failures terminate and
+clean up the child deterministically.
+
+Native executables are optional capabilities. If an operation cannot safely
+fall back to PHP, capability absence is surfaced rather than silently changing
+semantics.
+
+Serialization Boundary
+----------------------
+
+``SerializedValueValidator`` inspects serialized input defensively. It is not an
+object-instantiation API and must not be used to reconstruct untrusted classes.
+Keep application deserialization of trusted domain data outside Pathwise's
+validation boundary.
+
+Audit Data
+----------
+
+``AuditTrail`` and sinks are operational observability primitives, not a secret
+store. Do not log credentials, tokens, full uploaded payloads, or backend error
+material that should remain private. Prefer ``PartitionedAuditSink`` when one
+local JSONL file could grow without bound.
+
+Capability Failures
+-------------------
- $file = (new FileOperations('/var/app/data/report.txt'))
- ->setPolicyEngine($policy);
+Pathwise distinguishes invalid input, policy rejection, unsupported storage
+operations, missing platform capabilities, and operational failures through
+typed exceptions. Applications should branch on exception type/stable semantics
+instead of parsing backend-specific message strings.
- $file->create('ok'); // allowed
+See :doc:`api-reference`, :doc:`upload-processing`, :doc:`download-processing`,
+:doc:`symlink-management`, :doc:`queue`, and :doc:`performance-portability`.
diff --git a/docs/storage-adapters.rst b/docs/storage-adapters.rst
index b93ea62b..13e0cee0 100644
--- a/docs/storage-adapters.rst
+++ b/docs/storage-adapters.rst
@@ -1,185 +1,211 @@
Storage Adapters
================
-Pathwise is built on Flysystem 3, so you can use **any Flysystem adapter**
-as soon as its package is installed and mounted.
+Pathwise 4 uses Flysystem 3 for storage-neutral I/O while keeping storage
+topology explicit. There are two complementary APIs:
-Use ``Infocyph\Pathwise\Storage\StorageFactory`` to standardize setup.
+* ``StorageFactory`` is a stateless filesystem constructor and driver metadata
+ helper.
+* ``StorageContext`` owns named filesystems, default selection, lazy operator
+ instances, logical paths, and custom driver factories for one runtime.
-What ``StorageFactory`` Supports
---------------------------------
+There is no process-global custom-driver registry in ``StorageFactory`` and no
+``StorageFactory::mount()``/``mountMany()`` topology API in Pathwise 4.
-``StorageFactory::createFilesystem(array $config)`` accepts:
+StorageFactory
+--------------
-* local driver config: ``['driver' => 'local', 'root' => '/srv/storage']``
-* prebuilt filesystem: ``['filesystem' => $filesystemOperator]``
-* adapter instance: ``['adapter' => $adapter, 'options' => [...]]``
-* custom named drivers registered at runtime.
+``StorageFactory::createFilesystem(array $config)`` accepts exactly one storage
+construction mode:
-``StorageFactory::mount(string $name, array $config)`` creates and mounts in one step.
+* local driver configuration, for example
+ ``['driver' => 'local', 'root' => '/srv/storage']``;
+* a prebuilt ``FilesystemOperator`` through ``['filesystem' => $operator]``;
+* a prebuilt ``FilesystemAdapter`` through ``['adapter' => $adapter]``;
+* an official driver plus positional adapter constructor arguments.
-``StorageFactory::mountMany(array $mounts)`` mounts multiple storages at once.
-
-Driver config modes:
-
-* direct adapter object:
- ``['driver' => 'aws-s3', 'adapter' => $adapter]``
-* constructor arguments for official adapter classes:
- ``['driver' => 'aws-s3', 'constructor' => [$client, $bucket, $prefix]]``
-
-``StorageFactory`` also exposes:
-
-* ``StorageFactory::officialDrivers()`` for official driver metadata.
-* ``StorageFactory::suggestedPackage($driver)`` for install guidance.
-
-Official Adapter Coverage
--------------------------
-
-The following official Flysystem adapters are mapped by driver key:
-
-* ``local`` -> ``league/flysystem-local`` -> ``League\Flysystem\Local\LocalFilesystemAdapter``
-* ``ftp`` -> ``league/flysystem-ftp`` -> ``League\Flysystem\Ftp\FtpAdapter``
-* ``inmemory`` (alias: ``in-memory``) -> ``league/flysystem-memory`` -> ``League\Flysystem\InMemory\InMemoryFilesystemAdapter``
-* ``read-only`` (alias: ``readonly``) -> ``league/flysystem-read-only`` -> ``League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter``
-* ``path-prefixing`` (alias: ``path-prefix``) -> ``league/flysystem-path-prefixing`` -> ``League\Flysystem\PathPrefixing\PathPrefixedAdapter``
-* ``aws-s3`` (aliases: ``s3``, ``aws``) -> ``league/flysystem-aws-s3-v3`` -> ``League\Flysystem\AwsS3V3\AwsS3V3Adapter``
-* ``async-aws-s3`` -> ``league/flysystem-async-aws-s3`` -> ``League\Flysystem\AsyncAwsS3\AsyncAwsS3Adapter``
-* ``azure-blob-storage`` (alias: ``azure``) -> ``league/flysystem-azure-blob-storage`` -> ``League\Flysystem\AzureBlobStorage\AzureBlobStorageAdapter``
-* ``google-cloud-storage`` (alias: ``gcs``) -> ``league/flysystem-google-cloud-storage`` -> ``League\Flysystem\GoogleCloudStorage\GoogleCloudStorageAdapter``
-* ``mongodb-gridfs`` (alias: ``gridfs``) -> ``league/flysystem-gridfs`` -> ``League\Flysystem\GridFS\GridFSAdapter``
-* ``sftp-v2`` (alias: ``sftp2``) -> ``league/flysystem-sftp-v2`` -> ``League\Flysystem\PhpseclibV2\SftpAdapter``
-* ``sftp-v3`` (alias: ``sftp3``) -> ``league/flysystem-sftp-v3`` -> ``League\Flysystem\PhpseclibV3\SftpAdapter``
-* ``webdav`` -> ``league/flysystem-webdav`` -> ``League\Flysystem\WebDAV\WebDAVAdapter``
-* ``ziparchive`` (alias: ``zip``) -> ``league/flysystem-ziparchive`` -> ``League\Flysystem\ZipArchive\ZipArchiveAdapter``
-
-If a package is missing, ``StorageFactory`` throws an install hint with the package name.
-
-Basic Local Example
--------------------
+Ambiguous combinations are rejected.
.. code-block:: php
use Infocyph\Pathwise\Storage\StorageFactory;
- use Infocyph\Pathwise\Utils\FlysystemHelper;
- StorageFactory::mount('assets', [
+ $filesystem = StorageFactory::createFilesystem([
'driver' => 'local',
- 'root' => '/srv/storage/assets',
+ 'root' => '/srv/storage',
]);
- FlysystemHelper::write('assets://images/logo.txt', 'ok');
-
-Any Adapter Example (S3)
+ $filesystem->write('reports/a.txt', "hello\n");
+
+The metadata helpers are:
+
+* ``StorageFactory::officialDrivers()``
+* ``StorageFactory::isOfficialDriver($driver)``
+* ``StorageFactory::suggestedPackage($driver)``
+
+Official Drivers
+----------------
+
+Pathwise maps these Flysystem driver keys:
+
+.. list-table::
+ :header-rows: 1
+
+ * - Driver
+ - Package
+ * - ``local``
+ - ``league/flysystem-local``
+ * - ``ftp``
+ - ``league/flysystem-ftp``
+ * - ``inmemory``
+ - ``league/flysystem-memory``
+ * - ``read-only``
+ - ``league/flysystem-read-only``
+ * - ``path-prefixing``
+ - ``league/flysystem-path-prefixing``
+ * - ``aws-s3``
+ - ``league/flysystem-aws-s3-v3``
+ * - ``async-aws-s3``
+ - ``league/flysystem-async-aws-s3``
+ * - ``azure-blob-storage``
+ - ``league/flysystem-azure-blob-storage``
+ * - ``google-cloud-storage``
+ - ``league/flysystem-google-cloud-storage``
+ * - ``mongodb-gridfs``
+ - ``league/flysystem-gridfs``
+ * - ``sftp-v2``
+ - ``league/flysystem-sftp-v2``
+ * - ``sftp-v3``
+ - ``league/flysystem-sftp-v3``
+ * - ``webdav``
+ - ``league/flysystem-webdav``
+ * - ``ziparchive``
+ - ``league/flysystem-ziparchive``
+
+Aliases such as ``s3``/``aws``, ``memory``, ``readonly``, ``gcs``, ``azure``,
+``sftp2``, ``sftp3`` and ``zip`` normalize to the canonical driver names.
+Optional adapter packages stay optional. If a selected adapter class is not
+installed, Pathwise returns an explicit install/configuration error rather than
+silently falling back.
+
+Prebuilt Adapter Example
------------------------
-Install adapter package first (example):
-
-.. code-block:: bash
-
- composer require league/flysystem-aws-s3-v3 aws/aws-sdk-php
-
-Then pass the adapter directly:
-
.. code-block:: php
+ use Aws\S3\S3Client;
use Infocyph\Pathwise\Storage\StorageFactory;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
- use Aws\S3\S3Client;
$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', [
+ $filesystem = StorageFactory::createFilesystem([
'adapter' => $adapter,
]);
- // Storage-neutral workflows can now address s3://uploads/a.pdf.
- // Native processes, direct locks, POSIX metadata, and transactions remain local-only.
+Official Constructor Mode
+-------------------------
-Constructor mode example (official drivers):
+When the adapter package is installed, official drivers may receive positional
+constructor arguments:
.. code-block:: php
- StorageFactory::mount('s3', [
- 'driver' => 's3',
+ $filesystem = StorageFactory::createFilesystem([
+ 'driver' => 'aws-s3',
'constructor' => [$client, 'my-bucket', 'app-prefix'],
]);
-Read-only/path-prefix wrappers (official adapters):
+For complex adapters, constructing the adapter in application/bootstrap code
+and passing ``adapter`` or ``filesystem`` is often clearer and easier to test.
+
+StorageContext: Recommended Runtime Model
+-----------------------------------------
.. code-block:: php
- use League\Flysystem\Local\LocalFilesystemAdapter;
+ use Infocyph\Pathwise\Storage\StorageContext;
- StorageFactory::mount('readonly', [
- 'driver' => 'read-only',
- 'constructor' => [new LocalFilesystemAdapter('/srv/storage')],
- ]);
+ $storage = new StorageContext([
+ 'primary' => [
+ 'driver' => 'local',
+ 'root' => '/srv/app/storage',
+ ],
+ 'objects' => [
+ 'filesystem' => $s3Filesystem,
+ ],
+ ], 'primary');
- StorageFactory::mount('prefixed', [
- 'driver' => 'path-prefixing',
- 'constructor' => [new LocalFilesystemAdapter('/srv/storage'), 'tenant-a'],
- ]);
+ [$filesystem, $location] = $storage->resolve('objects://uploads/a.pdf');
+ $filesystem->write($location, $contents);
-Custom Driver Registration
---------------------------
+Relative logical paths select the context default. ``name://path`` selects a
+configured filesystem. Absolute filesystem paths are deliberately not accepted
+as logical context paths; use ``localPath()`` when a local context disk must be
+exposed to a native/local-only capability.
-If you want environment-driven config, register a custom driver once:
+Custom Drivers Are Context-Scoped
+---------------------------------
.. code-block:: php
- use Infocyph\Pathwise\Storage\StorageFactory;
+ use Infocyph\Pathwise\Storage\StorageContext;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
- StorageFactory::registerDriver('tenant-local', function (array $config): Filesystem {
- $tenant = (string) ($config['tenant'] ?? 'default');
- $root = '/srv/tenants/' . $tenant;
-
- return new Filesystem(new LocalFilesystemAdapter($root));
- });
+ $storage = new StorageContext(
+ ['tenant' => ['driver' => 'tenant-local', 'tenant' => 'acme']],
+ 'tenant',
+ [
+ 'tenant-local' => static 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',
- ]);
+Custom factories are isolated to that context. Two applications in the same
+process can reuse ``tenant`` and ``tenant-local`` without cross-talk.
- // tenant://docs/report.txt
+Processor Integration
+---------------------
-Facade Gateways
----------------
+``UploadProcessor`` and ``DownloadProcessor`` accept a context directly:
-Use ``PathwiseFacade::createFilesystem()``, ``mountStorage()``, and
-``mountStorages()`` when a static gateway is preferable. Pathwise 3.0 does not
-autoload global helper functions.
+.. code-block:: php
-Processor Integration Notes
----------------------------
+ $uploader->setStorageContext($storage);
+ $uploader->setDirectorySettings('objects://uploads', tempDir: sys_get_temp_dir());
-``UploadProcessor`` and ``DownloadProcessor`` already work with mounted paths.
+ $downloads->setStorageContext($storage);
+ $downloads->setAllowedRoots(['objects://downloads']);
-Examples:
+Configure the context **before** path-dependent processor settings. Processors
+do not register global mounts. Direct absolute paths remain local; context
+relative/scheme paths route through the configured context.
-* upload destination: ``$uploader->setDirectorySettings('s3://uploads')``
-* chunk temp dir on mounted storage: ``$uploader->setDirectorySettings('s3://uploads', false, 's3://tmp')``
-* download root restriction for mounted storage:
- ``$downloads->setAllowedRoots(['s3://uploads'])``
+Local-Only Capabilities
+-----------------------
-Recommended Operational Pattern
--------------------------------
+Some Pathwise features deliberately require a direct local path because they
+need OS primitives that remote object stores cannot provide safely:
-For remote object stores, common production setup is:
+* file queue locking/durable state;
+* native processes;
+* POSIX/Windows ownership operations;
+* local transactions and native atomic rename semantics;
+* safe symbolic-link creation/removal.
-* receive chunks on fast local temp storage
-* finalize and write merged object to remote mount
+Do not emulate these capabilities on object storage. Keep a local working area
+when a workflow needs them, then move the resulting artifact through Flysystem.
-This reduces object churn and upload latency compared with writing every chunk
-as a separate remote object.
+See :doc:`storage-context`, :doc:`storage-contracts`, and
+:doc:`performance-portability` for the complete runtime and capability model.
diff --git a/docs/storage-context.rst b/docs/storage-context.rst
new file mode 100644
index 00000000..ff9002f2
--- /dev/null
+++ b/docs/storage-context.rst
@@ -0,0 +1,202 @@
+Storage Context
+===============
+
+``Infocyph\Pathwise\Storage\StorageContext`` is Pathwise 4's preferred
+storage-topology boundary for applications, workers, long-running processes,
+and multi-application runtimes. A context owns its filesystem configuration,
+default filesystem, lazily-created Flysystem operators, and custom driver
+factories. It does **not** register process-global mounts.
+
+Why use a context?
+------------------
+
+Use ``StorageContext`` when storage configuration belongs to an application or
+execution host rather than to the whole PHP process. Two contexts may safely
+reuse names such as ``files`` or ``archive`` while pointing at completely
+different roots or adapters.
+
+The low-level static ``FlysystemHelper`` mount/default APIs still exist for
+standalone scripts and direct compatibility use. They are not the recommended
+persistent-runtime topology model.
+
+Basic local context
+-------------------
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\Storage\StorageContext;
+
+ $storage = new StorageContext(
+ configurations: [
+ 'files' => [
+ 'driver' => 'local',
+ 'root' => '/srv/app/storage',
+ ],
+ 'archive' => [
+ 'driver' => 'local',
+ 'root' => '/srv/app/archive',
+ ],
+ ],
+ defaultFilesystem: 'files',
+ );
+
+ $storage->filesystem()->write('documents/readme.txt', 'hello');
+ $storage->filesystem('archive')->write('2026/readme.txt', 'archived');
+
+ [$filesystem, $location] = $storage->resolve('archive://2026/readme.txt');
+ $contents = $filesystem->read($location);
+
+Logical paths
+-------------
+
+Relative paths select the context default filesystem. ``name://path`` selects
+an explicitly configured filesystem.
+
+.. code-block:: php
+
+ $defaultPath = $storage->path('reports/q1.pdf');
+ // files://reports/q1.pdf
+
+ $archivePath = $storage->path('reports/q1.pdf', 'archive');
+ // archive://reports/q1.pdf
+
+ [$filesystem, $location] = $storage->resolve('archive://reports/q1.pdf');
+ // $location === 'reports/q1.pdf'
+
+Logical paths are adapter-relative. Absolute logical paths, parent-directory
+traversal, null bytes, Windows drive paths, and UNC-style absolute paths are
+rejected rather than reinterpreted.
+
+Local-path capability
+---------------------
+
+A local context filesystem exposes its physical root through ``localPath()``.
+Remote/adapted filesystems do not pretend to have native paths.
+
+.. code-block:: php
+
+ if ($storage->isLocal('files')) {
+ $nativePath = $storage->localPath('reports/q1.pdf', 'files');
+ }
+
+Calling ``localPath()`` for a non-local filesystem throws an
+``InvalidArgumentException``. Use ``filesystem()`` or ``resolve()`` for
+storage-neutral operations instead.
+
+Processor integration
+---------------------
+
+``UploadProcessor`` and ``DownloadProcessor`` accept a context directly. This
+keeps their logical paths isolated from process-global mounts.
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
+ use Infocyph\Pathwise\StreamHandler\UploadProcessor;
+ use Infocyph\Pathwise\StreamHandler\UploadSource;
+
+ $uploads = new UploadProcessor();
+ $uploads->setStorageContext($storage);
+ $uploads->setDirectorySettings('files://uploads');
+
+ $stored = $uploads->ingestSource(
+ UploadSource::fromPath('/srv/import/report.pdf', 'report.pdf'),
+ );
+
+ $downloads = new DownloadProcessor();
+ $downloads->setStorageContext($storage);
+ $downloads->setAllowedRoots(['files://uploads']);
+
+ $preparation = $downloads->prepareDownload($stored);
+ foreach ($downloads->streamChunks($preparation) as $chunk) {
+ echo $chunk;
+ }
+
+Processor context configuration is optional. Without a context, the existing
+low-level ``FlysystemHelper`` routing model remains available for direct local,
+default-Flysystem, and explicitly mounted helper paths.
+
+Custom context drivers
+----------------------
+
+Custom driver factories belong to a context rather than a global registry.
+Each factory receives the filesystem configuration and must return a Flysystem
+``FilesystemOperator``.
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\Storage\StorageContext;
+ use League\Flysystem\Filesystem;
+ use League\Flysystem\Local\LocalFilesystemAdapter;
+
+ $storage = new StorageContext(
+ configurations: [
+ 'tenant' => [
+ 'driver' => 'tenant-local',
+ 'tenant' => 'acme',
+ ],
+ ],
+ defaultFilesystem: 'tenant',
+ drivers: [
+ 'tenant-local' => static function (array $config): Filesystem {
+ $tenant = (string) ($config['tenant'] ?? 'default');
+
+ return new Filesystem(
+ new LocalFilesystemAdapter('/srv/tenants/' . $tenant),
+ );
+ },
+ ],
+ );
+
+The same custom driver name may be defined differently by another context
+without cross-talk. Official driver names are reserved and continue to be
+created through ``StorageFactory``.
+
+Prebuilt adapters and operators
+-------------------------------
+
+A context configuration may contain any configuration accepted by
+``StorageFactory::createFilesystem()``. For example, a prebuilt Flysystem
+adapter can be supplied directly:
+
+.. code-block:: php
+
+ $storage = new StorageContext([
+ 'remote' => [
+ 'adapter' => $adapter,
+ ],
+ ], 'remote');
+
+Or a complete ``FilesystemOperator`` can be supplied:
+
+.. code-block:: php
+
+ $storage = new StorageContext([
+ 'remote' => [
+ 'filesystem' => $filesystem,
+ ],
+ ], 'remote');
+
+See :doc:`storage-adapters` for official adapter package names and constructor
+configuration examples.
+
+Multiple applications in one process
+------------------------------------
+
+Do not create unique global mount names as an application-isolation strategy.
+Give each application its own ``StorageContext`` instead:
+
+.. code-block:: php
+
+ $appA = new StorageContext([
+ 'files' => ['driver' => 'local', 'root' => '/srv/a/storage'],
+ ], 'files');
+
+ $appB = new StorageContext([
+ 'files' => ['driver' => 'local', 'root' => '/srv/b/storage'],
+ ], 'files');
+
+ $appA->filesystem()->write('same.txt', 'A');
+ $appB->filesystem()->write('same.txt', 'B');
+
+Both contexts use ``files`` without sharing operators or mutable topology.
diff --git a/docs/storage-contracts.rst b/docs/storage-contracts.rst
index 6a6a0214..43ed0822 100644
--- a/docs/storage-contracts.rst
+++ b/docs/storage-contracts.rst
@@ -1,21 +1,26 @@
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.
+Pathwise separates **path syntax**, **runtime topology**, and **storage
+capability**. ``StorageContext`` is the preferred instance-scoped topology for
+applications and persistent runtimes. Low-level ``FlysystemHelper``
+default/mount routing remains available for standalone utility use.
+
+A local Flysystem adapter is still adapter-backed when addressed through a
+context/default/mount. Pathwise does not unwrap the adapter and silently claim
+native filesystem guarantees that the logical storage surface cannot promise.
Compatibility Matrix
--------------------
.. list-table::
:header-rows: 1
- :widths: 34 20 20 26
+ :widths: 34 20 23 23
* - Capability
- Direct local path
- - Default Flysystem path
- - Mounted scheme path
+ - StorageContext / adapter path
+ - Low-level default/mount path
* - Read/write/stream/copy/visibility
- Supported
- Adapter-dependent
@@ -30,8 +35,8 @@ Compatibility Matrix
- Adapter-dependent
* - ZIP creation/extraction
- Supported
- - Streamed through local staging
- - Streamed through local staging
+ - Streamed through bounded local staging where needed
+ - Streamed through bounded local staging where needed
* - Native append
- Supported
- Rejected
@@ -46,70 +51,90 @@ Compatibility Matrix
- Rejected
* - POSIX modes, owner, and group
- Platform-dependent
- - Rejected
- - Rejected
- * - Direct locks/handles and shell search
+ - Rejected as a portable storage guarantee
+ - Rejected as a portable storage guarantee
+ * - Direct locks/handles and native tools
- 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.
+Runtime Topology
+----------------
+
+Use ``StorageContext`` when logical names belong to an application/runtime:
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\Storage\StorageContext;
+
+ $storage = new StorageContext([
+ 'files' => ['driver' => 'local', 'root' => '/srv/app/files'],
+ ], 'files');
+
+ [$filesystem, $location] = $storage->resolve('files://reports/a.csv');
+
+Contexts own their filesystem instances and custom driver factories. Two
+contexts may reuse the same logical name without process-global cross-talk.
+Inject the context into ``UploadProcessor`` or ``DownloadProcessor`` before
+configuring relative or ``name://`` paths.
+
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.
+``SafeFileWriter::enableAtomicWrite()`` is a **direct-local guarantee**. It
+stages in the destination directory and requires the final local rename to
+succeed. Adapter-backed destinations do not pretend that a final object write
+is an atomic rename; request normal staged writing instead.
-``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``.
+``FileOperations`` transactions are direct-local only. They use structured
+journal entries and disk-backed private rollback copies, restore file
+existence/content and permission bits, restore copy destinations, and reset the
+object path after rename rollback. Transactions are process-local, reject
+nesting, and do not provide database isolation. Invalid commit/rollback
+lifecycle raises ``TransactionStateException`` and rollback failure is explicit.
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.
+native append semantics and optional exclusive locking without reading the
+existing file. Flysystem does not define portable append semantics, so
+adapter-backed callers must choose ``appendEmulated()`` and accept a complete
+object read/replacement.
+
+Audit logging follows the same capability rule: local JSONL uses locked append;
+remote/application pipelines should use ``PartitionedAuditSink`` or
+``CallbackAuditSink`` rather than hiding whole-object rewrites.
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. Entry-count, per-entry uncompressed-size,
-total uncompressed-size, and compression-ratio limits are enforced before any
-destination mutation. Remote compression stages each entry to bounded temporary
-disk and registers the staged file with ``ZipArchive``; it does not load an
-entire remote entry into one PHP string.
+``FileCompression::decompress()``, selective extraction, and
+``DirectoryOperations::unzip()`` share the hardened archive validation path.
+The complete manifest is validated before publication and rejects traversal,
+absolute/drive/UNC paths, null bytes, canonical/case-fold collisions, symbolic
+links, unsupported special entries, and destination breakout.
+
+Entry-count, per-entry expanded-size, total expanded-size, compression-ratio,
+and actual streamed-byte bounds are enforced. Containment and destination
+symlink state are revalidated at publication time. Adapter-backed archives and
+destinations use Pathwise-owned local staging only where necessary and clean it
+deterministically.
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:
+events may 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.
+* ``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
@@ -118,20 +143,24 @@ 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).
+``PHP`` never starts native tools. ``AUTO`` may use a supported native
+capability and otherwise falls back to PHP. ``NATIVE`` requires the capability
+and fails explicitly with ``NativeExecutionException`` when it cannot be used.
+
+Native execution is direct-local only and is bounded by
+``NativeExecutionLimits``: finite timeout, stdout/stderr caps, termination grace,
+and polling interval. Pathwise invokes argument vectors rather than accepting
+caller shell fragments. See :doc:`native-execution`.
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.
+Streams are used for cross-filesystem copy, upload/download transfer, checksums,
+and archive publication/extraction. Directory listings remain lazy except where
+ordering or orphan deletion requires materialization. Transaction rollback state
+consumes temporary disk proportional to the affected local files. Remote
+emulated append consumes bandwidth and memory proportional to the full object,
+so partitioned writes are preferred for logs and event workloads.
+
+See :doc:`performance-portability` for the release workload and scaling
+recommendations.
diff --git a/docs/symlink-management.rst b/docs/symlink-management.rst
new file mode 100644
index 00000000..1fe885c7
--- /dev/null
+++ b/docs/symlink-management.rst
@@ -0,0 +1,97 @@
+Safe Symlink Management
+=======================
+
+Namespace: ``Infocyph\Pathwise\FileManager``
+
+``SafeSymlinkManager`` owns reusable local-filesystem mechanics for creating,
+inspecting, and removing symbolic links inside explicit trust boundaries. It is
+intended for application/framework adapters that own configuration and policy,
+while Pathwise owns the filesystem safety rules.
+
+Boundary Model
+--------------
+
+Construct the manager with two existing local directories:
+
+* ``linkRoot`` — every managed symlink must remain below this directory.
+* ``targetRoot`` — every managed target must remain inside this directory.
+
+Relative link and target paths are resolved beneath their respective roots.
+Absolute local paths are accepted only when they remain within the configured
+boundary. Stream-wrapper/scheme paths are rejected because native symbolic links
+are local-filesystem operations.
+
+The manager rejects:
+
+* parent-directory traversal,
+* link paths outside ``linkRoot``,
+* target paths outside ``targetRoot``,
+* link parents that resolve through a symlink outside ``linkRoot``,
+* target ancestors that resolve through a symlink outside ``targetRoot``,
+* replacement of an existing regular file/directory,
+* replacement of a symlink that points somewhere else, and
+* removal of a symlink whose current target does not match the expected target.
+
+Creation is no-clobber. Pathwise creates the final symlink directly instead of
+creating a temporary link and renaming it into place. If another process creates
+the destination concurrently, Pathwise re-checks the resulting path and only
+treats the operation as idempotent when the new symlink already points to the
+same expected target.
+
+Creating Links
+--------------
+
+.. code-block:: php
+
+ use Infocyph\Pathwise\FileManager\SafeSymlinkManager;
+
+ $links = new SafeSymlinkManager(
+ linkRoot: '/srv/app/public',
+ targetRoot: '/srv/app/storage',
+ );
+
+ $created = $links->create(
+ link: 'assets',
+ target: 'public/assets',
+ createTargetDirectory: true,
+ );
+
+``create()`` returns ``true`` when it creates the link and ``false`` when an
+already-existing link matches the expected target. When
+``createTargetDirectory`` is enabled, a missing directory target is created
+beneath ``targetRoot`` only after its nearest existing ancestor has been
+resolved and verified inside the configured target boundary.
+
+Status and Removal
+------------------
+
+.. code-block:: php
+
+ $status = $links->status('assets', 'public/assets');
+
+ if ($status->matches) {
+ $links->remove('assets', 'public/assets');
+ }
+
+``status()`` returns ``SymlinkStatus`` with:
+
+* ``exists`` — a regular path or symbolic link occupies the link path,
+* ``linked`` — the path itself is a symbolic link,
+* ``matches`` — the symbolic link points to the expected target, and
+* ``broken`` — the path is a symbolic link whose target no longer resolves.
+
+A broken link created by this manager can still be identified and removed when
+its stored absolute target exactly matches the expected in-root target path.
+Pathwise does not use that fallback for arbitrary relative broken links.
+
+Framework Ownership
+-------------------
+
+Pathwise deliberately does not read application configuration or decide which
+links an application should expose. A framework/application layer should:
+
+#. resolve its configured public/storage roots,
+#. construct one ``SafeSymlinkManager`` for those roots,
+#. map each configured link/target pair into ``create()``, ``status()``, or
+ ``remove()`` calls, and
+#. translate Pathwise exceptions/results into its own CLI or management output.
diff --git a/docs/upload-processing.rst b/docs/upload-processing.rst
index bcd96e04..b3a7ebcc 100644
--- a/docs/upload-processing.rst
+++ b/docs/upload-processing.rst
@@ -3,134 +3,191 @@ Upload Processing
Namespace: ``Infocyph\Pathwise\StreamHandler``
-Where it fits:
+``UploadProcessor`` is the Pathwise 4 upload pipeline for validated HTTP
+uploads, trusted application ingestion, framework-neutral upload sources, and
+resumable chunks. It is mutable configuration state; create/configure it for the
+lifecycle of one application policy rather than sharing it across unrelated
+policies.
+
+Core Entry Points
+-----------------
+
+* ``processUpload(array $file)`` — PHP HTTP upload; preserves
+ ``is_uploaded_file()`` provenance.
+* ``ingestFile(array $file)`` — trusted application/CLI input using ``$_FILES``
+ shaped metadata.
+* ``ingestSource(UploadSource $source)`` — framework-neutral typed source.
+* ``processChunkUpload(...)`` — one ``$_FILES``-shaped chunk.
+* ``processChunkUploadSource(...)`` — one typed source chunk.
+* ``finalizeChunkUpload($uploadId)`` — explicitly validate and publish a
+ complete resumable upload.
+
+Typed UploadSource Ownership
+----------------------------
+
+``UploadSource`` lets frameworks cross into Pathwise without synthesizing an
+HTTP upload array themselves:
+
+* ``UploadSource::fromMover()`` receives a Pathwise-owned target path. Use it for
+ uploaded-file abstractions with ``moveTo()``-style APIs.
+* ``UploadSource::fromPath()`` copies a borrowed path. With ``owned: true``, the
+ source is consumed after successful staging.
+* ``UploadSource::fromStream()`` reads from the caller-owned stream's current
+ position and never closes that caller stream.
+
+Every source is materialized into a private local staging directory/file before
+validation. Pathwise secures the staged file, measures its actual size, and
+removes staging state in a ``finally`` path. Cleanup failure never replaces the
+primary validation/storage/scanner failure.
-* Use this module for HTTP uploads that need validation, deterministic naming,
- resumable chunk flow, and layered upload hardening.
+.. code-block:: php
+
+ use Infocyph\Pathwise\StreamHandler\UploadSource;
+
+ $source = UploadSource::fromMover(
+ mover: fn (string $target): void => $uploadedFile->moveTo($target),
+ clientFilename: $uploadedFile->getClientFilename() ?? 'upload.bin',
+ size: $uploadedFile->getSize(),
+ clientMediaType: $uploadedFile->getClientMediaType(),
+ error: $uploadedFile->getError(),
+ );
+
+ $finalPath = $uploader->ingestSource($source);
+
+A non-success upload error code is rejected before a mover is invoked.
+
+StorageContext Integration
+--------------------------
+
+Persistent runtimes should inject ``StorageContext`` directly. Do this before
+calling path-dependent configuration such as ``setDirectorySettings()``.
-``UploadProcessor`` supports:
+.. code-block:: php
-* HTTP upload handling through ``processUpload()`` (requires PHP's verified
- ``is_uploaded_file()`` provenance).
-* Explicit trusted CLI/application ingestion through ``ingestFile()``.
-* Validation profiles: ``image``, ``video``, ``document``.
-* MIME and size validation with optional image dimension validation.
-* Extension allowlist/blocklist policy.
-* Naming strategies (hash/timestamp).
-* Chunked/resumable uploads:
- * ``processChunkUpload()``
- * ``finalizeChunkUpload()``
-* Upload ID safety validation for chunk/session identifiers.
-* Strict content checks:
- * extension <> MIME agreement
- * lightweight file signature verification for common formats
-* Malware scanner callback hook (optional or required).
+ use Infocyph\Pathwise\Storage\StorageContext;
+ use Infocyph\Pathwise\StreamHandler\UploadProcessor;
-Storage notes:
+ $storage = new StorageContext([
+ 'objects' => ['filesystem' => $objectFilesystem],
+ ], 'objects');
-* Uses Flysystem operations for chunk manifests and destination writes.
-* Supports mounted/default filesystem routing through helper resolution.
-* For adapter setup (S3/SFTP/FTP/custom), see ``storage-adapters``.
+ $uploader = new UploadProcessor();
+ $uploader->setStorageContext($storage);
+ $uploader->setDirectorySettings(
+ uploadDir: 'objects://uploads',
+ useDateDirectories: true,
+ tempDir: sys_get_temp_dir(),
+ );
-Security Hardening Controls
----------------------------
+Relative and ``name://`` processor paths route through that context. Direct
+absolute filesystem paths remain local. No global mount is required or created.
+Use a direct-local temporary path for efficient source staging, chunk locking,
+and workflows that depend on OS-level atomicity.
-``UploadProcessor`` exposes explicit controls for upload policy:
+Validation Policy
+-----------------
-* ``setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = [])``
- to enforce extension allow/deny policies.
-* ``setChunkLimits(int $maxChunkCount = 0, int $maxChunkSize = 0)``
- to cap chunk count and per-chunk size.
-* ``setRequireMalwareScan(bool $required = true)``
- to reject uploads if scanner execution is required but unavailable.
-* ``setStrictContentTypeValidation(bool $enabled = true)``
- to enforce extension-to-MIME agreement and signature checks.
+Important controls include:
-Chunk upload IDs are validated and must contain only:
+* ``setValidationProfile('image'|'video'|'document')``;
+* ``setValidationSettings(array $allowedFileTypes, int $maxFileSize)``;
+* ``setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = [])``;
+* ``setImageValidationSettings(int $maxImageWidth = 0, int $maxImageHeight = 0)``;
+* ``setStrictContentTypeValidation(bool $enabled = true)``;
+* ``setNamingStrategy('hash'|'timestamp')``;
+* ``setChunkLimits(int $maxChunkCount = 0, int $maxChunkSize = 0)``.
-* letters/numbers
-* ``-`` and ``_``
+The built-in blocked extension set includes executable/server-side script types.
+Strict content validation checks extension/MIME agreement plus lightweight magic
+signatures for supported formats. Image profiles can also enforce dimensions.
+The authoritative size is measured from the staged/current payload rather than
+trusting caller metadata.
-Identifiers with separators such as ``/`` or traversal patterns are rejected.
+Malware Scanner Contract
+------------------------
-Examples
---------
+Pathwise 4 uses ``MalwareScannerInterface`` and an explicit ``MalwareScanMode``:
-Basic single upload:
+* ``OFF`` — do not scan;
+* ``WHEN_CONFIGURED`` — default; scan only when a scanner exists;
+* ``REQUIRED`` — fail closed unless a scanner is configured and returns
+ ``MalwareScanVerdict::CLEAN``.
.. code-block:: php
- use Infocyph\Pathwise\StreamHandler\UploadProcessor;
+ use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
+ use Infocyph\Pathwise\StreamHandler\Scanner\ClamAvDaemonScanner;
- $uploader = new UploadProcessor();
- $uploader->setDirectorySettings('/tmp/uploads');
- $uploader->setValidationProfile('document');
- $uploader->setExtensionPolicy(['pdf', 'doc', 'docx'], ['php', 'phtml', 'phar']);
- $uploader->setStrictContentTypeValidation(true);
+ $uploader->setMalwareScanner(new ClamAvDaemonScanner(
+ endpoint: 'unix:///run/clamav/clamd.ctl',
+ ));
+ $uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
- $finalPath = $uploader->processUpload($_FILES['file']);
+Pathwise creates a private local scan copy and scans before MIME/signature/image
+parsing. It accepts only an explicit clean verdict, checks that the scanner did
+not mutate the scan input, rechecks source size around scanning, removes the
+scan copy on every path, and maps scanner failures to stable ``UploadException``
+messages while retaining the original exception as ``previous``.
-Resumable chunk flow:
+See :doc:`malware-scanning` for daemon limits/provider/status details.
+
+Resumable Uploads
+-----------------
.. code-block:: php
- $state = $uploader->processChunkUpload(
- chunkFile: $_FILES['chunk'],
- uploadId: 'session-42',
+ $state = $uploader->processChunkUploadSource(
+ source: $chunkSource,
+ uploadId: 'session_42',
chunkIndex: 0,
totalChunks: 4,
originalFilename: 'video.mp4',
);
if ($state->complete) {
- $finalPath = $uploader->finalizeChunkUpload('session-42');
+ $finalPath = $uploader->finalizeChunkUpload('session_42');
}
-``processChunkUpload()`` stores one chunk and returns ``ChunkUploadState``; it
-never publishes the final file implicitly. Call ``finalizeChunkUpload()`` only
-after ``$state->complete`` is true. Hash naming is calculated from the fully
-assembled object, so identical uploads reuse the same deterministic target.
-
-Trusted non-HTTP ingestion:
-
-.. code-block:: php
+Upload IDs allow only letters, numbers, ``-`` and ``_`` and are length bounded.
+Chunk metadata is persisted in a manifest and must remain consistent across the
+session. ``ChunkUploadState`` exposes ``uploadId``, ``receivedChunks``,
+``totalChunks`` and ``complete``.
- $finalPath = $uploader->ingestFile([
- 'error' => UPLOAD_ERR_OK,
- 'size' => filesize('/srv/import/report.pdf'),
- 'tmp_name' => '/srv/import/report.pdf',
- 'name' => 'report.pdf',
- ]);
+Finalization is explicit. Pathwise assembles all chunks into a staging object,
+validates the assembled payload, publishes it according to the naming policy,
+and cleans session artifacts only after successful publication. Direct-local
+chunk storage uses a session lock; non-local adapters cannot provide the same OS
+locking semantics, so local chunk staging is the recommended production model.
-Hardened chunk upload:
+Hash Naming and Collision Safety
+--------------------------------
-.. code-block:: php
+Hash naming is based on the actual payload content. If the deterministic target
+already exists, Pathwise compares source/destination checksums and reuses it
+only when the content matches. Different content at the same deterministic name
+is rejected rather than overwritten.
- $uploader->setChunkLimits(maxChunkCount: 20, maxChunkSize: 2 * 1024 * 1024); // 2MB
- $uploader->setRequireMalwareScan(true);
- $uploader->setMalwareScanner(
- fn (string $path, string $type): bool => true // return false to block
- );
+Logging
+-------
- $uploader->processChunkUpload(
- chunkFile: $_FILES['chunk'],
- uploadId: 'session_42',
- chunkIndex: 0,
- totalChunks: 4,
- originalFilename: 'video.mp4',
- );
+``setLogger(LoggerInterface $logger)`` accepts any PSR-3 logger. Upload success
+and failure logs can include caller-supplied scalar metadata. Avoid placing
+secrets or raw untrusted payload contents in audit metadata.
-Mounted destination example:
+Example Hardened Policy
+-----------------------
.. code-block:: php
- use Infocyph\Pathwise\Storage\StorageFactory;
- use Infocyph\Pathwise\StreamHandler\UploadProcessor;
-
- StorageFactory::mount('s3', ['adapter' => $myS3Adapter]);
-
$uploader = new UploadProcessor();
- $uploader->setDirectorySettings('s3://uploads', false, 's3://tmp');
+ $uploader->setStorageContext($storage);
+ $uploader->setDirectorySettings('objects://uploads', tempDir: sys_get_temp_dir());
$uploader->setValidationProfile('document');
+ $uploader->setExtensionPolicy(['pdf', 'doc', 'docx']);
+ $uploader->setStrictContentTypeValidation(true);
+ $uploader->setChunkLimits(maxChunkCount: 100, maxChunkSize: 4 * 1024 * 1024);
+ $uploader->setMalwareScanner($scanner);
+ $uploader->setMalwareScanMode(MalwareScanMode::REQUIRED);
- $finalPath = $uploader->processUpload($_FILES['file']);
+See :doc:`storage-context`, :doc:`security`, and
+:doc:`performance-portability` for runtime, trust-boundary, and memory guidance.
diff --git a/docs/utilities.rst b/docs/utilities.rst
index 8bbd0cef..eaf42b8f 100644
--- a/docs/utilities.rst
+++ b/docs/utilities.rst
@@ -3,25 +3,33 @@ Utilities
Namespace: ``Infocyph\Pathwise\Utils``
-Path and metadata helpers:
+Pathwise utilities are shared low-level building blocks. They do not replace
+``StorageContext`` as application-owned storage topology.
-* ``PathHelper``: normalize, join, relative/absolute conversion, scheme-aware paths.
-* ``MetadataHelper``: size, mime, checksum, timestamps, ownership, path type.
-* ``PermissionsHelper``: read/write/execute checks and permission formatting.
+Path and Storage Helpers
+------------------------
-Ownership resolution:
+``PathHelper``
+ Normalize/join/relative/absolute/temp and scheme-aware path operations.
-* Uses OS-specific adapters in ``Utils\\Ownership`` (POSIX, Windows, fallback).
-* Avoids shell-based ownership lookup.
+``FlysystemHelper``
+ Low-level direct-local/default/mount storage routing used by the standalone
+ storage-neutral APIs. Persistent applications should prefer
+ ``StorageContext`` for named filesystem ownership and isolation.
-File watch helper:
+``MetadataHelper``
+ Size, MIME, checksum, timestamp, ownership and path-type helpers subject to
+ the selected storage/platform capability.
-* ``FileWatcher`` provides snapshot/diff/watch flows for change tracking.
+``PermissionsHelper``
+ Direct-local permission checks/formatting and mutation where supported.
-Examples
---------
+Ownership resolution is delegated to OS-capability implementations under
+``Utils\Ownership`` (POSIX, Windows and fallback). Pathwise does not shell out
+merely to discover ownership metadata.
-Path and metadata:
+Path and Metadata Example
+-------------------------
.. code-block:: php
@@ -32,13 +40,41 @@ Path and metadata:
$mime = MetadataHelper::getMimeType($path);
$meta = MetadataHelper::getAllMetadata($path);
-Watcher snapshot + diff:
+File Watcher
+------------
+
+``FileWatcher`` provides deterministic snapshot/diff/polling workflows:
+
+* ``snapshot()`` returns a path-keyed ``mtime``/``size`` map sorted by path;
+* ``diff()`` returns typed ``SnapshotDiff`` with sorted created/modified/deleted
+ lists;
+* ``watch()`` invokes a callback for non-empty diffs and returns typed
+ ``WatchResult``;
+* watch duration must be at least one second;
+* polling interval must be at least 10 milliseconds;
+* snapshots of a directory necessarily retain one metadata entry per observed
+ file, so memory grows with the watched set.
.. code-block:: php
+ use Infocyph\Pathwise\Results\SnapshotDiff;
use Infocyph\Pathwise\Utils\FileWatcher;
$before = FileWatcher::snapshot('/tmp/reports');
- // perform file operations
+ // Perform file operations.
$after = FileWatcher::snapshot('/tmp/reports');
$changes = FileWatcher::diff($before, $after);
+
+ $result = FileWatcher::watch(
+ '/tmp/reports',
+ static function (SnapshotDiff $diff): void {
+ // Handle one deterministic change set.
+ },
+ durationSeconds: 5,
+ intervalMilliseconds: 500,
+ );
+
+``FileWatcher`` is polling, not an OS event-stream abstraction. For very large
+namespaces or long-running distributed watching, use a platform/application
+service designed for that scale and treat Pathwise snapshots as bounded
+filesystem workflows. See :doc:`performance-portability`.
diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php
index 718470d8..258f2bb4 100644
--- a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php
+++ b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php
@@ -10,6 +10,8 @@
use Infocyph\Pathwise\Exceptions\NativeExecutionException;
use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException;
use Infocyph\Pathwise\Native\NativeOperationsAdapter;
+use Infocyph\Pathwise\Security\ZipArchiveExtractor;
+use Infocyph\Pathwise\Security\ZipArchiveManifestEntry;
use Infocyph\Pathwise\Security\ZipEntryValidator;
use Infocyph\Pathwise\Utils\FlysystemHelper;
use Infocyph\Pathwise\Utils\PathHelper;
@@ -18,14 +20,9 @@
use SplFileInfo;
use ZipArchive;
+/** @phpstan-type RemoteZipEntry array{source: string, target: string, directory: bool} */
trait DirectoryOperationsZipConcern
{
- /**
- * Deletes all files and directories in the given local directory.
- *
- * @param string $directory The directory to delete contents of.
- * @return bool True if the directory contents were successfully deleted, false otherwise.
- */
protected function deleteDirectoryContents(string $directory): bool
{
if (!is_dir($directory)) {
@@ -105,6 +102,7 @@ private function addFlysystemContentsToZip(ZipArchive $zip): string
if (!is_dir($parent) && !mkdir($parent, 0700, true) && !is_dir($parent)) {
throw new DirectoryOperationException("Unable to create ZIP staging directory: {$parent}");
}
+
FlysystemHelper::copy($this->buildPath($this->path, $relative), $stagedPath);
$this->assertDirectoryZipMutation(
$zip->addFile($stagedPath, $zipPathName),
@@ -131,6 +129,11 @@ private function addLocalContentsToZip(ZipArchive $zip, string $zipPath): void
if (!$file instanceof SplFileInfo) {
continue;
}
+ if ($file->isLink()) {
+ throw new DirectoryOperationException(
+ "Symbolic links are not followed during ZIP creation: {$file->getPathname()}",
+ );
+ }
$currentPath = PathHelper::normalize($file->getPathname());
if ($currentPath === $normalizedZipPath) {
@@ -158,43 +161,59 @@ private function assertDirectoryZipMutation(bool $succeeded, string $operation):
}
}
- private function ensureZipEntryDirectory(string $entry): void
+ private function assertRemoteZipTarget(string $target, bool $directory): void
{
- $relativeDir = pathinfo($entry, PATHINFO_DIRNAME);
- if ($relativeDir === '' || $relativeDir === '.') {
+ if ($directory) {
+ if (FlysystemHelper::fileExists($target)) {
+ throw new DirectoryOperationException("ZIP directory conflicts with an existing file: {$target}");
+ }
+
return;
}
- $targetDir = $this->buildPath($this->path, str_replace('\\', '/', $relativeDir));
- if (!FlysystemHelper::directoryExists($targetDir)) {
- FlysystemHelper::createDirectory($targetDir);
+ if (FlysystemHelper::fileExists($target) || FlysystemHelper::directoryExists($target)) {
+ throw new DirectoryOperationException("Remote ZIP extraction refuses to overwrite an existing path: {$target}");
}
}
- private function extractSingleZipEntry(ZipArchive $zip, int $index, string $entry): void
+ /** @return list */
+ private function collectRemoteZipEntries(string $stage): array
{
- if ($entry === '') {
- return;
- }
+ $entries = [];
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($stage, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::SELF_FIRST,
+ );
- if (str_ends_with($entry, '/')) {
- FlysystemHelper::createDirectory($this->buildPath($this->path, rtrim($entry, '/')));
+ foreach ($iterator as $item) {
+ if (!$item instanceof SplFileInfo) {
+ continue;
+ }
- return;
- }
+ $relative = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen(rtrim($stage, '/\\')))), '/');
+ if ($relative === '') {
+ continue;
+ }
- $this->ensureZipEntryDirectory($entry);
- $contents = $zip->getFromIndex($index);
- if (!is_string($contents)) {
- throw new DirectoryOperationException("Unable to extract ZIP entry: {$entry}");
+ $target = $this->buildPath($this->path, $relative);
+ $directory = $item->isDir();
+ $this->assertRemoteZipTarget($target, $directory);
+ $entries[] = [
+ 'source' => $item->getPathname(),
+ 'target' => $target,
+ 'directory' => $directory,
+ ];
}
- FlysystemHelper::write($this->buildPath($this->path, $entry), $contents);
+ return $entries;
}
- /**
- * @param array $validatedEntries
- */
+ private function copyZipStageToStorage(string $stage): void
+ {
+ $this->publishRemoteZipEntries($this->collectRemoteZipEntries($stage));
+ }
+
+ /** @param array $validatedEntries */
private function extractZipContents(string $localSource, string $source, array $validatedEntries): void
{
$zip = new ZipArchive();
@@ -202,12 +221,27 @@ private function extractZipContents(string $localSource, string $source, array $
throw new DirectoryOperationException("Unable to open ZIP source: {$source}");
}
+ $stage = null;
+
try {
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $this->extractSingleZipEntry($zip, $i, $validatedEntries[$i] ?? '');
+ if ($this->isLocalPath($this->path)) {
+ ZipArchiveExtractor::extractToLocal($zip, $validatedEntries, $this->path);
+
+ return;
+ }
+
+ $stage = PathHelper::createTempDirectory('pathwise_unzip_stage_');
+ if (!is_string($stage)) {
+ throw new DirectoryOperationException('Unable to create secure ZIP extraction staging directory.');
}
+
+ ZipArchiveExtractor::extractToLocal($zip, $validatedEntries, $stage);
+ $this->copyZipStageToStorage($stage);
} finally {
$zip->close();
+ if (is_string($stage)) {
+ PathHelper::deleteDirectory($stage);
+ }
}
}
@@ -246,9 +280,7 @@ private function persistZipToDestination(string $zipPath, string $destination):
}
}
- /**
- * @return array{string, bool}
- */
+ /** @return array{string, bool} */
private function prepareLocalZipSource(string $source): array
{
if ($this->isLocalPath($source) && is_file($source)) {
@@ -292,35 +324,75 @@ private function prepareZipPath(string $destination, bool $useLocalDestination):
return $destination;
}
- private function tryNativeUnzip(string $localSource, string $source): bool
+ /** @param list $entries */
+ private function publishRemoteZipEntries(array $entries): void
{
- if ($this->executionStrategy === ExecutionStrategy::NATIVE) {
- if (!$this->isLocalPath($source) || !$this->isLocalPath($this->path)) {
- throw new UnsupportedStorageOperationException('Native unzip requires local source and destination paths.');
+ $createdFiles = [];
+ $createdDirectories = [];
+
+ try {
+ foreach ($entries as $entry) {
+ $this->publishRemoteZipEntry($entry, $createdFiles, $createdDirectories);
}
- if (!NativeOperationsAdapter::canUseNativeZipDecompression()) {
- throw new NativeExecutionException('Native ZIP decompression executables are unavailable.');
+ } catch (\Throwable $exception) {
+ $this->rollbackRemoteZipEntries($createdFiles, $createdDirectories);
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * @param RemoteZipEntry $entry
+ * @param list $createdFiles
+ * @param list $createdDirectories
+ */
+ private function publishRemoteZipEntry(array $entry, array &$createdFiles, array &$createdDirectories): void
+ {
+ if ($entry['directory']) {
+ if (!FlysystemHelper::directoryExists($entry['target'])) {
+ FlysystemHelper::createDirectory($entry['target']);
+ $createdDirectories[] = $entry['target'];
}
+
+ return;
}
- if (
- $this->executionStrategy === ExecutionStrategy::PHP
- || !NativeOperationsAdapter::canUseNativeZipDecompression()
- || !$this->isLocalPath($source)
- || !$this->isLocalPath($this->path)
- ) {
- return false;
+ $stream = fopen($entry['source'], 'rb');
+ if (!is_resource($stream)) {
+ throw new DirectoryOperationException("Unable to read staged ZIP entry: {$entry['source']}");
}
- $native = NativeOperationsAdapter::decompressZip($localSource, $this->path);
- if ($native->success) {
- return true;
+ try {
+ FlysystemHelper::writeStream($entry['target'], $stream);
+ $createdFiles[] = $entry['target'];
+ } finally {
+ fclose($stream);
}
+ }
+ /**
+ * @param list $createdFiles
+ * @param list $createdDirectories
+ */
+ private function rollbackRemoteZipEntries(array $createdFiles, array $createdDirectories): void
+ {
+ for ($index = count($createdFiles) - 1; $index >= 0; $index--) {
+ if (FlysystemHelper::fileExists($createdFiles[$index])) {
+ FlysystemHelper::delete($createdFiles[$index]);
+ }
+ }
+ for ($index = count($createdDirectories) - 1; $index >= 0; $index--) {
+ if (FlysystemHelper::directoryExists($createdDirectories[$index])) {
+ FlysystemHelper::deleteDirectory($createdDirectories[$index]);
+ }
+ }
+ }
+
+ private function tryNativeUnzip(string $localSource, string $source): bool
+ {
if ($this->executionStrategy === ExecutionStrategy::NATIVE) {
throw new NativeExecutionException(
- "Native unzip failed with exit code {$native->exitCode}: " . implode("\n", $native->output),
- $native,
+ "Native unzip of {$source} from {$localSource} is unavailable because hardened extraction requires Pathwise byte and rollback enforcement.",
);
}
@@ -367,9 +439,7 @@ private function tryNativeZip(string $destination, bool $useLocalDestination): b
return false;
}
- /**
- * @return array
- */
+ /** @return array */
private function validateZipEntries(string $localSource, string $source): array
{
$zip = new ZipArchive();
diff --git a/src/Exceptions/MalwareScannerException.php b/src/Exceptions/MalwareScannerException.php
new file mode 100644
index 00000000..cf526b00
--- /dev/null
+++ b/src/Exceptions/MalwareScannerException.php
@@ -0,0 +1,7 @@
+addDirectoryEntriesToZip($path, $zip, $baseDir);
@@ -92,47 +85,37 @@ private function addFilesToZip(string $path, ZipArchive $zip, ?string $baseDir =
$this->addSinglePathToZip($path, $zip, $baseDir);
}
- /**
- * Recursively add files to the ZIP archive, filtering by extensions.
- *
- * This method traverses the specified directory and adds files to the
- * ZIP archive based on the provided file extensions. Directories are
- * added as empty directories if no matching files are found within them.
- * If a password is set, files are encrypted using the specified algorithm.
- *
- * @param string $path The path to the directory or file to add.
- * @param ZipArchive $zip The ZIP archive instance to add files to.
- * @param string|null $relativePath The relative path within the ZIP archive.
- * @param list $extensions An array of file extensions to filter by.
- */
+ /** @param list $extensions */
private function addFilesToZipWithFilter(string $path, ZipArchive $zip, ?string $relativePath, array $extensions): void
{
$relativePath ??= basename($path);
$relativePath = $this->normalizeZipPath($relativePath);
+ if (is_link($path)) {
+ throw new CompressionException("Symbolic links are not followed during ZIP creation: {$path}");
+ }
if (is_dir($path)) {
- if ($relativePath !== '' && !$this->shouldTraverseDirectory($relativePath)) {
- return;
- }
- $this->assertZipMutation($zip->addEmptyDir($relativePath), "add ZIP directory: {$relativePath}");
- $entries = scandir($path);
- if ($entries === false) {
- throw new CompressionException("Failed to read directory: {$path}");
- }
+ $this->addFilteredDirectoryToZip($path, $zip, $relativePath, $extensions);
- foreach ($entries as $file) {
- if ($file !== '.' && $file !== '..') {
- $this->addFilesToZipWithFilter($path . DIRECTORY_SEPARATOR . $file, $zip, "$relativePath/$file", $extensions);
- }
- }
- } elseif ((empty($extensions) || in_array(pathinfo($path, PATHINFO_EXTENSION), $extensions)) && $this->shouldIncludePath($relativePath)) {
- $this->addArchiveEntry($zip, $path, $relativePath);
- $this->advanceProgress('compress', $relativePath);
+ return;
+ }
+ if ($extensions !== [] && !in_array(pathinfo($path, PATHINFO_EXTENSION), $extensions, true)) {
+ return;
}
+ if (!$this->shouldIncludePath($relativePath)) {
+ return;
+ }
+
+ $this->addArchiveEntry($zip, $path, $relativePath);
+ $this->advanceProgress('compress', $relativePath);
}
private function addFileToArchive(string $filePath, string $zipPath): void
{
+ if (!PathHelper::hasScheme($filePath) && is_link($filePath)) {
+ throw new CompressionException("Symbolic links are not followed during ZIP creation: {$filePath}");
+ }
+
$this->triggerHook('beforeAdd', $filePath, $zipPath);
if ($this->password !== null) {
$this->assertZipMutation($this->zip->setPassword($this->password), 'set the ZIP password');
@@ -146,7 +129,7 @@ private function addFileToArchive(string $filePath, string $zipPath): void
$added = $this->zip->addFile($localFilePath, $zipPath);
if (!$added) {
- throw new CompressionException("Failed to add file to ZIP: $filePath");
+ throw new CompressionException("Failed to add file to ZIP: {$filePath}");
}
if ($this->password !== null) {
@@ -158,6 +141,37 @@ private function addFileToArchive(string $filePath, string $zipPath): void
$this->triggerHook('afterAdd', $filePath, $zipPath);
}
+ /** @param list $extensions */
+ private function addFilteredDirectoryToZip(
+ string $path,
+ ZipArchive $zip,
+ string $relativePath,
+ array $extensions,
+ ): void {
+ if ($relativePath !== '' && !$this->shouldTraverseDirectory($relativePath)) {
+ return;
+ }
+
+ $this->assertZipMutation($zip->addEmptyDir($relativePath), "add ZIP directory: {$relativePath}");
+ $entries = scandir($path);
+ if ($entries === false) {
+ throw new CompressionException("Failed to read directory: {$path}");
+ }
+
+ foreach ($entries as $file) {
+ if ($file === '.' || $file === '..') {
+ continue;
+ }
+
+ $this->addFilesToZipWithFilter(
+ $path . DIRECTORY_SEPARATOR . $file,
+ $zip,
+ "{$relativePath}/{$file}",
+ $extensions,
+ );
+ }
+ }
+
private function addSinglePathToZip(string $path, ZipArchive $zip, string $baseDir): void
{
$relativePath = $this->getRelativePath($path, $baseDir);
@@ -195,6 +209,21 @@ private function applyArchivePassword(): void
}
}
+ private function assertRemoteExtractionTarget(string $target, bool $directory): void
+ {
+ if ($directory) {
+ if (FlysystemHelper::fileExists($target)) {
+ throw new CompressionException("Remote ZIP directory conflicts with an existing file: {$target}");
+ }
+
+ return;
+ }
+
+ if (FlysystemHelper::fileExists($target) || FlysystemHelper::directoryExists($target)) {
+ throw new CompressionException("Remote ZIP extraction refuses to overwrite an existing path: {$target}");
+ }
+ }
+
private function assertZipMutation(bool $succeeded, string $operation): void
{
if (!$succeeded) {
@@ -205,70 +234,20 @@ private function assertZipMutation(bool $succeeded, string $operation): void
private function attemptNativeDecompression(string $destination, bool $isRemoteDestination): bool
{
if ($this->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::canUseNativeZipDecompression()) {
- throw new NativeExecutionException('Native ZIP decompression executables are unavailable.');
- }
- }
-
- if (
- $this->executionStrategy === ExecutionStrategy::PHP
- || $this->password !== null
- || $isRemoteDestination
- || !FlysystemHelper::isLocalPath($this->zipFilePath)
- || !FlysystemHelper::isLocalPath($destination)
- || !NativeOperationsAdapter::canUseNativeZipDecompression()
- ) {
- return false;
- }
+ $destinationType = $isRemoteDestination ? 'remote' : 'local';
- $this->closeZip();
- $native = NativeOperationsAdapter::decompressZip($this->workingZipPath, $destination);
- if ($native->success) {
- if (is_callable($this->progressCallback)) {
- ($this->progressCallback)([
- 'operation' => 'decompress',
- 'path' => $this->zipFilePath,
- 'current' => 1,
- 'total' => 1,
- ]);
- }
- $this->openZip();
-
- return true;
- }
-
- if ($this->executionStrategy === ExecutionStrategy::NATIVE) {
throw new NativeExecutionException(
- "Native decompression failed with exit code {$native->exitCode}: " . implode("\n", $native->output),
- $native,
+ "Native {$destinationType} decompression to {$destination} is unavailable because hardened extraction requires Pathwise byte and rollback enforcement.",
);
}
- $this->openZip();
-
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
+ /** @return list */
+ private function collectRemoteExtractionEntries(string $localSource, string $destination): array
{
+ $entries = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($localSource, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST,
@@ -284,30 +263,25 @@ private function copyLocalDirectoryToFlysystem(string $localSource, string $dest
continue;
}
- $targetPath = PathHelper::join($destination, $relative);
-
- if ($item->isDir()) {
- FlysystemHelper::createDirectory($targetPath);
-
- continue;
- }
+ $target = PathHelper::join($destination, $relative);
+ $directory = $item->isDir();
+ $this->assertRemoteExtractionTarget($target, $directory);
+ $entries[] = [
+ 'source' => $item->getPathname(),
+ 'target' => $target,
+ 'directory' => $directory,
+ ];
+ }
- $stream = fopen($item->getPathname(), 'rb');
- if (!is_resource($stream)) {
- throw new CompressionException("Unable to read extracted file: {$item->getPathname()}");
- }
+ return $entries;
+ }
- try {
- FlysystemHelper::writeStream($targetPath, $stream);
- } finally {
- fclose($stream);
- }
- }
+ private function copyLocalDirectoryToFlysystem(string $localSource, string $destination): void
+ {
+ $this->publishRemoteExtractionEntries($this->collectRemoteExtractionEntries($localSource, $destination));
}
- /**
- * @param list $extensions
- */
+ /** @param list $extensions */
private function countFilesForCompression(string $source, array $extensions = []): int
{
if (is_file($source)) {
@@ -330,11 +304,7 @@ private function countFilesForCompression(string $source, array $extensions = []
);
foreach ($iterator as $item) {
- if (!$item instanceof \SplFileInfo) {
- continue;
- }
-
- if ($item->isDir()) {
+ if (!$item instanceof \SplFileInfo || $item->isDir()) {
continue;
}
@@ -377,60 +347,20 @@ 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}");
- }
- }
-
- /** @param array $entries */
+ /** @param array $entries */
private function extractArchive(
array $entries,
string $extractDestination,
string $destination,
bool $isRemoteDestination,
): void {
- foreach ($entries as $index => $entry) {
- $this->extractArchiveEntry($index, $entry, $extractDestination);
- }
+ ZipArchiveExtractor::extractToLocal($this->zip, $entries, $extractDestination);
if ($isRemoteDestination) {
$this->copyLocalDirectoryToFlysystem($extractDestination, $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.
- */
private function getRelativePath(string $path, string $baseDir): string
{
$normalizedPath = str_replace('\\', '/', PathHelper::normalize($path));
@@ -447,9 +377,7 @@ private function getRelativePath(string $path, string $baseDir): string
return ltrim($normalizedPath, '/');
}
- /**
- * @param list $extensions
- */
+ /** @param list $extensions */
private function initializeProgress(string $source, array $extensions = []): void
{
$this->progressCurrent = 0;
@@ -466,9 +394,7 @@ private function isRemotePath(string $path): bool
return PathHelper::hasScheme($path) || (FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($path));
}
- /**
- * @param list $extensions
- */
+ /** @param list $extensions */
private function matchesExtensions(string $path, array $extensions): bool
{
if ($extensions === []) {
@@ -490,9 +416,7 @@ private function normalizeZipPath(string $path): string
return $normalized;
}
- /**
- * @return ExtractionDestination
- */
+ /** @return ExtractionDestination */
private function prepareExtractionDestination(string $destination): array
{
$isRemoteDestination = $this->isRemotePath($destination);
@@ -506,10 +430,6 @@ private function prepareExtractionDestination(string $destination): array
];
}
- if (!FlysystemHelper::directoryExists($destination)) {
- FlysystemHelper::createDirectory($destination);
- }
-
return [
'extractDestination' => $destination,
'extractTempDir' => null,
@@ -517,6 +437,55 @@ private function prepareExtractionDestination(string $destination): array
];
}
+ /** @param list $entries */
+ private function publishRemoteExtractionEntries(array $entries): void
+ {
+ $createdFiles = [];
+ $createdDirectories = [];
+
+ try {
+ foreach ($entries as $entry) {
+ $this->publishRemoteExtractionEntry($entry, $createdFiles, $createdDirectories);
+ }
+ } catch (\Throwable $exception) {
+ $this->rollbackRemoteExtraction($createdFiles, $createdDirectories);
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * @param RemoteExtractionEntry $entry
+ * @param list $createdFiles
+ * @param list $createdDirectories
+ */
+ private function publishRemoteExtractionEntry(
+ array $entry,
+ array &$createdFiles,
+ array &$createdDirectories,
+ ): void {
+ if ($entry['directory']) {
+ if (!FlysystemHelper::directoryExists($entry['target'])) {
+ FlysystemHelper::createDirectory($entry['target']);
+ $createdDirectories[] = $entry['target'];
+ }
+
+ return;
+ }
+
+ $stream = fopen($entry['source'], 'rb');
+ if (!is_resource($stream)) {
+ throw new CompressionException("Unable to read extracted file: {$entry['source']}");
+ }
+
+ try {
+ FlysystemHelper::writeStream($entry['target'], $stream);
+ $createdFiles[] = $entry['target'];
+ } finally {
+ fclose($stream);
+ }
+ }
+
private function resolveDecompressionDestination(?string $destination): string
{
$destination ??= $this->defaultDecompressionPath;
@@ -527,13 +496,30 @@ private function resolveDecompressionDestination(?string $destination): string
return PathHelper::normalize($destination);
}
+ /**
+ * @param list $createdFiles
+ * @param list $createdDirectories
+ */
+ private function rollbackRemoteExtraction(array $createdFiles, array $createdDirectories): void
+ {
+ for ($index = count($createdFiles) - 1; $index >= 0; $index--) {
+ if (FlysystemHelper::fileExists($createdFiles[$index])) {
+ FlysystemHelper::delete($createdFiles[$index]);
+ }
+ }
+ for ($index = count($createdDirectories) - 1; $index >= 0; $index--) {
+ if (FlysystemHelper::directoryExists($createdDirectories[$index])) {
+ FlysystemHelper::deleteDirectory($createdDirectories[$index]);
+ }
+ }
+ }
+
private function shouldAttemptNativeCompression(): bool
{
if ($this->executionStrategy === ExecutionStrategy::PHP) {
return false;
}
- // Native path currently targets whole-source archive operations only.
return $this->password === null
&& $this->includePatterns === []
&& $this->excludePatterns === []
@@ -541,7 +527,7 @@ private function shouldAttemptNativeCompression(): bool
&& $this->hooks === [];
}
- /** @return array */
+ /** @return array */
private function validateArchiveForExtraction(string $destination): array
{
return ZipEntryValidator::validateArchive(
diff --git a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php
index a6b81043..20271ce7 100644
--- a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php
+++ b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php
@@ -169,9 +169,7 @@ private function requireXmlParam(array $params, int $index, string $type): Simpl
private function trackWriteType(string $type): void
{
$type = strtolower($type);
- if (!isset($this->writeTypesCount[$type])) {
- $this->writeTypesCount[$type] = 0;
- }
+ $this->writeTypesCount[$type] ??= 0;
$this->writeTypesCount[$type]++;
}
diff --git a/src/FileManager/FileCompression.php b/src/FileManager/FileCompression.php
index 35f7c6d2..3fb870b4 100644
--- a/src/FileManager/FileCompression.php
+++ b/src/FileManager/FileCompression.php
@@ -182,51 +182,59 @@ public function batchAddFiles(array $files): self
*
* @param array $files An associative array mapping ZIP paths to local paths.
* @param string $destination The destination directory to extract to.
- *
- *
* @throws CompressionException If any of the files fail to extract.
*/
public function batchExtractFiles(array $files, string $destination): self
{
$this->reopenIfNeeded();
$destination = PathHelper::normalize($destination);
- if (!FlysystemHelper::directoryExists($destination)) {
- FlysystemHelper::createDirectory($destination);
- }
$this->log('Batch extracting files.');
$this->progressCurrent = 0;
$this->progressTotal = count($files);
- ZipEntryValidator::validateArchive($this->zip, $destination);
+
+ $manifest = ZipEntryValidator::validateArchive(
+ $this->zip,
+ $destination,
+ $this->maxEntries,
+ $this->maxEntryUncompressedBytes,
+ $this->maxTotalUncompressedBytes,
+ $this->maxCompressionRatio,
+ );
+ $manifestByPath = [];
+ foreach ($manifest as $entry) {
+ $manifestByPath[$entry->path] = $entry;
+ }
+
+ $selected = [];
+ $selectedTargets = [];
foreach ($files as $zipPath => $localPath) {
$zipPath = ZipEntryValidator::validate($zipPath, $destination);
$localPath = ZipEntryValidator::validate($localPath, $destination);
- $targetPath = PathHelper::join($destination, $localPath);
-
- if (str_ends_with($zipPath, '/')) {
- if (!FlysystemHelper::directoryExists($targetPath)) {
- FlysystemHelper::createDirectory($targetPath);
- }
-
- continue;
+ $entry = $manifestByPath[$zipPath] ?? null;
+ if ($entry === null) {
+ throw new CompressionException("File not found in ZIP archive: {$zipPath}.");
}
- $stream = $this->zip->getStream($zipPath);
- if (!is_resource($stream)) {
- throw new CompressionException("File not found in ZIP archive: $zipPath.");
+ $targetKey = strtolower(rtrim(str_replace('\\', '/', $localPath), '/'));
+ if (isset($selectedTargets[$targetKey])) {
+ throw new CompressionException("Multiple ZIP entries target the same extraction path: {$localPath}");
}
+ $selectedTargets[$targetKey] = true;
+ $selected[$entry->index] = $entry->withPath($localPath);
+ }
- $targetDir = dirname($targetPath);
- if (!FlysystemHelper::directoryExists($targetDir)) {
- FlysystemHelper::createDirectory($targetDir);
- }
+ ['extractDestination' => $extractDestination, 'extractTempDir' => $extractTempDir, 'isRemote' => $isRemoteDestination] = $this->prepareExtractionDestination($destination);
+ $this->applyArchivePassword();
- try {
- FlysystemHelper::writeStream($targetPath, $stream);
- } finally {
- fclose($stream);
+ try {
+ $this->extractArchive($selected, $extractDestination, $destination, $isRemoteDestination);
+ foreach ($selected as $entry) {
+ $this->advanceProgress('decompress', $entry->archiveName);
+ }
+ } finally {
+ if ($extractTempDir !== null) {
+ $this->cleanupLocalizedPath($extractTempDir);
}
-
- $this->advanceProgress('decompress', $zipPath);
}
return $this;
diff --git a/src/FileManager/FileTransactionJournal.php b/src/FileManager/FileTransactionJournal.php
index d904e955..ad131ca9 100644
--- a/src/FileManager/FileTransactionJournal.php
+++ b/src/FileManager/FileTransactionJournal.php
@@ -6,6 +6,7 @@
use Infocyph\Pathwise\Exceptions\FileAccessException;
use Infocyph\Pathwise\Exceptions\TransactionRollbackException;
+use Infocyph\Pathwise\Utils\PathHelper;
final class FileTransactionJournal
{
@@ -26,7 +27,7 @@ public function commit(): void
public function record(string $path): void
{
- $path = \Infocyph\Pathwise\Utils\PathHelper::normalize($path);
+ $path = PathHelper::normalize($path);
if (isset($this->recordedPaths[$path])) {
return;
}
@@ -37,14 +38,7 @@ public function record(string $path): void
$owner = null;
$group = null;
if ($existed) {
- $backup = tempnam(sys_get_temp_dir(), 'pathwise_tx_');
- if ($backup === false || !copy($path, $backup)) {
- if (is_string($backup) && is_file($backup)) {
- $this->unlinkSilently($backup);
- }
-
- throw new FileAccessException("Unable to create rollback backup for {$path}.");
- }
+ $backup = $this->createPrivateBackup($path);
$fileMode = fileperms($path);
$fileOwner = fileowner($path);
$fileGroup = filegroup($path);
@@ -95,9 +89,45 @@ private function cleanup(): void
}
}
- /**
- * @param array{path: string, existed: bool, backup: string|null, mode: int|null, owner: int|null, group: int|null} $entry
- */
+ private function createPrivateBackup(string $path): string
+ {
+ $backup = tempnam(sys_get_temp_dir(), 'pathwise_tx_');
+ if (!is_string($backup)) {
+ throw new FileAccessException("Unable to allocate rollback backup for {$path}.");
+ }
+
+ try {
+ if (!chmod($backup, 0600) || !copy($path, $backup)) {
+ throw new FileAccessException("Unable to create rollback backup for {$path}.");
+ }
+
+ $stream = fopen($backup, 'r+b');
+ if (!is_resource($stream)) {
+ throw new FileAccessException("Unable to verify rollback backup for {$path}.");
+ }
+
+ try {
+ if (!fflush($stream)) {
+ throw new FileAccessException("Unable to flush rollback backup for {$path}.");
+ }
+ if (function_exists('fsync') && !fsync($stream)) {
+ throw new FileAccessException("Unable to synchronize rollback backup for {$path}.");
+ }
+ } finally {
+ fclose($stream);
+ }
+
+ return $backup;
+ } catch (\Throwable $exception) {
+ if (is_file($backup)) {
+ $this->unlinkSilently($backup);
+ }
+
+ throw $exception;
+ }
+ }
+
+ /** @param array{path: string, existed: bool, backup: string|null, mode: int|null, owner: int|null, group: int|null} $entry */
private function restore(array $entry): void
{
if (!$entry['existed']) {
@@ -121,9 +151,7 @@ private function restore(array $entry): void
$this->restoreMetadata($entry);
}
- /**
- * @param array{path: string, existed: bool, backup: string|null, mode: int|null, owner: int|null, group: int|null} $entry
- */
+ /** @param array{path: string, existed: bool, backup: string|null, mode: int|null, owner: int|null, group: int|null} $entry */
private function restoreMetadata(array $entry): void
{
if (is_int($entry['mode']) && !chmod($entry['path'], $entry['mode'])) {
diff --git a/src/FileManager/SafeFileWriter.php b/src/FileManager/SafeFileWriter.php
index be0794ad..8f06e84c 100644
--- a/src/FileManager/SafeFileWriter.php
+++ b/src/FileManager/SafeFileWriter.php
@@ -119,17 +119,23 @@ public function count(): int
}
/**
- * Enable or disable atomic write mode.
+ * Enable or disable atomic local replacement mode.
*
- * @param bool $enabled If true, enable atomic writes.
- * @return self This instance for method chaining.
- * @throws FileAccessException If atomic mode is enabled in append mode.
+ * Atomic mode writes to a temporary sibling and publishes it with one local
+ * filesystem rename. It is deliberately unavailable for append mode and for
+ * adapter-backed paths because Pathwise cannot promise adapter-level atomic
+ * replacement semantics.
+ *
+ * @throws FileAccessException If atomic mode cannot be guaranteed.
*/
public function enableAtomicWrite(bool $enabled = true): self
{
if ($enabled && $this->append) {
throw new FileAccessException('Atomic write mode is not supported in append mode.');
}
+ if ($enabled && $this->isRemoteTarget()) {
+ throw new FileAccessException('Atomic write mode requires a direct-local filesystem path.');
+ }
$this->atomicWriteEnabled = $enabled;
@@ -405,10 +411,6 @@ public function writeXml(SimpleXMLElement $element): int
private function createAtomicTempFilePath(): string
{
- if ($this->isRemoteTarget()) {
- return $this->createLocalTempFile('pathwise_writer_atomic_');
- }
-
$directory = dirname($this->filename);
$prefix = basename($this->filename) . '.tmp_';
$tempFile = tempnam($directory, $prefix);
@@ -441,28 +443,8 @@ private function finalizeAtomicWrite(): void
return;
}
- if ($this->isRemoteTarget()) {
- if ($this->localWorkingPath === null) {
- $this->localWorkingPath = $this->createLocalTempFile('pathwise_writer_sync_');
- $this->cleanupLocalWorkingPath = true;
- }
- if (!$this->runSilently(fn(): bool => rename($this->atomicTempFilePath, $this->localWorkingPath))) {
- if (!$this->runSilently(fn(): bool => copy($this->atomicTempFilePath, $this->localWorkingPath))) {
- throw new FileAccessException("Failed to finalize atomic write for {$this->filename}");
- }
- $this->unlinkPathSilently($this->atomicTempFilePath);
- }
- $this->syncBackOnClose = true;
- $this->atomicTempFilePath = null;
-
- return;
- }
-
if (!$this->runSilently(fn(): bool => rename($this->atomicTempFilePath, $this->filename))) {
- if (!$this->runSilently(fn(): bool => copy($this->atomicTempFilePath, $this->filename))) {
- throw new FileAccessException("Failed to finalize atomic write for {$this->filename}");
- }
- $this->unlinkPathSilently($this->atomicTempFilePath);
+ throw new FileAccessException("Failed to atomically replace {$this->filename}");
}
$this->atomicTempFilePath = null;
@@ -492,7 +474,7 @@ private function initializeRemoteWorkingPath(): void
/**
* Initializes the internal state of the SafeFileWriter.
*
- * This function is called internally whenever a write operation is requested.
+ * This function is called internally whenever a file operation is requested.
* It checks if the internal state has already been initialized, and if not,
* initializes it. It checks if the file is writable, creating it if it does
* not exist. Otherwise, it throws a FileAccessException.
diff --git a/src/FileManager/SafeSymlinkManager.php b/src/FileManager/SafeSymlinkManager.php
new file mode 100644
index 00000000..9e2af59c
--- /dev/null
+++ b/src/FileManager/SafeSymlinkManager.php
@@ -0,0 +1,326 @@
+linkRoot = $this->canonicalRoot($linkRoot, 'Symlink root');
+ $this->targetRoot = $this->canonicalRoot($targetRoot, 'Symlink target root');
+ }
+
+ /**
+ * Create a symlink without replacing an existing path.
+ *
+ * Relative link paths are resolved below the configured link root and
+ * relative target paths are resolved below the configured target root.
+ */
+ public function create(
+ string $link,
+ string $target,
+ bool $createTargetDirectory = false,
+ int $directoryPermissions = 0775,
+ ): bool {
+ $this->assertDirectoryPermissions($directoryPermissions);
+ $link = $this->resolveLinkPath($link, true);
+ $target = $this->resolveTargetPath($target);
+
+ if (is_link($link)) {
+ if (!$this->linkMatchesTarget($link, $target)) {
+ throw new PolicyViolationException(sprintf('A different symbolic link already exists at "%s".', $link));
+ }
+
+ $this->prepareTarget($target, $createTargetDirectory, $directoryPermissions);
+
+ return false;
+ }
+ if (file_exists($link)) {
+ throw new PolicyViolationException(sprintf('A file or directory already exists at "%s".', $link));
+ }
+
+ $target = $this->prepareTarget($target, $createTargetDirectory, $directoryPermissions);
+ if ($this->createNativeSymlink($target, $link)) {
+ return true;
+ }
+
+ return $this->resolveConcurrentCreate($link, $target);
+ }
+
+ /**
+ * Remove a symlink only when it still points to the expected target.
+ */
+ public function remove(string $link, string $expectedTarget): bool
+ {
+ $link = $this->resolveLinkPath($link, true);
+ $expectedTarget = $this->resolveExpectedTarget($expectedTarget);
+
+ if (!is_link($link)) {
+ if (file_exists($link)) {
+ throw new PolicyViolationException(sprintf('Refusing to remove non-symbolic path "%s".', $link));
+ }
+
+ return false;
+ }
+ if (!$this->linkMatchesTarget($link, $expectedTarget)) {
+ throw new PolicyViolationException(sprintf(
+ 'Refusing to remove symbolic link "%s" because its current target does not match the expected target.',
+ $link,
+ ));
+ }
+ if (!$this->removeNativeSymlink($link)) {
+ throw new \RuntimeException(sprintf('Unable to remove symbolic link "%s".', $link));
+ }
+
+ return true;
+ }
+
+ /**
+ * Inspect a symlink without changing it.
+ */
+ public function status(string $link, string $expectedTarget): SymlinkStatus
+ {
+ $link = $this->resolveLinkPath($link, false);
+ $expectedTarget = $this->resolveExpectedTarget($expectedTarget);
+ $linked = is_link($link);
+ $exists = $linked || file_exists($link);
+ $resolved = $linked ? realpath($link) : false;
+ $broken = $linked && ($resolved === false || !file_exists($resolved));
+
+ return new SymlinkStatus(
+ link: $link,
+ target: $expectedTarget,
+ exists: $exists,
+ linked: $linked,
+ matches: $linked && $this->linkMatchesTarget($link, $expectedTarget),
+ broken: $broken,
+ );
+ }
+
+ private function assertDirectoryPermissions(int $permissions): void
+ {
+ if ($permissions < 0 || $permissions > 0777) {
+ throw new \InvalidArgumentException('Directory permissions must be between 0000 and 0777.');
+ }
+ }
+
+ private function assertInside(string $path, string $root, string $label, bool $allowRoot = true): void
+ {
+ $pathKey = $this->comparisonKey($path);
+ $rootKey = $this->comparisonKey($root);
+ if ($pathKey === $rootKey) {
+ if ($allowRoot) {
+ return;
+ }
+
+ throw new PolicyViolationException(sprintf('%s must remain below "%s".', $label, $root));
+ }
+ if (str_starts_with($pathKey, $this->rootPrefix($rootKey))) {
+ return;
+ }
+
+ throw new PolicyViolationException(sprintf('%s must remain inside "%s".', $label, $root));
+ }
+
+ private function canonicalExistingTarget(string $target): string
+ {
+ $resolved = realpath($target);
+ if ($resolved === false) {
+ throw new PolicyViolationException(sprintf('Symlink target does not resolve: %s', $target));
+ }
+
+ $resolved = PathHelper::normalize($resolved);
+ $this->assertInside($resolved, $this->targetRoot, 'Symlink target');
+
+ return $resolved;
+ }
+
+ private function canonicalRoot(string $root, string $label): string
+ {
+ $root = trim($root);
+ if ($root === '' || str_contains($root, "\0") || PathHelper::hasScheme($root)) {
+ throw new \InvalidArgumentException($label . ' must be a non-empty local filesystem path.');
+ }
+
+ $resolved = realpath($root);
+ if ($resolved === false || !is_dir($resolved)) {
+ throw new \InvalidArgumentException($label . ' must reference an existing directory.');
+ }
+
+ return PathHelper::normalize($resolved);
+ }
+
+ private function comparisonKey(string $path): string
+ {
+ $key = str_replace('\\', '/', PathHelper::normalize($path));
+ if ($key !== '/' && preg_match('/^[A-Za-z]:\/$/', $key) !== 1) {
+ $key = rtrim($key, '/');
+ }
+
+ return PHP_OS_FAMILY === 'Windows' ? strtolower($key) : $key;
+ }
+
+ private function createNativeSymlink(string $target, string $link): bool
+ {
+ return $this->runSilently(static fn(): bool => symlink($target, $link)) === true;
+ }
+
+ private function linkMatchesTarget(string $link, string $target): bool
+ {
+ $resolvedLink = realpath($link);
+ $resolvedTarget = realpath($target);
+ if ($resolvedLink !== false && $resolvedTarget !== false) {
+ return $this->samePath($resolvedLink, $resolvedTarget);
+ }
+
+ $rawTarget = readlink($link);
+ if (!is_string($rawTarget) || !PathHelper::isAbsolute($rawTarget)) {
+ return false;
+ }
+
+ return $this->samePath($rawTarget, $target);
+ }
+
+ private function nearestExistingAncestor(string $path): string
+ {
+ $ancestor = $path;
+ while (!file_exists($ancestor) && !is_link($ancestor)) {
+ $parent = dirname($ancestor);
+ if ($parent === $ancestor) {
+ throw new PolicyViolationException(sprintf('Path has no existing ancestor: %s', $path));
+ }
+
+ $ancestor = $parent;
+ }
+
+ return $ancestor;
+ }
+
+ private function prepareTarget(string $target, bool $createDirectory, int $permissions): string
+ {
+ if (file_exists($target) || is_link($target)) {
+ return $this->canonicalExistingTarget($target);
+ }
+ if (!$createDirectory) {
+ throw new \RuntimeException(sprintf('Symlink target does not exist: %s', $target));
+ }
+
+ $ancestor = realpath($this->nearestExistingAncestor($target));
+ if ($ancestor === false) {
+ throw new PolicyViolationException(sprintf('Unable to resolve symlink target ancestor: %s', $target));
+ }
+ $this->assertInside($ancestor, $this->targetRoot, 'Symlink target ancestor');
+
+ $created = $this->runSilently(static fn(): bool => mkdir($target, $permissions, true));
+ if (!$created && !is_dir($target)) {
+ throw new \RuntimeException(sprintf('Unable to create symlink target directory "%s".', $target));
+ }
+
+ return $this->canonicalExistingTarget($target);
+ }
+
+ private function removeNativeSymlink(string $link): bool
+ {
+ if ($this->runSilently(static fn(): bool => unlink($link)) === true) {
+ return true;
+ }
+
+ return $this->runSilently(static fn(): bool => rmdir($link)) === true;
+ }
+
+ private function resolveCandidate(string $path, string $root, string $label, bool $allowRoot): string
+ {
+ $path = trim($path);
+ if ($path === '' || str_contains($path, "\0") || PathHelper::hasScheme($path)) {
+ throw new \InvalidArgumentException($label . ' must be a non-empty local filesystem path.');
+ }
+ if (preg_match('~(?:^|[\\\\/])\.\.(?:[\\\\/]|$)~', $path) === 1) {
+ throw new PolicyViolationException($label . ' cannot contain parent-directory traversal.');
+ }
+
+ $candidate = PathHelper::isAbsolute($path)
+ ? PathHelper::normalize($path)
+ : PathHelper::join($root, trim($path, '/\\'));
+ $this->assertInside($candidate, $root, $label, $allowRoot);
+
+ return $candidate;
+ }
+
+ private function resolveConcurrentCreate(string $link, string $target): bool
+ {
+ if (is_link($link) && $this->linkMatchesTarget($link, $target)) {
+ return false;
+ }
+ if (is_link($link) || file_exists($link)) {
+ throw new PolicyViolationException(sprintf('A different path appeared at symbolic link "%s".', $link));
+ }
+
+ throw new \RuntimeException(sprintf('Unable to create symbolic link "%s".', $link));
+ }
+
+ private function resolveExpectedTarget(string $target): string
+ {
+ $target = $this->resolveTargetPath($target);
+ if (file_exists($target) || is_link($target)) {
+ return $this->canonicalExistingTarget($target);
+ }
+
+ return $target;
+ }
+
+ private function resolveLinkPath(string $link, bool $requireParent): string
+ {
+ $link = $this->resolveCandidate($link, $this->linkRoot, 'Symlink path', false);
+ $parent = realpath(dirname($link));
+ if ($parent === false) {
+ if (!$requireParent && !is_link($link) && !file_exists($link)) {
+ return $link;
+ }
+
+ throw new \RuntimeException(sprintf('Symlink parent does not exist: %s', dirname($link)));
+ }
+
+ $parent = PathHelper::normalize($parent);
+ $this->assertInside($parent, $this->linkRoot, 'Symlink parent');
+ $link = PathHelper::join($parent, basename($link));
+ $this->assertInside($link, $this->linkRoot, 'Symlink path', false);
+
+ return $link;
+ }
+
+ private function resolveTargetPath(string $target): string
+ {
+ return $this->resolveCandidate($target, $this->targetRoot, 'Symlink target', true);
+ }
+
+ private function rootPrefix(string $root): string
+ {
+ return str_ends_with($root, '/') ? $root : $root . '/';
+ }
+
+ private function runSilently(callable $operation): mixed
+ {
+ set_error_handler(static fn(): bool => true);
+
+ try {
+ return $operation();
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ private function samePath(string $left, string $right): bool
+ {
+ return $this->comparisonKey($left) === $this->comparisonKey($right);
+ }
+}
diff --git a/src/Indexing/ChecksumIndexer.php b/src/Indexing/ChecksumIndexer.php
index ffc8bfcd..bfedafe3 100644
--- a/src/Indexing/ChecksumIndexer.php
+++ b/src/Indexing/ChecksumIndexer.php
@@ -6,7 +6,6 @@
use Infocyph\Pathwise\Exceptions\FileAccessException;
use Infocyph\Pathwise\Results\DeduplicationResult;
-
use Infocyph\Pathwise\Utils\FlysystemHelper;
use Infocyph\Pathwise\Utils\FlysystemPathResolver;
use Infocyph\Pathwise\Utils\LocalFileIterator;
@@ -14,31 +13,12 @@
final class ChecksumIndexer
{
- /**
- * Build a checksum index for all files in a directory.
- *
- * @param string $directory The directory to index.
- * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'.
- * @return array> Array mapping checksum to array of file paths.
- */
+ /** @return array> */
public static function buildIndex(string $directory, string $algorithm = 'sha256'): array
{
- $directory = PathHelper::normalize($directory);
- if (!in_array($algorithm, hash_algos(), true)) {
- throw new \InvalidArgumentException("Unsupported checksum algorithm: {$algorithm}.");
- }
- if (!FlysystemHelper::directoryExists($directory)) {
- throw new FileAccessException("Checksum index directory does not exist: {$directory}.");
- }
-
$index = [];
- foreach (self::iterFiles($directory) as $path) {
- $hash = self::hashPath($path, $algorithm);
- if (!is_string($hash)) {
- throw new FileAccessException("Unable to calculate checksum for: {$path}.");
- }
-
- $index[$hash][] = $path;
+ foreach (self::iterate($directory, $algorithm) as $entry) {
+ $index[$entry['checksum']][] = $entry['path'];
}
ksort($index);
@@ -46,12 +26,6 @@ public static function buildIndex(string $directory, string $algorithm = 'sha256
return $index;
}
- /**
- * Deduplicate files by replacing duplicate entries with hard links where supported.
- *
- * @param string $directory The directory to deduplicate.
- * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'.
- */
public static function deduplicateWithHardLinks(
string $directory,
string $algorithm = 'sha256',
@@ -67,13 +41,7 @@ public static function deduplicateWithHardLinks(
return new DeduplicationResult($linked, $skipped);
}
- /**
- * Find duplicate files in a directory.
- *
- * @param string $directory The directory to search for duplicates.
- * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'.
- * @return array> Array mapping checksum to array of duplicate file paths.
- */
+ /** @return array> */
public static function findDuplicates(string $directory, string $algorithm = 'sha256'): array
{
$index = self::buildIndex($directory, $algorithm);
@@ -81,6 +49,35 @@ public static function findDuplicates(string $directory, string $algorithm = 'sh
return array_filter($index, static fn(array $paths): bool => count($paths) > 1);
}
+ /**
+ * Stream checksum/path pairs without retaining a complete index in memory.
+ *
+ * SHA-256 remains the default because this API is also used for integrity-oriented
+ * workflows; callers that only need a faster non-security fingerprint may select
+ * another algorithm explicitly.
+ *
+ * @return \Generator
+ */
+ public static function iterate(string $directory, string $algorithm = 'sha256'): \Generator
+ {
+ $directory = PathHelper::normalize($directory);
+ if (!in_array($algorithm, hash_algos(), true)) {
+ throw new \InvalidArgumentException("Unsupported checksum algorithm: {$algorithm}.");
+ }
+ if (!FlysystemHelper::directoryExists($directory)) {
+ throw new FileAccessException("Checksum index directory does not exist: {$directory}.");
+ }
+
+ foreach (self::iterFiles($directory) as $path) {
+ $hash = self::hashPath($path, $algorithm);
+ if (!is_string($hash)) {
+ throw new FileAccessException("Unable to calculate checksum for: {$path}.");
+ }
+
+ yield ['checksum' => $hash, 'path' => $path];
+ }
+ }
+
/**
* @param list $paths
* @param list $linked
@@ -225,11 +222,9 @@ private static function iterFilesViaFlysystem(string $directory): \Generator
foreach (FlysystemHelper::listContentsListing($directory, true) as $item) {
$relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file');
- if ($relative === null) {
- continue;
+ if ($relative !== null) {
+ yield PathHelper::join($directory, $relative);
}
-
- yield PathHelper::join($directory, $relative);
}
}
@@ -258,10 +253,8 @@ private static function temporarySiblingPath(string $path): ?string
private static function unlinkSilently(string $path): void
{
- if (!is_file($path)) {
- return;
+ if (is_file($path)) {
+ self::runSilently(static fn(): bool => unlink($path));
}
-
- self::runSilently(static fn(): bool => unlink($path));
}
}
diff --git a/src/Native/NativeCommandRunner.php b/src/Native/NativeCommandRunner.php
index 1ed830c7..9f0a104c 100644
--- a/src/Native/NativeCommandRunner.php
+++ b/src/Native/NativeCommandRunner.php
@@ -4,13 +4,29 @@
namespace Infocyph\Pathwise\Native;
+use Infocyph\Pathwise\Results\NativeExecutionResult;
+
final class NativeCommandRunner
{
+ private const int EXIT_IO_ERROR = 126;
+
+ private const int EXIT_OUTPUT_LIMIT = 125;
+
+ private const int EXIT_START_FAILED = 127;
+
+ private const int EXIT_TIMEOUT = 124;
+
+ private const int READ_CHUNK_BYTES = 65_536;
+
/** @var array */
private static array $executableCache = [];
public static function commandExists(string $command): bool
{
+ if (!self::supportsBoundedExecution()) {
+ return false;
+ }
+
$cacheKey = PHP_OS_FAMILY . ':' . strtolower($command);
if (array_key_exists($cacheKey, self::$executableCache)) {
return self::$executableCache[$cacheKey];
@@ -21,77 +37,247 @@ public static function commandExists(string $command): bool
/**
* @param list $command
- * @return array{success: bool, output: list, code: int}
*/
- public static function run(array $command, ?string $workingDirectory = null): array
- {
- if ($command === []) {
- return ['success' => false, 'output' => ['No command was provided.'], 'code' => 127];
+ public static function run(
+ array $command,
+ ?string $workingDirectory = null,
+ ?NativeExecutionLimits $limits = null,
+ ): NativeExecutionResult {
+ if (!self::supportsBoundedExecution()) {
+ return self::unsupportedResult();
+ }
+
+ $limits ??= new NativeExecutionLimits();
+ if (!self::isValidCommand($command)) {
+ return self::startFailedResult('', 'No valid command was provided.');
}
+ $displayCommand = self::displayCommand($command);
$pipes = [];
- $process = proc_open(
- $command,
- [
- 1 => ['pipe', 'w'],
- 2 => ['pipe', 'w'],
- ],
- $pipes,
- $workingDirectory,
- null,
- ['bypass_shell' => true],
- );
+ $process = self::startProcess($command, $workingDirectory, $pipes);
if (!is_resource($process)) {
- return ['success' => false, 'output' => ['Unable to start native command.'], 'code' => 127];
+ return self::startFailedResult($displayCommand, 'Unable to start native command.');
+ }
+
+ self::closePipe($pipes[0] ?? null);
+ $stdout = $pipes[1] ?? null;
+ $stderr = $pipes[2] ?? null;
+ if (!is_resource($stdout) || !is_resource($stderr)) {
+ self::closePipe($stdout);
+ self::closePipe($stderr);
+ self::terminateImmediately($process);
+ proc_close($process);
+
+ return self::startFailedResult($displayCommand, 'Unable to initialize native command output pipes.');
+ }
+
+ if (!stream_set_blocking($stdout, false) || !stream_set_blocking($stderr, false)) {
+ self::closePipe($stdout);
+ self::closePipe($stderr);
+ self::terminateImmediately($process);
+ proc_close($process);
+
+ return self::startFailedResult($displayCommand, 'Unable to configure bounded native command output pipes.');
+ }
+
+ $stdoutBuffer = '';
+ $stderrBuffer = '';
+ [$failure, $statusExitCode] = self::monitorProcess(
+ $process,
+ $stdout,
+ $stderr,
+ $limits,
+ $stdoutBuffer,
+ $stderrBuffer,
+ );
+
+ self::closePipe($stdout);
+ self::closePipe($stderr);
+ $closeExitCode = proc_close($process);
+
+ return self::buildResult(
+ $displayCommand,
+ $failure,
+ $statusExitCode,
+ $closeExitCode,
+ $stdoutBuffer,
+ $stderrBuffer,
+ );
+ }
+
+ public static function supportsBoundedExecution(): bool
+ {
+ return PHP_OS_FAMILY !== 'Windows';
+ }
+
+ private static function buildResult(
+ string $displayCommand,
+ ?NativeExecutionFailure $failure,
+ int $statusExitCode,
+ int $closeExitCode,
+ string $stdoutBuffer,
+ string $stderrBuffer,
+ ): NativeExecutionResult {
+ $exitCode = self::resolveExitCode($failure, $statusExitCode, $closeExitCode);
+ if ($failure === null && $exitCode !== 0) {
+ $failure = NativeExecutionFailure::EXIT_CODE;
}
- $stdout = is_resource($pipes[1] ?? null) ? stream_get_contents($pipes[1]) : '';
- $stderr = is_resource($pipes[2] ?? null) ? stream_get_contents($pipes[2]) : '';
- foreach ($pipes as $pipe) {
- if (is_resource($pipe)) {
- fclose($pipe);
+ $stdout = self::lines($stdoutBuffer);
+ $stderr = self::lines($stderrBuffer);
+
+ return new NativeExecutionResult(
+ $failure === null,
+ $displayCommand,
+ $exitCode,
+ [...$stdout, ...$stderr],
+ $failure,
+ $stdout,
+ $stderr,
+ );
+ }
+
+ private static function closePipe(mixed $pipe): void
+ {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+ }
+
+ private static function deadlineFromNow(float $seconds): int
+ {
+ return hrtime(true) + (int) round($seconds * 1_000_000_000);
+ }
+
+ private static function discardAvailable(mixed $pipe): void
+ {
+ while (is_resource($pipe) && !feof($pipe)) {
+ $chunk = fread($pipe, self::READ_CHUNK_BYTES);
+ if (!is_string($chunk) || $chunk === '') {
+ return;
}
}
- $exitCode = proc_close($process);
- $combined = trim((is_string($stdout) ? $stdout : '') . "\n" . (is_string($stderr) ? $stderr : ''));
- $output = $combined === '' ? [] : preg_split('/\R/', $combined);
+ }
- return [
- 'success' => $exitCode === 0,
- 'output' => is_array($output) ? $output : [],
- 'code' => $exitCode,
- ];
+ /** @param list $command */
+ private static function displayCommand(array $command): string
+ {
+ return implode(' ', array_map(
+ static fn(string $argument): string => json_encode($argument, JSON_UNESCAPED_SLASHES) ?: '""',
+ $command,
+ ));
+ }
+
+ private static function drainFinalPipes(
+ mixed $stdout,
+ mixed $stderr,
+ string &$stdoutBuffer,
+ string &$stderrBuffer,
+ int &$stdoutBytes,
+ int &$stderrBytes,
+ NativeExecutionLimits $limits,
+ ): ?NativeExecutionFailure {
+ return self::drainPipe(
+ $stdout,
+ $stdoutBuffer,
+ $stdoutBytes,
+ $limits->stdoutBytes,
+ NativeExecutionFailure::STDOUT_LIMIT,
+ ) ?? self::drainPipe(
+ $stderr,
+ $stderrBuffer,
+ $stderrBytes,
+ $limits->stderrBytes,
+ NativeExecutionFailure::STDERR_LIMIT,
+ );
+ }
+
+ private static function drainPipe(
+ mixed $pipe,
+ string &$buffer,
+ int &$bytes,
+ int $limit,
+ NativeExecutionFailure $limitFailure,
+ ): ?NativeExecutionFailure {
+ while (is_resource($pipe) && !feof($pipe)) {
+ $remaining = $limit - $bytes;
+ $readLength = min(self::READ_CHUNK_BYTES, max(1, $remaining + 1));
+ $chunk = fread($pipe, $readLength);
+ if ($chunk === false) {
+ return NativeExecutionFailure::IO_ERROR;
+ }
+ if ($chunk === '') {
+ return null;
+ }
+
+ $length = strlen($chunk);
+ if ($length > $remaining) {
+ if ($remaining > 0) {
+ $buffer .= substr($chunk, 0, $remaining);
+ $bytes += $remaining;
+ }
+
+ return $limitFailure;
+ }
+
+ $buffer .= $chunk;
+ $bytes += $length;
+ }
+
+ return null;
}
/** @return \Generator */
private static function executableCandidates(string $command, string $path): \Generator
{
- $extensions = PHP_OS_FAMILY === 'Windows' ? self::windowsExecutableExtensions() : [''];
foreach (explode(PATH_SEPARATOR, $path) as $directory) {
if ($directory === '') {
continue;
}
- foreach ($extensions as $extension) {
- yield rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $command . $extension;
- }
+
+ yield rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $command;
}
}
+ /** @param list $command */
+ private static function isValidCommand(array $command): bool
+ {
+ if ($command === [] || $command[0] === '') {
+ return false;
+ }
+
+ return array_all($command, static fn(string $argument): bool => !str_contains($argument, "\0"));
+ }
+
+ /** @return list */
+ private static function lines(string $buffer): array
+ {
+ $normalized = rtrim($buffer, "\r\n");
+ if ($normalized === '') {
+ return [];
+ }
+
+ $lines = preg_split('/\R/', $normalized);
+
+ return $lines === false ? [] : $lines;
+ }
+
private static function locateExecutable(string $command): bool
{
if ($command === '' || str_contains($command, "\0")) {
return false;
}
if (str_contains($command, '/') || str_contains($command, '\\')) {
- return is_file($command) && (PHP_OS_FAMILY === 'Windows' || is_executable($command));
+ return is_file($command) && is_executable($command);
}
$path = getenv('PATH');
if (!is_string($path) || $path === '') {
return false;
}
+
foreach (self::executableCandidates($command, $path) as $candidate) {
- if (is_file($candidate) && (PHP_OS_FAMILY === 'Windows' || is_executable($candidate))) {
+ if (is_file($candidate) && is_executable($candidate)) {
return true;
}
}
@@ -99,14 +285,202 @@ private static function locateExecutable(string $command): bool
return false;
}
- /** @return list */
- private static function windowsExecutableExtensions(): array
+ /**
+ * @param resource $process
+ * @param resource $stdout
+ * @param resource $stderr
+ * @return array{NativeExecutionFailure|null, int}
+ */
+ private static function monitorProcess(
+ mixed $process,
+ mixed $stdout,
+ mixed $stderr,
+ NativeExecutionLimits $limits,
+ string &$stdoutBuffer,
+ string &$stderrBuffer,
+ ): array {
+ $stdoutBytes = 0;
+ $stderrBytes = 0;
+ $deadline = self::deadlineFromNow($limits->timeoutSeconds);
+
+ while (true) {
+ $failure = self::drainPipe(
+ $stdout,
+ $stdoutBuffer,
+ $stdoutBytes,
+ $limits->stdoutBytes,
+ NativeExecutionFailure::STDOUT_LIMIT,
+ ) ?? self::drainPipe(
+ $stderr,
+ $stderrBuffer,
+ $stderrBytes,
+ $limits->stderrBytes,
+ NativeExecutionFailure::STDERR_LIMIT,
+ );
+ $status = proc_get_status($process);
+
+ if ($failure !== null) {
+ $exitCode = $status['running']
+ ? self::terminateBounded($process, $stdout, $stderr, $limits)
+ : $status['exitcode'];
+
+ return [$failure, $exitCode];
+ }
+
+ if (!$status['running']) {
+ return [
+ self::drainFinalPipes(
+ $stdout,
+ $stderr,
+ $stdoutBuffer,
+ $stderrBuffer,
+ $stdoutBytes,
+ $stderrBytes,
+ $limits,
+ ),
+ $status['exitcode'],
+ ];
+ }
+
+ if (hrtime(true) >= $deadline) {
+ return [
+ NativeExecutionFailure::TIMEOUT,
+ self::terminateBounded($process, $stdout, $stderr, $limits),
+ ];
+ }
+
+ usleep($limits->pollIntervalMicroseconds);
+ }
+ }
+
+ private static function resolveExitCode(
+ ?NativeExecutionFailure $failure,
+ int $statusExitCode,
+ int $closeExitCode,
+ ): int {
+ $failureExitCode = match ($failure) {
+ NativeExecutionFailure::TIMEOUT => self::EXIT_TIMEOUT,
+ NativeExecutionFailure::STDOUT_LIMIT, NativeExecutionFailure::STDERR_LIMIT => self::EXIT_OUTPUT_LIMIT,
+ NativeExecutionFailure::IO_ERROR => self::EXIT_IO_ERROR,
+ NativeExecutionFailure::START_FAILED, NativeExecutionFailure::UNSUPPORTED => self::EXIT_START_FAILED,
+ default => null,
+ };
+
+ return $failureExitCode ?? ($statusExitCode >= 0 ? $statusExitCode : $closeExitCode);
+ }
+
+ private static function runSilently(callable $operation): mixed
+ {
+ set_error_handler(static fn(): bool => true);
+
+ try {
+ return $operation();
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ private static function startFailedResult(string $displayCommand, string $message): NativeExecutionResult
+ {
+ return new NativeExecutionResult(
+ false,
+ $displayCommand,
+ self::EXIT_START_FAILED,
+ [$message],
+ NativeExecutionFailure::START_FAILED,
+ );
+ }
+
+ /**
+ * @param list $command
+ * @param array $pipes
+ * @return resource|false
+ */
+ private static function startProcess(array $command, ?string $workingDirectory, array &$pipes): mixed
{
- $pathExtensions = getenv('PATHEXT');
- if (!is_string($pathExtensions) || $pathExtensions === '') {
- return ['.exe', '.com', '.bat', '.cmd'];
+ set_error_handler(static fn(): bool => true);
+
+ try {
+ try {
+ return proc_open(
+ $command,
+ [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ $workingDirectory,
+ null,
+ ['bypass_shell' => true],
+ );
+ } catch (\Throwable) {
+ return false;
+ }
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /** @param resource $process */
+ private static function terminateBounded(
+ mixed $process,
+ mixed $stdout,
+ mixed $stderr,
+ NativeExecutionLimits $limits,
+ ): int {
+ self::runSilently(static fn(): bool => proc_terminate($process));
+ $exitCode = self::waitForExit($process, $stdout, $stderr, $limits);
+ if ($exitCode !== null) {
+ return $exitCode;
}
- return array_values(array_filter(array_map(strtolower(...), explode(PATH_SEPARATOR, $pathExtensions))));
+ self::runSilently(static fn(): bool => proc_terminate($process, 9));
+
+ return self::waitForExit($process, $stdout, $stderr, $limits) ?? -1;
+ }
+
+ /** @param resource $process */
+ private static function terminateImmediately(mixed $process): void
+ {
+ self::runSilently(static fn(): bool => proc_terminate($process));
+ $status = proc_get_status($process);
+ if ($status['running']) {
+ self::runSilently(static fn(): bool => proc_terminate($process, 9));
+ }
+ }
+
+ private static function unsupportedResult(): NativeExecutionResult
+ {
+ return new NativeExecutionResult(
+ false,
+ '',
+ self::EXIT_START_FAILED,
+ ['Bounded native execution is unavailable on this platform.'],
+ NativeExecutionFailure::UNSUPPORTED,
+ );
+ }
+
+ /** @param resource $process */
+ private static function waitForExit(
+ mixed $process,
+ mixed $stdout,
+ mixed $stderr,
+ NativeExecutionLimits $limits,
+ ): ?int {
+ $deadline = self::deadlineFromNow($limits->terminationGraceSeconds);
+
+ do {
+ self::discardAvailable($stdout);
+ self::discardAvailable($stderr);
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ return $status['exitcode'];
+ }
+
+ usleep($limits->pollIntervalMicroseconds);
+ } while (hrtime(true) < $deadline);
+
+ return null;
}
}
diff --git a/src/Native/NativeExecutionFailure.php b/src/Native/NativeExecutionFailure.php
new file mode 100644
index 00000000..0ac3c82e
--- /dev/null
+++ b/src/Native/NativeExecutionFailure.php
@@ -0,0 +1,22 @@
+ 1_000_000) {
+ throw new \InvalidArgumentException(
+ 'Native execution poll interval must be between 1 and 1000000 microseconds.',
+ );
+ }
+ }
+}
diff --git a/src/Native/NativeOperationsAdapter.php b/src/Native/NativeOperationsAdapter.php
index 3b0460bf..1e3253b4 100644
--- a/src/Native/NativeOperationsAdapter.php
+++ b/src/Native/NativeOperationsAdapter.php
@@ -17,107 +17,65 @@ public static function canUseNativeCompression(): bool
public static function canUseNativeDirectoryCopy(): bool
{
- return NativeCommandRunner::commandExists(PHP_OS_FAMILY === 'Windows' ? 'robocopy' : 'rsync');
+ return NativeCommandRunner::commandExists('rsync');
}
public static function canUseNativeFileCopy(): bool
{
- return NativeCommandRunner::commandExists(PHP_OS_FAMILY === 'Windows' ? 'powershell' : 'cp');
+ return NativeCommandRunner::commandExists('cp');
}
public static function canUseNativeSearch(): bool
{
- return NativeCommandRunner::commandExists(PHP_OS_FAMILY === 'Windows' ? 'findstr' : 'grep');
+ return NativeCommandRunner::commandExists('grep');
}
public static function canUseNativeZipCompression(): bool
{
- return PHP_OS_FAMILY === 'Windows'
- ? NativeCommandRunner::commandExists('powershell')
- : NativeCommandRunner::commandExists('zip');
+ return NativeCommandRunner::commandExists('zip');
}
public static function canUseNativeZipDecompression(): bool
{
- return PHP_OS_FAMILY === 'Windows'
- ? NativeCommandRunner::commandExists('powershell')
- : NativeCommandRunner::commandExists('unzip');
+ return NativeCommandRunner::commandExists('unzip');
}
- public static function compressToZip(string $source, string $zipPath): NativeExecutionResult
- {
+ public static function compressToZip(
+ string $source,
+ string $zipPath,
+ ?NativeExecutionLimits $limits = null,
+ ): NativeExecutionResult {
$source = PathHelper::normalize($source);
$zipPath = PathHelper::normalize($zipPath);
-
- if (PHP_OS_FAMILY === 'Windows' && NativeCommandRunner::commandExists('powershell')) {
- $sourceArgument = is_dir($source)
- ? rtrim($source, '/\\') . DIRECTORY_SEPARATOR . '*'
- : $source;
- $sourcePattern = str_replace("'", "''", $sourceArgument);
- $destination = str_replace("'", "''", $zipPath);
-
- return self::run([
- 'powershell',
- '-NoProfile',
- '-Command',
- "Compress-Archive -Path '{$sourcePattern}' -DestinationPath '{$destination}' -Force",
- ]);
- }
-
if (!NativeCommandRunner::commandExists('zip')) {
return self::unsupportedResult();
}
if (is_dir($source)) {
- $command = ['zip', '-r', $zipPath, '.'];
+ $command = ['zip', '-q', '-r', $zipPath, '.'];
if (FlysystemHelper::isSameOrDescendant($source, $zipPath)) {
$command[] = '-x';
$command[] = basename($zipPath);
}
- return self::run($command, $source);
+ return self::run($command, $source, $limits);
}
- return self::run(['zip', '-r', $zipPath, basename($source)], dirname($source));
+ return self::run(['zip', '-q', '-r', $zipPath, basename($source)], dirname($source), $limits);
}
public static function copyDirectory(
string $source,
string $destination,
bool $mirror = false,
+ ?NativeExecutionLimits $limits = null,
): NativeExecutionResult {
$source = PathHelper::normalize($source);
$destination = PathHelper::normalize($destination);
-
- if (PHP_OS_FAMILY === 'Windows') {
- if (!NativeCommandRunner::commandExists('robocopy')) {
- return self::unsupportedResult();
- }
- $result = self::run([
- 'robocopy',
- $source,
- $destination,
- $mirror ? '/MIR' : '/E',
- '/R:1',
- '/W:1',
- '/NFL',
- '/NDL',
- '/NJH',
- '/NJS',
- '/NP',
- ]);
-
- return new NativeExecutionResult(
- $result->exitCode <= 7,
- $result->command,
- $result->exitCode,
- $result->output,
- );
- }
-
if (!NativeCommandRunner::commandExists('rsync')) {
return self::unsupportedResult();
}
+
$command = ['rsync', '-a'];
if ($mirror) {
$command[] = '--delete';
@@ -125,94 +83,62 @@ public static function copyDirectory(
$command[] = rtrim($source, '/\\') . DIRECTORY_SEPARATOR;
$command[] = rtrim($destination, '/\\') . DIRECTORY_SEPARATOR;
- return self::run($command);
+ return self::run($command, limits: $limits);
}
- public static function copyFile(string $source, string $destination): NativeExecutionResult
- {
+ public static function copyFile(
+ string $source,
+ string $destination,
+ ?NativeExecutionLimits $limits = null,
+ ): NativeExecutionResult {
$source = PathHelper::normalize($source);
$destination = PathHelper::normalize($destination);
- if (PHP_OS_FAMILY === 'Windows') {
- if (!NativeCommandRunner::commandExists('powershell')) {
- return self::unsupportedResult();
- }
- $literalSource = str_replace("'", "''", $source);
- $literalDestination = str_replace("'", "''", $destination);
-
- return self::run([
- 'powershell',
- '-NoProfile',
- '-Command',
- "Copy-Item -LiteralPath '{$literalSource}' -Destination '{$literalDestination}' -Force",
- ]);
- }
return NativeCommandRunner::commandExists('cp')
- ? self::run(['cp', '-f', $source, $destination])
+ ? self::run(['cp', '-f', $source, $destination], limits: $limits)
: self::unsupportedResult();
}
- public static function decompressZip(string $zipPath, string $destination): NativeExecutionResult
- {
+ public static function decompressZip(
+ string $zipPath,
+ string $destination,
+ ?NativeExecutionLimits $limits = null,
+ ): NativeExecutionResult {
$zipPath = PathHelper::normalize($zipPath);
$destination = PathHelper::normalize($destination);
- if (PHP_OS_FAMILY === 'Windows') {
- if (!NativeCommandRunner::commandExists('powershell')) {
- return self::unsupportedResult();
- }
- $source = str_replace("'", "''", $zipPath);
- $target = str_replace("'", "''", $destination);
-
- return self::run([
- 'powershell',
- '-NoProfile',
- '-Command',
- "Expand-Archive -LiteralPath '{$source}' -DestinationPath '{$target}' -Force",
- ]);
- }
return NativeCommandRunner::commandExists('unzip')
- ? self::run(['unzip', '-o', $zipPath, '-d', $destination])
+ ? self::run(['unzip', '-q', '-o', $zipPath, '-d', $destination], limits: $limits)
: self::unsupportedResult();
}
- public static function searchFile(string $path, string $term): NativeExecutionResult
- {
- if (PHP_OS_FAMILY === 'Windows') {
- return NativeCommandRunner::commandExists('findstr')
- ? self::run(['findstr', '/I', '/L', $term, PathHelper::normalize($path)])
- : self::unsupportedResult();
- }
-
+ public static function searchFile(
+ string $path,
+ string $term,
+ ?NativeExecutionLimits $limits = null,
+ ): NativeExecutionResult {
return NativeCommandRunner::commandExists('grep')
- ? self::run(['grep', '-i', '-F', '--', $term, PathHelper::normalize($path)])
+ ? self::run(['grep', '-i', '-F', '--', $term, PathHelper::normalize($path)], limits: $limits)
: self::unsupportedResult();
}
/** @param list $command */
- private static function displayCommand(array $command): string
- {
- return implode(' ', array_map(
- static fn(string $argument): string => json_encode($argument, JSON_UNESCAPED_SLASHES) ?: '""',
- $command,
- ));
+ private static function run(
+ array $command,
+ ?string $workingDirectory = null,
+ ?NativeExecutionLimits $limits = null,
+ ): NativeExecutionResult {
+ return NativeCommandRunner::run($command, $workingDirectory, $limits);
}
- /** @param list $command */
- private static function run(array $command, ?string $workingDirectory = null): NativeExecutionResult
+ private static function unsupportedResult(): NativeExecutionResult
{
- $result = NativeCommandRunner::run($command, $workingDirectory);
-
return new NativeExecutionResult(
- $result['success'],
- self::displayCommand($command),
- $result['code'],
- $result['output'],
+ false,
+ '',
+ 127,
+ ['Bounded native execution or the required native executable is unavailable.'],
+ NativeExecutionFailure::UNSUPPORTED,
);
}
-
- private static function unsupportedResult(): NativeExecutionResult
- {
- return new NativeExecutionResult(false, '', 127, ['Required native executable is unavailable.']);
- }
}
diff --git a/src/Observability/LocalJsonlAuditSink.php b/src/Observability/LocalJsonlAuditSink.php
index 19adffab..6a8b036c 100644
--- a/src/Observability/LocalJsonlAuditSink.php
+++ b/src/Observability/LocalJsonlAuditSink.php
@@ -23,8 +23,13 @@ public function __construct(string $path)
$this->path = PathHelper::normalize($path);
$directory = dirname($this->path);
- if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
- throw new AuditException("Unable to create audit directory: {$directory}");
+ if (!is_dir($directory)) {
+ if (!mkdir($directory, 0700, true) && !is_dir($directory)) {
+ throw new AuditException("Unable to create audit directory: {$directory}");
+ }
+ if (!chmod($directory, 0700)) {
+ throw new AuditException("Unable to secure audit directory: {$directory}");
+ }
}
}
@@ -35,9 +40,49 @@ public function write(array $record): void
} catch (\JsonException $exception) {
throw new AuditException('Unable to encode audit record.', 0, $exception);
}
- $written = file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX);
- if ($written !== strlen($line)) {
- throw new AuditException("Unable to append audit record to {$this->path}.");
+
+ $stream = fopen($this->path, 'ab');
+ if (!is_resource($stream)) {
+ throw new AuditException("Unable to open audit record file: {$this->path}.");
+ }
+
+ $locked = false;
+
+ try {
+ if (!chmod($this->path, 0600)) {
+ throw new AuditException("Unable to secure audit record file: {$this->path}.");
+ }
+ if (!flock($stream, LOCK_EX)) {
+ throw new AuditException("Unable to lock audit record file: {$this->path}.");
+ }
+ $locked = true;
+
+ self::writeAll($stream, $line, $this->path);
+ if (!fflush($stream)) {
+ throw new AuditException("Unable to flush audit record file: {$this->path}.");
+ }
+ if (function_exists('fsync') && !fsync($stream)) {
+ throw new AuditException("Unable to synchronize audit record file: {$this->path}.");
+ }
+ } finally {
+ if ($locked) {
+ flock($stream, LOCK_UN);
+ }
+ fclose($stream);
+ }
+ }
+
+ /** @param resource $stream */
+ private static function writeAll(mixed $stream, string $contents, string $path): void
+ {
+ $offset = 0;
+ $length = strlen($contents);
+ while ($offset < $length) {
+ $written = fwrite($stream, substr($contents, $offset));
+ if (!is_int($written) || $written < 1) {
+ throw new AuditException("Unable to append audit record to {$path}.");
+ }
+ $offset += $written;
}
}
}
diff --git a/src/Observability/PartitionedAuditSink.php b/src/Observability/PartitionedAuditSink.php
index 6128807c..af5d0b37 100644
--- a/src/Observability/PartitionedAuditSink.php
+++ b/src/Observability/PartitionedAuditSink.php
@@ -19,10 +19,18 @@ public function write(array $record): void
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$partition = $now->format('Y/m/d/H');
$name = sprintf('%s-%s.json', $now->format('Ymd\THis.u\Z'), bin2hex(random_bytes(12)));
- $path = PathHelper::join($this->directory, $partition, $name);
+ $partitionPath = PathHelper::join($this->directory, $partition);
+ $path = PathHelper::join($partitionPath, $name);
try {
- FlysystemHelper::write($path, json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
+ if (!FlysystemHelper::directoryExists($partitionPath)) {
+ FlysystemHelper::createDirectory($partitionPath, ['visibility' => 'private']);
+ }
+ FlysystemHelper::write(
+ $path,
+ json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES),
+ ['visibility' => 'private'],
+ );
} catch (\Throwable $exception) {
throw new AuditException("Unable to write partitioned audit record: {$path}", 0, $exception);
}
diff --git a/src/PathwiseFacade.php b/src/PathwiseFacade.php
index f5fc2e29..bff3b600 100644
--- a/src/PathwiseFacade.php
+++ b/src/PathwiseFacade.php
@@ -29,162 +29,78 @@
use League\Flysystem\FilesystemOperator;
/**
+ * Stateless convenience facade for common Pathwise operations.
+ *
+ * Persistent storage topology belongs to Storage\StorageContext rather than
+ * process-global facade registration.
+ *
* @phpstan-type SnapshotEntry array{mtime: int, size: int}
* @phpstan-type SnapshotMap array
- * @phpstan-type DiffReport array{created: list, modified: list, deleted: list}
*/
final class PathwiseFacade
{
- /**
- * Constructor to initialize the file path.
- *
- * @param string $path The path to the file or directory.
- */
public function __construct(private string $path)
{
$this->path = PathHelper::normalize($path);
}
- /**
- * Create a new instance at the given path.
- *
- * @param string $path The path to the file or directory.
- * @return self A new facade instance.
- */
public static function at(string $path): self
{
return new self($path);
}
- /**
- * Create an audit trail logger.
- *
- * @param string|AuditSink $sink A local JSONL path or a custom audit sink.
- * @return AuditTrail The audit trail instance.
- */
public static function audit(string|AuditSink $sink): AuditTrail
{
return new AuditTrail($sink);
}
- /**
- * Create a Flysystem filesystem from configuration.
- *
- * @param array $config The filesystem configuration.
- * @return FilesystemOperator The created filesystem.
- */
+ /** @param array $config */
public static function createFilesystem(array $config): FilesystemOperator
{
return StorageFactory::createFilesystem($config);
}
- /**
- * Deduplicate files in a directory using hard links.
- *
- * @param string $directory The directory to deduplicate.
- * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'.
- */
public static function deduplicate(string $directory, string $algorithm = 'sha256'): DeduplicationResult
{
return ChecksumIndexer::deduplicateWithHardLinks($directory, $algorithm);
}
/**
- * Compare two snapshots and return the differences.
- *
- * @param SnapshotMap $previousSnapshot The previous snapshot data.
- * @param SnapshotMap $currentSnapshot The current snapshot data.
+ * @param SnapshotMap $previousSnapshot
+ * @param SnapshotMap $currentSnapshot
*/
public static function diffSnapshots(array $previousSnapshot, array $currentSnapshot): SnapshotDiff
{
return FileWatcher::diff($previousSnapshot, $currentSnapshot);
}
- /**
- * Create a download processor for secure file downloads.
- *
- * @return DownloadProcessor The download processor instance.
- */
public static function download(): DownloadProcessor
{
return new DownloadProcessor();
}
- /**
- * Find duplicate files in a directory.
- *
- * @param string $directory The directory to search for duplicates.
- * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'.
- * @return array> Array mapping checksum to duplicate file paths.
- */
+ /** @return array> */
public static function duplicates(string $directory, string $algorithm = 'sha256'): array
{
return ChecksumIndexer::findDuplicates($directory, $algorithm);
}
- /**
- * Build a checksum index for all files in a directory.
- *
- * @param string $directory The directory to index.
- * @param string $algorithm The hash algorithm to use. Defaults to 'sha256'.
- * @return array> Array mapping checksum to file paths.
- */
+ /** @return array> */
public static function index(string $directory, string $algorithm = 'sha256'): array
{
return ChecksumIndexer::buildIndex($directory, $algorithm);
}
- /**
- * Create and mount a filesystem under a name.
- *
- * @param string $name The mount name.
- * @param array $config The filesystem configuration.
- * @return FilesystemOperator The created filesystem.
- */
- public static function mountStorage(string $name, array $config): FilesystemOperator
- {
- return StorageFactory::mount($name, $config);
- }
-
- /**
- * Mount multiple filesystems at once.
- *
- * @param array> $mounts Array of mount name => config pairs.
- */
- public static function mountStorages(array $mounts): void
- {
- StorageFactory::mountMany($mounts);
- }
-
- /**
- * Create a policy engine for access control.
- *
- * @return PolicyEngine The policy engine instance.
- */
public static function policy(): PolicyEngine
{
return new PolicyEngine();
}
- /**
- * Create a file-based job queue.
- *
- * @param string $queueFilePath The path to the queue file.
- * @return FileJobQueue The job queue instance.
- */
public static function queue(string $queueFilePath): FileJobQueue
{
return new FileJobQueue($queueFilePath);
}
- /**
- * Apply retention rules to a directory.
- *
- * @param string $directory The directory to apply retention rules to.
- * @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').
- */
public static function retain(
string $directory,
?int $keepLast = null,
@@ -194,37 +110,17 @@ public static function retain(
return RetentionManager::apply($directory, $keepLast, $maxAgeDays, $sortBy);
}
- /**
- * Build a snapshot map for a file or directory.
- *
- * @param string $path The path to snapshot.
- * @param bool $recursive Whether to include subdirectories recursively.
- * @return array The snapshot map.
- */
+ /** @return SnapshotMap */
public static function snapshot(string $path, bool $recursive = true): array
{
return FileWatcher::snapshot($path, $recursive);
}
- /**
- * Create an upload processor for secure file uploads.
- *
- * @return UploadProcessor The upload processor instance.
- */
public static function upload(): UploadProcessor
{
return new UploadProcessor();
}
- /**
- * Poll for file-system changes and invoke callback on each non-empty diff.
- *
- * @param string $path The path to watch.
- * @param callable $onChange Callback invoked when changes detected.
- * @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.
- */
public static function watch(
string $path,
callable $onChange,
@@ -235,95 +131,47 @@ public static function watch(
return FileWatcher::watch($path, $onChange, $durationSeconds, $intervalMilliseconds, $recursive);
}
- /**
- * Get a file compression handler for this path.
- *
- * @param bool $create If true, create a new ZIP archive if it doesn't exist.
- * @return FileCompression The file compression instance.
- */
public function compression(bool $create = false): FileCompression
{
return new FileCompression($this->path, $create);
}
- /**
- * Get a directory operations handler for this path.
- *
- * @return DirectoryOperations The directory operations instance.
- */
public function directory(): DirectoryOperations
{
return new DirectoryOperations($this->path);
}
- /**
- * Check if the file or directory exists.
- *
- * @return bool True if the path exists, false otherwise.
- */
public function exists(): bool
{
return FlysystemHelper::has($this->path);
}
- /**
- * Get a file operations handler for this path.
- *
- * @return FileOperations The file operations instance.
- */
public function file(): FileOperations
{
return new FileOperations($this->path);
}
- /**
- * Get metadata for this file or directory.
- *
- * @param bool $humanReadableSize If true, return size in human-readable format.
- * @return array|null The metadata array, or null if the path doesn't exist.
- */
+ /** @return array|null */
public function metadata(bool $humanReadableSize = false): ?array
{
return self::normalizeStringMap(MetadataHelper::getAllMetadata($this->path, $humanReadableSize));
}
- /**
- * Get the MIME type of this file.
- *
- * @return string|null The MIME type, or null if not a file.
- */
public function mimeType(): ?string
{
return MetadataHelper::getMimeType($this->path);
}
- /**
- * Get the normalized path.
- *
- * @return string The normalized path.
- */
public function path(): string
{
return $this->path;
}
- /**
- * Get a safe file reader for this path.
- *
- * @param string $mode The file mode to open with. Defaults to 'r'.
- * @return SafeFileReader The file reader instance.
- */
public function reader(string $mode = 'r', ?int $lockType = null): SafeFileReader
{
return new SafeFileReader($this->path, $mode, $lockType);
}
- /**
- * Get a safe file writer for this path.
- *
- * @param bool $append If true, append to existing file. Defaults to false.
- * @return SafeFileWriter The file writer instance.
- */
public function writer(bool $append = false): SafeFileWriter
{
return new SafeFileWriter($this->path, $append);
@@ -341,11 +189,9 @@ private static function normalizeStringMap(?array $values): ?array
$result = [];
foreach ($values as $key => $value) {
- if (!is_string($key)) {
- continue;
+ if (is_string($key)) {
+ $result[$key] = $value;
}
-
- $result[$key] = $value;
}
return $result;
diff --git a/src/Queue/FileJobQueue.php b/src/Queue/FileJobQueue.php
index eaf44a97..be5722ea 100644
--- a/src/Queue/FileJobQueue.php
+++ b/src/Queue/FileJobQueue.php
@@ -6,7 +6,6 @@
use Infocyph\Pathwise\Exceptions\QueueException;
use Infocyph\Pathwise\Results\QueueProcessResult;
-
use Infocyph\Pathwise\Utils\FlysystemHelper;
use Infocyph\Pathwise\Utils\PathHelper;
use InvalidArgumentException;
@@ -20,10 +19,12 @@
* priority: int,
* createdAt: int,
* reservedAt?: int,
+ * leaseToken?: string,
* error?: string,
* failedAt?: int
* }
* @phpstan-type QueueState array{
+ * version: int,
* pending: list,
* processing: list,
* failed: list
@@ -31,11 +32,17 @@
*/
final readonly class FileJobQueue
{
+ private const int ERROR_MESSAGE_BYTES = 4096;
+
+ private const int STATE_VERSION = 1;
+
+ private FileQueueStateStore $stateStore;
+
public function __construct(
private string $queueFilePath,
private int $reservationTimeout = 300,
private int $maxJobs = 10_000,
- private int $maxQueueBytes = 16_777_216,
+ int $maxQueueBytes = 16_777_216,
private int $maxPayloadBytes = 1_048_576,
) {
if (!$this->isLocalQueuePath()) {
@@ -49,20 +56,24 @@ public function __construct(
) {
throw new InvalidArgumentException('Queue limits and reservation timeout must be positive integers.');
}
- $directory = dirname($this->queueFilePath);
- if (!FlysystemHelper::directoryExists($directory)) {
- FlysystemHelper::createDirectory($directory);
- }
- $this->initializeLocalQueue();
+
+ $this->stateStore = new FileQueueStateStore($this->queueFilePath, $maxQueueBytes);
+ $this->stateStore->initialize($this->encodeQueueData($this->emptyQueueData()));
+ $this->decodeQueueData($this->stateStore->read());
+ }
+
+ public function acknowledge(QueueReservation $reservation): void
+ {
+ $this->mutateQueueData(function (array $data) use ($reservation): array {
+ $index = $this->processingIndexForLease($data, $reservation);
+ array_splice($data['processing'], $index, 1);
+
+ return [$data, null];
+ });
}
/**
- * Add a job to the queue.
- *
- * @param string $type The job type.
- * @param array $payload The job payload data.
- * @param int $priority The job priority (higher is more important).
- * @return string The job ID.
+ * @param array $payload
*/
public function enqueue(string $type, array $payload = [], int $priority = 0): string
{
@@ -73,22 +84,13 @@ public function enqueue(string $type, array $payload = [], int $priority = 0): s
throw new InvalidArgumentException('Queue payload keys must be strings.');
}
$payload = $this->normalizePayload($payload);
-
- try {
- $payloadBytes = strlen(json_encode($payload, JSON_THROW_ON_ERROR));
- } catch (JsonException $exception) {
- throw new QueueException('Queue payload cannot be encoded as JSON.', 0, $exception);
- }
- if ($payloadBytes > $this->maxPayloadBytes) {
- throw new QueueException('Queue payload exceeds the configured size limit.');
- }
-
- $jobId = 'job_' . bin2hex(random_bytes(16));
+ $jobId = $this->newOpaqueId('job');
return $this->mutateQueueData(function (array $data) use ($jobId, $type, $payload, $priority): array {
if ($this->jobCount($data) >= $this->maxJobs) {
throw new QueueException('Queue exceeds the configured job-count limit.');
}
+
$data['pending'][] = [
'id' => $jobId,
'type' => $type,
@@ -96,18 +98,34 @@ public function enqueue(string $type, array $payload = [], int $priority = 0): s
'priority' => $priority,
'createdAt' => time(),
];
-
- usort($data['pending'], static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']);
+ $this->sortPending($data);
return [$data, $jobId];
});
}
+ public function fail(QueueReservation $reservation, \Throwable|string $failure): void
+ {
+ $message = $failure instanceof \Throwable ? $failure->getMessage() : $failure;
+ $message = trim($message) === '' ? 'Queue job failed.' : $message;
+ $message = substr($message, 0, self::ERROR_MESSAGE_BYTES);
+
+ $this->mutateQueueData(function (array $data) use ($reservation, $message): array {
+ $index = $this->processingIndexForLease($data, $reservation);
+ $job = $data['processing'][$index];
+ array_splice($data['processing'], $index, 1);
+
+ unset($job['reservedAt'], $job['leaseToken']);
+ $job['error'] = $message;
+ $job['failedAt'] = time();
+ $data['failed'][] = $job;
+
+ return [$data, null];
+ });
+ }
+
/**
- * Process jobs from the queue.
- *
- * @param callable(QueueJob): void $handler Callback to process each job.
- * @param int $maxJobs Maximum number of jobs to process (0 for unlimited).
+ * @param callable(QueueReservation): void $handler
*/
public function process(callable $handler, int $maxJobs = 0): QueueProcessResult
{
@@ -120,35 +138,66 @@ public function process(callable $handler, int $maxJobs = 0): QueueProcessResult
$attempted = 0;
while ($maxJobs === 0 || $attempted < $maxJobs) {
- $job = $this->claimNextJob();
- if ($job === null) {
+ $reservation = $this->reserve();
+ if (!$reservation instanceof QueueReservation) {
break;
}
$attempted++;
try {
- $handler($job);
- $processed++;
- } catch (\Throwable $e) {
- $job['error'] = substr($e->getMessage(), 0, 4096);
- $job['failedAt'] = time();
+ $handler($reservation);
+ } catch (\Throwable $exception) {
+ $this->fail($reservation, $exception);
$failed++;
+
+ continue;
}
- $this->completeJob($job);
+ $this->acknowledge($reservation);
+ $processed++;
}
return new QueueProcessResult($processed, $failed);
}
+ public function release(QueueReservation $reservation): void
+ {
+ $this->mutateQueueData(function (array $data) use ($reservation): array {
+ $index = $this->processingIndexForLease($data, $reservation);
+ $job = $data['processing'][$index];
+ array_splice($data['processing'], $index, 1);
+
+ unset($job['reservedAt'], $job['leaseToken']);
+ $data['pending'][] = $job;
+ $this->sortPending($data);
+
+ return [$data, null];
+ });
+ }
+
+ public function renew(QueueReservation $reservation): QueueReservation
+ {
+ return $this->mutateQueueData(function (array $data) use ($reservation): array {
+ $index = $this->processingIndexForLease($data, $reservation);
+ $job = $data['processing'][$index];
+ $job['reservedAt'] = time();
+ $data['processing'][$index] = $job;
+
+ return [$data, $this->reservationFromJob($job)];
+ });
+ }
+
+ public function reserve(): ?QueueReservation
+ {
+ return $this->mutateQueueData($this->reserveFromQueueState(...));
+ }
+
/**
- * Get queue statistics.
- *
* @return array{pending: int, processing: int, failed: int, file: string}
*/
public function stats(): array
{
- $data = $this->readQueueData();
+ $data = $this->decodeQueueData($this->stateStore->read());
return [
'pending' => count($data['pending']),
@@ -158,64 +207,41 @@ public function stats(): array
];
}
- /**
- * @param QueueState $data
- * @return array{0: QueueState, 1: QueueJob|null}
- */
- private function claimFromQueueState(array $data): array
+ /** @param array $value */
+ private function assertNoFailureState(array $value): void
{
- $data = $this->reclaimStaleReservations($data);
- if ($data['pending'] === []) {
- return [$data, null];
+ if (isset($value['error']) || isset($value['failedAt'])) {
+ throw new QueueException('Pending queue job contains failure state.');
}
-
- $job = array_shift($data['pending']);
- $job['reservedAt'] = time();
- $data['processing'][] = $job;
-
- return [$data, $job];
}
- /**
- * @return QueueJob|null
- */
- private function claimNextJob(): ?array
+ /** @param array $value */
+ private function assertNoReservationState(array $value): void
{
- return $this->mutateQueueData($this->claimFromQueueState(...));
+ if (isset($value['reservedAt']) || isset($value['leaseToken'])) {
+ throw new QueueException('Non-processing queue job contains reservation state.');
+ }
}
- /**
- * @param QueueJob $job
- */
- private function completeJob(array $job): void
+ /** @param QueueState $data */
+ private function assertUniqueJobIds(array $data): void
{
- $this->mutateQueueData(static function (array $data) use ($job): array {
- foreach ($data['processing'] as $index => $processingJob) {
- if ($processingJob['id'] !== $job['id']) {
- continue;
+ $seen = [];
+ foreach (['pending', 'processing', 'failed'] as $bucket) {
+ foreach ($data[$bucket] as $job) {
+ if (isset($seen[$job['id']])) {
+ throw new QueueException("Queue contains duplicate job identifier: {$job['id']}");
}
-
- array_splice($data['processing'], $index, 1);
-
- break;
- }
-
- unset($job['reservedAt']);
- if (isset($job['error'])) {
- $data['failed'][] = $job;
+ $seen[$job['id']] = true;
}
-
- return [$data, null];
- });
+ }
}
- /**
- * @return QueueState
- */
+ /** @return QueueState */
private function decodeQueueData(string $content): array
{
if ($content === '') {
- return $this->emptyQueueData();
+ throw new QueueException("Queue file is empty or truncated: {$this->queueFilePath}");
}
try {
@@ -227,67 +253,48 @@ private function decodeQueueData(string $content): array
if (!is_array($decoded)) {
throw new QueueException("Queue file does not contain an object: {$this->queueFilePath}");
}
+ if (($decoded['version'] ?? null) !== self::STATE_VERSION) {
+ throw new QueueException('Queue state version is missing or unsupported.');
+ }
+ foreach (['pending', 'processing', 'failed'] as $bucket) {
+ if (!array_key_exists($bucket, $decoded)) {
+ throw new QueueException("Queue state is missing the {$bucket} bucket.");
+ }
+ }
$state = [
- 'pending' => $this->normalizeJobList($decoded['pending'] ?? []),
- 'processing' => $this->normalizeJobList($decoded['processing'] ?? []),
- 'failed' => $this->normalizeJobList($decoded['failed'] ?? []),
+ 'version' => self::STATE_VERSION,
+ 'pending' => $this->normalizeJobList($decoded['pending'], 'pending'),
+ 'processing' => $this->normalizeJobList($decoded['processing'], 'processing'),
+ 'failed' => $this->normalizeJobList($decoded['failed'], 'failed'),
];
if ($this->jobCount($state) > $this->maxJobs) {
throw new QueueException('Queue exceeds the configured job-count limit.');
}
+ $this->assertUniqueJobIds($state);
return $state;
}
- /**
- * @return QueueState
- */
+ /** @return QueueState */
private function emptyQueueData(): array
{
- return ['pending' => [], 'processing' => [], 'failed' => []];
+ return [
+ 'version' => self::STATE_VERSION,
+ 'pending' => [],
+ 'processing' => [],
+ 'failed' => [],
+ ];
}
- /**
- * @param QueueState $data
- */
+ /** @param QueueState $data */
private function encodeQueueData(array $data): string
{
try {
- $encoded = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
+ return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException $exception) {
throw new QueueException('Queue data cannot be encoded as JSON.', 0, $exception);
}
- if (strlen($encoded) > $this->maxQueueBytes) {
- throw new QueueException('Queue exceeds the configured byte-size limit.');
- }
-
- return $encoded;
- }
-
- private function initializeLocalQueue(): void
- {
- $stream = fopen($this->queueFilePath, 'c+b');
- if (!is_resource($stream)) {
- throw new QueueException("Unable to initialize queue file: {$this->queueFilePath}");
- }
-
- try {
- if (!flock($stream, LOCK_EX)) {
- throw new QueueException("Unable to lock queue file: {$this->queueFilePath}");
- }
-
- $metadata = fstat($stream);
- if (!is_array($metadata) || $metadata['size'] !== 0) {
- return;
- }
-
- $this->writeFully($stream, $this->encodeQueueData($this->emptyQueueData()));
- fflush($stream);
- } finally {
- flock($stream, LOCK_UN);
- fclose($stream);
- }
}
private function isLocalQueuePath(): bool
@@ -302,6 +309,18 @@ private function jobCount(array $data): int
return count($data['pending']) + count($data['processing']) + count($data['failed']);
}
+ /**
+ * @param QueueJob $job
+ * @return QueueJob
+ */
+ private function leaseJob(array $job): array
+ {
+ $job['reservedAt'] = time();
+ $job['leaseToken'] = $this->newOpaqueId('lease');
+
+ return $job;
+ }
+
/**
* @template T
* @param callable(QueueState): array{0: QueueState, 1: T} $mutation
@@ -309,109 +328,104 @@ private function jobCount(array $data): int
*/
private function mutateQueueData(callable $mutation): mixed
{
- $stream = fopen($this->queueFilePath, 'c+b');
- if (!is_resource($stream)) {
- throw new QueueException("Unable to open queue file: {$this->queueFilePath}");
- }
-
- try {
- if (!flock($stream, LOCK_EX)) {
- throw new QueueException("Unable to lock queue file: {$this->queueFilePath}");
- }
-
- rewind($stream);
- $content = stream_get_contents($stream);
- [$data, $result] = $mutation($this->decodeQueueData(is_string($content) ? $content : ''));
- $encoded = $this->encodeQueueData($data);
+ return $this->stateStore->mutate(function (string $content) use ($mutation): array {
+ [$data, $result] = $mutation($this->decodeQueueData($content));
- rewind($stream);
- if (!ftruncate($stream, 0)) {
- throw new QueueException("Unable to truncate queue file: {$this->queueFilePath}");
- }
- $this->writeFully($stream, $encoded);
- if (!fflush($stream)) {
- throw new QueueException("Unable to flush queue file: {$this->queueFilePath}");
- }
+ return [$this->encodeQueueData($data), $result];
+ });
+ }
- return $result;
- } finally {
- flock($stream, LOCK_UN);
- fclose($stream);
+ private function newOpaqueId(string $prefix): string
+ {
+ try {
+ return $prefix . '_' . bin2hex(random_bytes(16));
+ } catch (\Throwable $exception) {
+ throw new QueueException("Unable to generate {$prefix} identifier.", 0, $exception);
}
}
- /** @return QueueJob */
- private function normalizeJob(mixed $value): array
+ /**
+ * @param array $value
+ * @return QueueJob
+ */
+ private function normalizeBaseJob(array $value): array
{
- if (!is_array($value)) {
- throw new QueueException('Queue contains a malformed job.');
+ $id = $value['id'] ?? null;
+ if (!is_string($id) || preg_match('/^job_[a-f0-9]{32}$/D', $id) !== 1) {
+ throw new QueueException('Queue contains a malformed job identifier.');
}
- $id = $value['id'] ?? null;
$type = $value['type'] ?? null;
- $payload = $this->normalizePayload($value['payload'] ?? null);
$priority = $value['priority'] ?? null;
$createdAt = $value['createdAt'] ?? null;
- if (!is_string($id) || trim($id) === '' || !is_string($type) || trim($type) === '') {
+ if (!is_string($type) || trim($type) === '' || !is_int($priority) || !is_int($createdAt) || $createdAt < 0) {
throw new QueueException('Queue contains a malformed job.');
}
- if (!is_int($priority) || !is_int($createdAt) || $createdAt < 0) {
- throw new QueueException('Queue contains a malformed job.');
- }
-
- $job = [
+ return [
'id' => $id,
'type' => $type,
- 'payload' => $payload,
+ 'payload' => $this->normalizePayload($value['payload'] ?? null),
'priority' => $priority,
'createdAt' => $createdAt,
];
+ }
+ /**
+ * @param QueueJob $job
+ * @param array $value
+ * @return QueueJob
+ */
+ private function normalizeFailedJob(array $job, array $value): array
+ {
$error = $value['error'] ?? null;
- if (is_string($error) && $error !== '') {
- $job['error'] = $error;
- }
-
$failedAt = $value['failedAt'] ?? null;
- if ($failedAt !== null) {
- if (!is_int($failedAt) || $failedAt < 0) {
- throw new QueueException('Queue contains a malformed failure timestamp.');
- }
- $job['failedAt'] = $failedAt;
+ if (!is_string($error) || trim($error) === '' || !is_int($failedAt) || $failedAt < 1) {
+ throw new QueueException('Queue contains malformed failure state.');
}
- $reservedAt = $value['reservedAt'] ?? null;
- if ($reservedAt !== null) {
- if (!is_int($reservedAt) || $reservedAt < 0) {
- throw new QueueException('Queue contains a malformed reservation timestamp.');
- }
- $job['reservedAt'] = $reservedAt;
+ if (strlen($error) > self::ERROR_MESSAGE_BYTES) {
+ throw new QueueException('Queue failure message exceeds the configured state limit.');
}
+ $job['error'] = $error;
+ $job['failedAt'] = $failedAt;
+
return $job;
}
- /**
- * @return list
- */
- private function normalizeJobList(mixed $value): array
+ /** @return QueueJob */
+ private function normalizeJob(mixed $value, string $bucket): array
{
if (!is_array($value)) {
- throw new QueueException('Queue job list must be an array.');
+ throw new QueueException('Queue contains a malformed job.');
}
- $jobs = [];
- foreach ($value as $rawJob) {
- $job = $this->normalizeJob($rawJob);
- $jobs[] = $job;
+ $job = $this->normalizeBaseJob($value);
+ if ($bucket === 'processing') {
+ return $this->normalizeProcessingJob($job, $value);
}
- return $jobs;
+ $this->assertNoReservationState($value);
+ if ($bucket === 'failed') {
+ return $this->normalizeFailedJob($job, $value);
+ }
+
+ $this->assertNoFailureState($value);
+
+ return $job;
}
- /**
- * @return array
- */
+ /** @return list */
+ private function normalizeJobList(mixed $value, string $bucket): array
+ {
+ if (!is_array($value) || !array_is_list($value)) {
+ throw new QueueException("Queue {$bucket} bucket must be a list.");
+ }
+
+ return array_map(fn(mixed $job): array => $this->normalizeJob($job, $bucket), $value);
+ }
+
+ /** @return array */
private function normalizePayload(mixed $value): array
{
if (!is_array($value)) {
@@ -423,7 +437,6 @@ private function normalizePayload(mixed $value): array
if (!is_string($key)) {
throw new QueueException('Queue payload keys must be strings.');
}
-
$payload[$key] = $item;
}
@@ -440,34 +453,48 @@ private function normalizePayload(mixed $value): array
}
/**
- * @return QueueState
+ * @param QueueJob $job
+ * @param array $value
+ * @return QueueJob
*/
- private function readQueueData(): array
+ private function normalizeProcessingJob(array $job, array $value): array
{
- if (!FlysystemHelper::fileExists($this->queueFilePath)) {
- return $this->emptyQueueData();
+ $reservedAt = $value['reservedAt'] ?? null;
+ $leaseToken = $value['leaseToken'] ?? null;
+ if (!is_int($reservedAt) || $reservedAt < 1) {
+ throw new QueueException('Queue contains a malformed reservation timestamp.');
}
-
- $stream = fopen($this->queueFilePath, 'rb');
- if (!is_resource($stream)) {
- throw new QueueException("Unable to open queue file: {$this->queueFilePath}");
+ if (!is_string($leaseToken) || preg_match('/^lease_[a-f0-9]{32}$/D', $leaseToken) !== 1) {
+ throw new QueueException('Queue contains a malformed reservation lease token.');
+ }
+ if (isset($value['error']) || isset($value['failedAt'])) {
+ throw new QueueException('Processing queue job contains failure state.');
}
- try {
- if (!flock($stream, LOCK_SH)) {
- throw new QueueException("Unable to lock queue file: {$this->queueFilePath}");
- }
+ $job['reservedAt'] = $reservedAt;
+ $job['leaseToken'] = $leaseToken;
- $content = stream_get_contents($stream);
- if (is_string($content) && strlen($content) > $this->maxQueueBytes) {
- throw new QueueException('Queue exceeds the configured byte-size limit.');
+ return $job;
+ }
+
+ /** @param QueueState $data */
+ private function processingIndexForLease(array $data, QueueReservation $reservation): int
+ {
+ foreach ($data['processing'] as $index => $job) {
+ if ($job['id'] !== $reservation->id) {
+ continue;
+ }
+ if (($job['leaseToken'] ?? null) !== $reservation->leaseToken) {
+ throw new QueueException('Queue reservation lease is stale or no longer owned.');
+ }
+ if (($job['reservedAt'] ?? 0) <= time() - $this->reservationTimeout) {
+ throw new QueueException('Queue reservation lease is stale or no longer owned.');
}
- return $this->decodeQueueData(is_string($content) ? $content : '');
- } finally {
- flock($stream, LOCK_UN);
- fclose($stream);
+ return $index;
}
+
+ throw new QueueException('Queue reservation lease is stale or no longer owned.');
}
/**
@@ -478,37 +505,66 @@ private function reclaimStaleReservations(array $data): array
{
$cutoff = time() - $this->reservationTimeout;
$active = [];
+
foreach ($data['processing'] as $job) {
- $reservedAt = $job['reservedAt'] ?? 0;
- if ($reservedAt > $cutoff) {
+ if (($job['reservedAt'] ?? 0) > $cutoff) {
$active[] = $job;
continue;
}
- unset($job['reservedAt']);
+ unset($job['reservedAt'], $job['leaseToken']);
$data['pending'][] = $job;
}
+
$data['processing'] = $active;
- usort($data['pending'], static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']);
+ $this->sortPending($data);
return $data;
}
- private function writeFully(mixed $stream, string $contents): void
+ /** @param QueueJob $job */
+ private function reservationFromJob(array $job): QueueReservation
{
- if (!is_resource($stream)) {
- throw new QueueException('Invalid queue stream.');
- }
+ $reservedAt = $job['reservedAt'] ?? null;
+ $leaseToken = $job['leaseToken'] ?? null;
+ if (!is_int($reservedAt) || !is_string($leaseToken)) {
+ throw new QueueException('Processing queue job is missing lease ownership state.');
+ }
+
+ return new QueueReservation(
+ id: $job['id'],
+ leaseToken: $leaseToken,
+ type: $job['type'],
+ payload: $job['payload'],
+ priority: $job['priority'],
+ createdAt: $job['createdAt'],
+ reservedAt: $reservedAt,
+ expiresAt: $reservedAt + $this->reservationTimeout,
+ );
+ }
- $offset = 0;
- $length = strlen($contents);
- while ($offset < $length) {
- $written = fwrite($stream, substr($contents, $offset));
- if (!is_int($written) || $written < 1) {
- throw new QueueException("Unable to write queue file: {$this->queueFilePath}");
- }
- $offset += $written;
+ /**
+ * @param QueueState $data
+ * @return array{0: QueueState, 1: QueueReservation|null}
+ */
+ private function reserveFromQueueState(array $data): array
+ {
+ $data = $this->reclaimStaleReservations($data);
+ if ($data['pending'] === []) {
+ return [$data, null];
}
+
+ $job = $this->leaseJob($data['pending'][0]);
+ array_shift($data['pending']);
+ $data['processing'][] = $job;
+
+ return [$data, $this->reservationFromJob($job)];
+ }
+
+ /** @param QueueState $data */
+ private function sortPending(array &$data): void
+ {
+ usort($data['pending'], static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']);
}
}
diff --git a/src/Queue/FileQueueStateStore.php b/src/Queue/FileQueueStateStore.php
new file mode 100644
index 00000000..9583dc96
--- /dev/null
+++ b/src/Queue/FileQueueStateStore.php
@@ -0,0 +1,263 @@
+ensurePrivateQueueDirectory();
+ }
+
+ public function initialize(string $initialState): void
+ {
+ $this->withExclusiveLock(function () use ($initialState): void {
+ $this->cleanupOrphanTemps();
+ if (!file_exists($this->queueFilePath)) {
+ $this->persistState($initialState);
+
+ return;
+ }
+
+ $this->readStateUnlocked();
+ });
+ }
+
+ /**
+ * @template T
+ * @param callable(string): array{0: string, 1: T} $mutation
+ * @return T
+ */
+ public function mutate(callable $mutation): mixed
+ {
+ return $this->withExclusiveLock(function () use ($mutation): mixed {
+ [$state, $result] = $mutation($this->readStateUnlocked());
+ $this->persistState($state);
+
+ return $result;
+ });
+ }
+
+ public function read(): string
+ {
+ return $this->withSharedLock(fn(): string => $this->readStateUnlocked());
+ }
+
+ private function cleanupOrphanTemps(): void
+ {
+ $directory = dirname($this->queueFilePath);
+ $prefix = basename($this->queueFilePath) . '.tmp.';
+
+ foreach (new DirectoryIterator($directory) as $entry) {
+ if ($entry->isDot() || !str_starts_with($entry->getFilename(), $prefix)) {
+ continue;
+ }
+ if ($entry->isLink()) {
+ throw new QueueException("Queue temporary path is unexpectedly a symbolic link: {$entry->getPathname()}");
+ }
+ if (!$entry->isFile()) {
+ continue;
+ }
+ if (!unlink($entry->getPathname())) {
+ throw new QueueException("Unable to remove orphan queue temporary file: {$entry->getPathname()}");
+ }
+ }
+ }
+
+ private function ensurePrivateQueueDirectory(): void
+ {
+ $directory = dirname($this->queueFilePath);
+ if (is_dir($directory)) {
+ return;
+ }
+
+ if (!mkdir($directory, 0700, true) && !is_dir($directory)) {
+ throw new QueueException("Unable to create queue directory: {$directory}");
+ }
+ if (!chmod($directory, 0700)) {
+ throw new QueueException("Unable to secure queue directory: {$directory}");
+ }
+ }
+
+ private function lockFilePath(): string
+ {
+ return $this->queueFilePath . '.lock';
+ }
+
+ private function newTempPath(): string
+ {
+ try {
+ return $this->queueFilePath . '.tmp.state_' . bin2hex(random_bytes(16));
+ } catch (\Throwable $exception) {
+ throw new QueueException('Unable to generate queue temporary-file identifier.', 0, $exception);
+ }
+ }
+
+ /** @return resource */
+ private function openLockStream(): mixed
+ {
+ $lockPath = $this->lockFilePath();
+ if (is_link($lockPath)) {
+ throw new QueueException("Queue lock path must not be a symbolic link: {$lockPath}");
+ }
+
+ $stream = fopen($lockPath, 'c+b');
+ if (!is_resource($stream)) {
+ throw new QueueException("Unable to open queue lock file: {$lockPath}");
+ }
+ if (!chmod($lockPath, 0600)) {
+ fclose($stream);
+
+ throw new QueueException("Unable to secure queue lock file: {$lockPath}");
+ }
+
+ return $stream;
+ }
+
+ private function persistState(string $state): void
+ {
+ if (strlen($state) > $this->maxQueueBytes) {
+ throw new QueueException('Queue exceeds the configured byte-size limit.');
+ }
+ if (is_link($this->queueFilePath)) {
+ throw new QueueException("Queue state path must not be a symbolic link: {$this->queueFilePath}");
+ }
+ if (file_exists($this->queueFilePath) && !is_file($this->queueFilePath)) {
+ throw new QueueException("Queue state path is not a regular file: {$this->queueFilePath}");
+ }
+
+ $tempPath = $this->newTempPath();
+ $stream = fopen($tempPath, 'x+b');
+ if (!is_resource($stream)) {
+ throw new QueueException("Unable to create queue temporary file: {$tempPath}");
+ }
+
+ try {
+ if (!chmod($tempPath, 0600)) {
+ throw new QueueException("Unable to secure queue temporary file: {$tempPath}");
+ }
+ $this->writeFully($stream, $state);
+ if (!fflush($stream) || !fsync($stream)) {
+ throw new QueueException("Unable to durably flush queue temporary file: {$tempPath}");
+ }
+ } finally {
+ fclose($stream);
+ }
+
+ try {
+ if (!rename($tempPath, $this->queueFilePath)) {
+ throw new QueueException("Unable to atomically replace queue state: {$this->queueFilePath}");
+ }
+ if (!chmod($this->queueFilePath, 0600)) {
+ throw new QueueException("Unable to secure queue state file: {$this->queueFilePath}");
+ }
+ } finally {
+ if (is_file($tempPath)) {
+ unlink($tempPath);
+ }
+ }
+ }
+
+ private function readStateUnlocked(): string
+ {
+ if (!file_exists($this->queueFilePath)) {
+ throw new QueueException("Queue state file is missing: {$this->queueFilePath}");
+ }
+ if (is_link($this->queueFilePath) || !is_file($this->queueFilePath)) {
+ throw new QueueException("Queue state path is not a regular file: {$this->queueFilePath}");
+ }
+
+ clearstatcache(true, $this->queueFilePath);
+ $size = filesize($this->queueFilePath);
+ if (!is_int($size)) {
+ throw new QueueException("Unable to inspect queue state file: {$this->queueFilePath}");
+ }
+ if ($size < 1) {
+ throw new QueueException("Queue file is empty or truncated: {$this->queueFilePath}");
+ }
+ if ($size > $this->maxQueueBytes) {
+ throw new QueueException('Queue exceeds the configured byte-size limit.');
+ }
+
+ $content = file_get_contents($this->queueFilePath);
+ if (!is_string($content) || strlen($content) !== $size) {
+ throw new QueueException("Unable to read complete queue state: {$this->queueFilePath}");
+ }
+
+ return $content;
+ }
+
+ /**
+ * @template T
+ * @param callable(): T $operation
+ * @return T
+ */
+ private function withExclusiveLock(callable $operation): mixed
+ {
+ $stream = $this->openLockStream();
+
+ try {
+ if (!flock($stream, LOCK_EX)) {
+ throw new QueueException("Unable to acquire queue lock: {$this->lockFilePath()}");
+ }
+
+ return $operation();
+ } finally {
+ flock($stream, LOCK_UN);
+ fclose($stream);
+ }
+ }
+
+ /**
+ * @template T
+ * @param callable(): T $operation
+ * @return T
+ */
+ private function withSharedLock(callable $operation): mixed
+ {
+ $stream = $this->openLockStream();
+
+ try {
+ if (!flock($stream, LOCK_SH)) {
+ throw new QueueException("Unable to acquire queue lock: {$this->lockFilePath()}");
+ }
+
+ return $operation();
+ } finally {
+ flock($stream, LOCK_UN);
+ fclose($stream);
+ }
+ }
+
+ private function writeFully(mixed $stream, string $contents): void
+ {
+ if (!is_resource($stream)) {
+ throw new QueueException('Invalid queue stream.');
+ }
+
+ $offset = 0;
+ $length = strlen($contents);
+ while ($offset < $length) {
+ $written = fwrite($stream, substr($contents, $offset));
+ if (!is_int($written) || $written < 1) {
+ throw new QueueException("Unable to write queue file: {$this->queueFilePath}");
+ }
+ $offset += $written;
+ }
+ }
+}
diff --git a/src/Queue/QueueReservation.php b/src/Queue/QueueReservation.php
new file mode 100644
index 00000000..17a50cd1
--- /dev/null
+++ b/src/Queue/QueueReservation.php
@@ -0,0 +1,25 @@
+ $payload
+ */
+ public function __construct(
+ public string $id,
+ public string $leaseToken,
+ public string $type,
+ public array $payload,
+ public int $priority,
+ public int $createdAt,
+ public int $reservedAt,
+ public int $expiresAt,
+ ) {}
+}
diff --git a/src/Results/NativeExecutionResult.php b/src/Results/NativeExecutionResult.php
index 9e270a2a..f043faa5 100644
--- a/src/Results/NativeExecutionResult.php
+++ b/src/Results/NativeExecutionResult.php
@@ -4,13 +4,22 @@
namespace Infocyph\Pathwise\Results;
+use Infocyph\Pathwise\Native\NativeExecutionFailure;
+
final readonly class NativeExecutionResult
{
- /** @param list $output */
+ /**
+ * @param list $output
+ * @param list $stdout
+ * @param list $stderr
+ */
public function __construct(
public bool $success,
public string $command,
public int $exitCode,
public array $output = [],
+ public ?NativeExecutionFailure $failure = null,
+ public array $stdout = [],
+ public array $stderr = [],
) {}
}
diff --git a/src/Results/SymlinkStatus.php b/src/Results/SymlinkStatus.php
new file mode 100644
index 00000000..8438732c
--- /dev/null
+++ b/src/Results/SymlinkStatus.php
@@ -0,0 +1,17 @@
+ $b[$sortBy] <=> $a[$sortBy]);
-
- $kept = [];
- $deleted = [];
- $cutoff = $maxAgeDays !== null ? (time() - ($maxAgeDays * 86400)) : null;
-
- foreach ($files as $index => $file) {
- $shouldDeleteByCount = $keepLast !== null && $index >= $keepLast;
- $shouldDeleteByAge = $cutoff !== null && $file[$sortBy] < $cutoff;
- $path = $file['path'];
-
- if (($shouldDeleteByCount || $shouldDeleteByAge) && FlysystemHelper::fileExists($path)) {
- FlysystemHelper::delete($path);
- $deleted[] = $path;
- } else {
- $kept[] = $path;
- }
- }
-
- return new RetentionResult($deleted, $kept);
+ return self::evaluate($directory, $keepLast, $maxAgeDays, $sortBy, true);
}
/**
- * @return array
+ * Return the exact retention decision without deleting files.
*/
+ public static function preview(
+ string $directory,
+ ?int $keepLast = null,
+ ?int $maxAgeDays = null,
+ string $sortBy = 'mtime',
+ ): RetentionResult {
+ return self::evaluate($directory, $keepLast, $maxAgeDays, $sortBy, false);
+ }
+
+ /** @return array */
private static function collectFiles(string $directory): array
{
if (PathHelper::hasScheme($directory) || (FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($directory))) {
@@ -73,9 +44,7 @@ private static function collectFiles(string $directory): array
return self::collectFilesLocal($directory);
}
- /**
- * @return array
- */
+ /** @return array */
private static function collectFilesLocal(string $directory): array
{
$files = [];
@@ -90,9 +59,7 @@ private static function collectFilesLocal(string $directory): array
return $files;
}
- /**
- * @return array
- */
+ /** @return array */
private static function collectFilesViaFlysystem(string $directory): array
{
$files = [];
@@ -100,35 +67,116 @@ private static function collectFilesViaFlysystem(string $directory): array
foreach (FlysystemHelper::listContentsListing($directory, true) as $item) {
$entry = self::normalizeFlysystemEntry($directory, $base, $item);
- if ($entry === null) {
- continue;
+ if ($entry !== null) {
+ $files[] = $entry;
}
-
- $files[] = $entry;
}
return $files;
}
- /**
- * @return array{path: string, mtime: int, ctime: int|null}|null
- */
- private static function normalizeFlysystemEntry(string $directory, string $base, \League\Flysystem\StorageAttributes $item): ?array
+ /** @param list $paths */
+ private static function deletePaths(array $paths): void
{
+ foreach ($paths as $path) {
+ if (FlysystemHelper::fileExists($path)) {
+ FlysystemHelper::delete($path);
+ }
+ }
+ }
+
+ private static function evaluate(
+ string $directory,
+ ?int $keepLast,
+ ?int $maxAgeDays,
+ string $sortBy,
+ bool $delete,
+ ): RetentionResult {
+ self::validateOptions($keepLast, $maxAgeDays, $sortBy);
+ $directory = PathHelper::normalize($directory);
+
+ if ($sortBy === 'ctime' && !FlysystemHelper::isLocalPath($directory)) {
+ throw new InvalidArgumentException('ctime retention is unavailable for adapter-backed storage.');
+ }
+ if (!FlysystemHelper::directoryExists($directory)) {
+ return new RetentionResult([], []);
+ }
+
+ $decision = self::partitionFiles(
+ self::sortFiles(self::collectFiles($directory), $sortBy),
+ $keepLast,
+ $maxAgeDays,
+ $sortBy,
+ );
+ if ($delete) {
+ self::deletePaths($decision['deleted']);
+ }
+
+ return new RetentionResult($decision['deleted'], $decision['kept']);
+ }
+
+ /** @return array{path: string, mtime: int, ctime: int|null}|null */
+ private static function normalizeFlysystemEntry(
+ string $directory,
+ string $base,
+ \League\Flysystem\StorageAttributes $item,
+ ): ?array {
$relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file');
if ($relative === null) {
return null;
}
- $mtime = $item->lastModified() ?? 0;
-
return [
'path' => PathHelper::join($directory, $relative),
- 'mtime' => $mtime,
+ 'mtime' => $item->lastModified() ?? 0,
'ctime' => null,
];
}
+ /**
+ * @param array $files
+ * @return array{deleted: list, kept: list}
+ */
+ private static function partitionFiles(
+ array $files,
+ ?int $keepLast,
+ ?int $maxAgeDays,
+ string $sortBy,
+ ): array {
+ $deleted = [];
+ $kept = [];
+ $cutoff = $maxAgeDays !== null ? time() - ($maxAgeDays * 86400) : null;
+
+ foreach ($files as $index => $file) {
+ $deleteByCount = $keepLast !== null && $index >= $keepLast;
+ $deleteByAge = $cutoff !== null && $file[$sortBy] < $cutoff;
+ if ($deleteByCount || $deleteByAge) {
+ $deleted[] = $file['path'];
+
+ continue;
+ }
+
+ $kept[] = $file['path'];
+ }
+
+ return ['deleted' => $deleted, 'kept' => $kept];
+ }
+
+ /**
+ * @param array $files
+ * @return array
+ */
+ private static function sortFiles(array $files, string $sortBy): array
+ {
+ usort($files, static function (array $first, array $second) use ($sortBy): int {
+ $comparison = $second[$sortBy] <=> $first[$sortBy];
+
+ return $comparison !== 0 ? $comparison : strcmp($first['path'], $second['path']);
+ });
+
+ return $files;
+ }
+
private static function validateOptions(?int $keepLast, ?int $maxAgeDays, string $sortBy): void
{
if ($keepLast !== null && $keepLast < 0) {
diff --git a/src/Security/PolicyEngine.php b/src/Security/PolicyEngine.php
index 072909bc..ce8770ad 100644
--- a/src/Security/PolicyEngine.php
+++ b/src/Security/PolicyEngine.php
@@ -19,6 +19,12 @@ final class PolicyEngine
*/
private array $rules = [];
+ /**
+ * Policy evaluation is deny-by-default. Pass true only for an explicitly
+ * permissive policy where unmatched operations should be allowed.
+ */
+ public function __construct(private readonly bool $defaultAllow = false) {}
+
/**
* Allow an operation matching the given pattern.
*
@@ -80,11 +86,11 @@ public function deny(string $operation, string $pattern = '*', ?callable $condit
* @param string $operation The operation to check.
* @param string $path The path to check.
* @param array $context Additional context for condition evaluation.
- * Rules use last-match-wins precedence. Returns true when allowed.
+ * Rules use last-match-wins precedence.
*/
public function isAllowed(string $operation, string $path, array $context = []): bool
{
- $decision = true;
+ $decision = $this->defaultAllow;
$normalizedPath = str_replace('\\', '/', $path);
$caseInsensitive = PHP_OS_FAMILY === 'Windows' && !PathHelper::hasScheme($path);
if ($caseInsensitive) {
diff --git a/src/Security/ZipArchiveExtractor.php b/src/Security/ZipArchiveExtractor.php
new file mode 100644
index 00000000..3f379303
--- /dev/null
+++ b/src/Security/ZipArchiveExtractor.php
@@ -0,0 +1,299 @@
+ $entries
+ */
+ public static function extractToLocal(ZipArchive $archive, array $entries, string $root): void
+ {
+ $root = PathHelper::normalize($root);
+ if (PathHelper::hasScheme($root)) {
+ throw new CompressionException('Secure ZIP extraction requires a local staging directory.');
+ }
+
+ $rootExisted = is_dir($root);
+ if (is_link($root)) {
+ throw new UnsafeArchiveEntryException('ZIP extraction root must not be a symbolic link.');
+ }
+ if (!$rootExisted && !mkdir($root, 0755, true) && !is_dir($root)) {
+ throw new CompressionException("Unable to create ZIP extraction root: {$root}");
+ }
+
+ $journal = new FileTransactionJournal($root);
+ $createdDirectories = [];
+
+ try {
+ foreach ($entries as $entry) {
+ self::extractEntry($archive, $entry, $root, $journal, $createdDirectories);
+ }
+
+ $journal->commit();
+ } catch (\Throwable $exception) {
+ try {
+ $journal->rollback($exception);
+ } finally {
+ self::cleanupCreatedDirectories($createdDirectories, $root, $rootExisted);
+ }
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * @param list $createdDirectories
+ */
+ private static function cleanupCreatedDirectories(array $createdDirectories, string $root, bool $rootExisted): void
+ {
+ for ($index = count($createdDirectories) - 1; $index >= 0; $index--) {
+ $directory = $createdDirectories[$index];
+ if (is_dir($directory) && !is_link($directory)) {
+ self::runSilently(static fn(): bool => rmdir($directory));
+ }
+ }
+
+ if (!$rootExisted && is_dir($root) && !is_link($root)) {
+ self::runSilently(static fn(): bool => rmdir($root));
+ }
+ }
+
+ /**
+ * @param resource $input
+ * @param resource $output
+ */
+ private static function copyValidatedBytes(mixed $input, mixed $output, ZipArchiveManifestEntry $entry): void
+ {
+ $written = 0;
+ while (!feof($input)) {
+ $chunk = fread($input, self::COPY_BUFFER_BYTES);
+ if ($chunk === false) {
+ throw new CompressionException("Unable to read ZIP entry: {$entry->archiveName}");
+ }
+ if ($chunk === '') {
+ break;
+ }
+
+ $length = strlen($chunk);
+ if ($length > $entry->uncompressedBytes - $written) {
+ throw new UnsafeArchiveEntryException(
+ "ZIP entry expanded beyond validated size: {$entry->archiveName}",
+ );
+ }
+
+ self::writeChunk($output, $chunk, $entry->archiveName);
+ $written += $length;
+ }
+
+ if ($written !== $entry->uncompressedBytes) {
+ throw new UnsafeArchiveEntryException(
+ "ZIP entry expanded size does not match validated metadata: {$entry->archiveName}",
+ );
+ }
+ }
+
+ /**
+ * @param list $createdDirectories
+ */
+ private static function ensureDirectory(
+ string $root,
+ string $relative,
+ string $entry,
+ array &$createdDirectories,
+ ): void {
+ $relative = trim(str_replace('\\', '/', $relative), '/');
+ if ($relative === '') {
+ return;
+ }
+
+ $current = rtrim($root, '/\\');
+ foreach (explode('/', $relative) as $segment) {
+ $current .= DIRECTORY_SEPARATOR . $segment;
+ if (is_link($current)) {
+ throw new UnsafeArchiveEntryException("ZIP destination traverses a symbolic link: {$entry}");
+ }
+ if (file_exists($current) && !is_dir($current)) {
+ throw new UnsafeArchiveEntryException("ZIP directory conflicts with an existing file: {$entry}");
+ }
+ if (is_dir($current)) {
+ continue;
+ }
+ if (!mkdir($current, 0755) && !is_dir($current)) {
+ throw new CompressionException("Unable to create ZIP extraction directory for: {$entry}");
+ }
+
+ $createdDirectories[] = $current;
+ }
+ }
+
+ /**
+ * @param list $createdDirectories
+ */
+ private static function extractEntry(
+ ZipArchive $archive,
+ ZipArchiveManifestEntry $entry,
+ string $root,
+ FileTransactionJournal $journal,
+ array &$createdDirectories,
+ ): void {
+ $validatedPath = ZipEntryValidator::validate($entry->path, $root);
+ $relative = rtrim($validatedPath, '/');
+ $target = PathHelper::join($root, $relative);
+
+ if ($entry->directory) {
+ if (file_exists($target) && !is_dir($target)) {
+ throw new UnsafeArchiveEntryException("ZIP directory conflicts with an existing file: {$entry->archiveName}");
+ }
+
+ self::ensureDirectory($root, $relative, $entry->archiveName, $createdDirectories);
+
+ return;
+ }
+
+ self::ensureDirectory($root, dirname(str_replace('\\', '/', $relative)), $entry->archiveName, $createdDirectories);
+ ZipEntryValidator::validate($entry->path, $root);
+ if (is_link($target) || is_dir($target)) {
+ throw new UnsafeArchiveEntryException("ZIP file target is not a regular file path: {$entry->archiveName}");
+ }
+
+ $journal->record($target);
+ $temporary = self::temporarySibling($target);
+
+ try {
+ self::writeEntry($archive, $entry, $temporary);
+ ZipEntryValidator::validate($entry->path, $root);
+ if (is_dir($target)) {
+ throw new UnsafeArchiveEntryException("ZIP file target changed during extraction: {$entry->archiveName}");
+ }
+
+ self::replaceTarget($temporary, $target, $entry->archiveName);
+ } finally {
+ if (is_file($temporary) || is_link($temporary)) {
+ self::runSilently(static fn(): bool => unlink($temporary));
+ }
+ }
+ }
+
+ private static function removeExistingTarget(string $target, string $entry): void
+ {
+ self::runSilently(static fn(): bool => unlink($target));
+ clearstatcache(true, $target);
+ if (file_exists($target) || is_link($target)) {
+ throw new CompressionException("Unable to replace extracted ZIP entry: {$entry}");
+ }
+ }
+
+ private static function replaceTarget(string $temporary, string $target, string $entry): void
+ {
+ if (self::runSilently(static fn(): bool => rename($temporary, $target))) {
+ return;
+ }
+
+ if (!is_file($target)) {
+ throw new CompressionException("Unable to publish extracted ZIP entry: {$entry}");
+ }
+
+ self::removeExistingTarget($target, $entry);
+ if (self::runSilently(static fn(): bool => rename($temporary, $target))) {
+ return;
+ }
+
+ throw new CompressionException("Unable to publish extracted ZIP entry: {$entry}");
+ }
+
+ private static function runSilently(callable $operation): mixed
+ {
+ set_error_handler(static fn(): bool => true);
+
+ try {
+ return $operation();
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /** @param resource $output */
+ private static function synchronizeOutput(mixed $output, string $entry): void
+ {
+ if (!fflush($output)) {
+ throw new CompressionException("Unable to flush ZIP entry: {$entry}");
+ }
+ if (function_exists('fsync') && !fsync($output)) {
+ throw new CompressionException("Unable to synchronize ZIP entry: {$entry}");
+ }
+ }
+
+ private static function temporarySibling(string $target): string
+ {
+ $directory = dirname($target);
+ for ($attempt = 0; $attempt < 10; $attempt++) {
+ $candidate = $directory . DIRECTORY_SEPARATOR . '.pathwise-zip-' . bin2hex(random_bytes(16)) . '.tmp';
+ $handle = self::runSilently(static fn() => fopen($candidate, 'xb'));
+ if (!is_resource($handle)) {
+ continue;
+ }
+
+ fclose($handle);
+ if (!self::runSilently(static fn(): bool => chmod($candidate, 0600))) {
+ self::runSilently(static fn(): bool => unlink($candidate));
+
+ throw new CompressionException('Unable to secure ZIP extraction staging file.');
+ }
+
+ return $candidate;
+ }
+
+ throw new CompressionException('Unable to allocate ZIP extraction staging file.');
+ }
+
+ /** @param resource $output */
+ private static function writeChunk(mixed $output, string $chunk, string $entry): void
+ {
+ $offset = 0;
+ $length = strlen($chunk);
+ while ($offset < $length) {
+ $bytes = fwrite($output, substr($chunk, $offset));
+ if (!is_int($bytes) || $bytes < 1) {
+ throw new CompressionException("Unable to write ZIP entry: {$entry}");
+ }
+
+ $offset += $bytes;
+ }
+ }
+
+ private static function writeEntry(ZipArchive $archive, ZipArchiveManifestEntry $entry, string $temporary): void
+ {
+ $input = $archive->getStream($entry->archiveName);
+ $output = self::runSilently(static fn() => fopen($temporary, 'wb'));
+ if (!is_resource($input) || !is_resource($output)) {
+ if (is_resource($input)) {
+ fclose($input);
+ }
+ if (is_resource($output)) {
+ fclose($output);
+ }
+
+ throw new CompressionException("Unable to extract ZIP entry: {$entry->archiveName}");
+ }
+
+ try {
+ self::copyValidatedBytes($input, $output, $entry);
+ self::synchronizeOutput($output, $entry->archiveName);
+ } finally {
+ fclose($input);
+ fclose($output);
+ }
+ }
+}
diff --git a/src/Security/ZipArchiveManifestEntry.php b/src/Security/ZipArchiveManifestEntry.php
new file mode 100644
index 00000000..698d5d5f
--- /dev/null
+++ b/src/Security/ZipArchiveManifestEntry.php
@@ -0,0 +1,28 @@
+index,
+ archiveName: $this->archiveName,
+ path: $path,
+ uncompressedBytes: $this->uncompressedBytes,
+ directory: $this->directory,
+ );
+ }
+}
diff --git a/src/Security/ZipEntryValidator.php b/src/Security/ZipEntryValidator.php
index 16ff46ee..8a35fdb9 100644
--- a/src/Security/ZipEntryValidator.php
+++ b/src/Security/ZipEntryValidator.php
@@ -17,8 +17,16 @@ final class ZipEntryValidator
public const int DEFAULT_MAX_TOTAL_UNCOMPRESSED_BYTES = 4_294_967_296;
+ private const int UNIX_BLOCK_DEVICE = 0060000;
+
+ private const int UNIX_CHARACTER_DEVICE = 0020000;
+
+ private const int UNIX_FIFO = 0010000;
+
private const int UNIX_FILE_TYPE_MASK = 0170000;
+ private const int UNIX_SOCKET = 0140000;
+
private const int UNIX_SYMBOLIC_LINK = 0120000;
public static function validate(string $entry, string $extractionRoot): string
@@ -65,7 +73,7 @@ public static function validate(string $entry, string $extractionRoot): string
}
/**
- * @return array
+ * @return array
*/
public static function validateArchive(
ZipArchive $archive,
@@ -88,25 +96,39 @@ public static function validateArchive(
}
$entries = [];
+ $seenPaths = [];
+ $filePaths = [];
+ $ancestorPaths = [];
$totalUncompressedBytes = 0;
for ($index = 0; $index < $archive->numFiles; $index++) {
- $entry = $archive->getNameIndex($index);
- if (!is_string($entry)) {
+ $archiveName = $archive->getNameIndex($index);
+ if (!is_string($archiveName)) {
throw new UnsafeArchiveEntryException("Unable to read ZIP entry at index {$index}.");
}
- $entries[$index] = self::validate($entry, $extractionRoot);
- self::assertNotSymbolicLink($archive, $index, $entry);
- $totalUncompressedBytes = self::validateEntryResources(
+ $path = self::validate($archiveName, $extractionRoot);
+ $directory = str_ends_with($path, '/');
+ self::assertSupportedEntryType($archive, $index, $archiveName);
+ self::assertNoPathConflict($path, $directory, $seenPaths, $filePaths, $ancestorPaths, $archiveName);
+
+ [$uncompressedBytes, $totalUncompressedBytes] = self::validateEntryResources(
$archive,
$index,
- $entry,
+ $archiveName,
$totalUncompressedBytes,
$maxEntryUncompressedBytes,
$maxTotalUncompressedBytes,
$maxCompressionRatio,
);
+
+ $entries[$index] = new ZipArchiveManifestEntry(
+ index: $index,
+ archiveName: $archiveName,
+ path: $path,
+ uncompressedBytes: $uncompressedBytes,
+ directory: $directory,
+ );
}
return $entries;
@@ -129,6 +151,50 @@ public static function validateArchiveLimits(
}
}
+ /**
+ * @param array $seenPaths
+ * @param array $filePaths
+ * @param array $ancestorPaths
+ */
+ private static function assertNoPathConflict(
+ string $path,
+ bool $directory,
+ array &$seenPaths,
+ array &$filePaths,
+ array &$ancestorPaths,
+ string $entry,
+ ): void {
+ $canonical = strtolower(rtrim(str_replace('\\', '/', $path), '/'));
+ if (isset($seenPaths[$canonical])) {
+ throw new UnsafeArchiveEntryException("Duplicate or case-conflicting ZIP entry detected: {$entry}");
+ }
+ if (!$directory && isset($ancestorPaths[$canonical])) {
+ throw new UnsafeArchiveEntryException("ZIP file conflicts with an archive directory path: {$entry}");
+ }
+
+ $segments = explode('/', $canonical);
+ $ancestor = '';
+ $lastIndex = count($segments) - 1;
+ foreach ($segments as $index => $segment) {
+ if ($segment === '') {
+ continue;
+ }
+ $ancestor = $ancestor === '' ? $segment : $ancestor . '/' . $segment;
+ if ($index === $lastIndex) {
+ break;
+ }
+ if (isset($filePaths[$ancestor])) {
+ throw new UnsafeArchiveEntryException("ZIP entry is nested below an archive file: {$entry}");
+ }
+ $ancestorPaths[$ancestor] = true;
+ }
+
+ $seenPaths[$canonical] = $directory;
+ if (!$directory) {
+ $filePaths[$canonical] = true;
+ }
+ }
+
/** @param list $segments */
private static function assertNoSymbolicLinkInDestination(string $root, array $segments, string $entry): void
{
@@ -149,7 +215,7 @@ private static function assertNoSymbolicLinkInDestination(string $root, array $s
}
}
- private static function assertNotSymbolicLink(ZipArchive $archive, int $index, string $entry): void
+ private static function assertSupportedEntryType(ZipArchive $archive, int $index, string $entry): void
{
$attributes = 0;
$operationsSystem = 0;
@@ -164,8 +230,12 @@ private static function assertNotSymbolicLink(ZipArchive $archive, int $index, s
if ($mode === self::UNIX_SYMBOLIC_LINK) {
throw new UnsafeArchiveEntryException("Symbolic-link ZIP entry detected: {$entry}");
}
+ if (in_array($mode, [self::UNIX_BLOCK_DEVICE, self::UNIX_CHARACTER_DEVICE, self::UNIX_FIFO, self::UNIX_SOCKET], true)) {
+ throw new UnsafeArchiveEntryException("Special-file ZIP entry detected: {$entry}");
+ }
}
+ /** @return array{int, int} */
private static function validateEntryResources(
ZipArchive $archive,
int $index,
@@ -174,7 +244,7 @@ private static function validateEntryResources(
int $maxEntryUncompressedBytes,
int $maxTotalUncompressedBytes,
float $maxCompressionRatio,
- ): int {
+ ): array {
$stat = $archive->statIndex($index);
if (!is_array($stat)) {
throw new UnsafeArchiveEntryException("Unable to read ZIP resource metadata for entry: {$entry}");
@@ -208,6 +278,6 @@ private static function validateEntryResources(
);
}
- return $total;
+ return [$size, $total];
}
}
diff --git a/src/Storage/StorageContext.php b/src/Storage/StorageContext.php
new file mode 100644
index 00000000..8c7ece2d
--- /dev/null
+++ b/src/Storage/StorageContext.php
@@ -0,0 +1,309 @@
+> */
+ private readonly array $configurations;
+
+ private readonly string $defaultFilesystem;
+
+ /** @var array): mixed> */
+ private readonly array $drivers;
+
+ /** @var array */
+ private array $filesystems = [];
+
+ /**
+ * @param array $configurations
+ * @param array $drivers Custom driver factories returning FilesystemOperator instances.
+ */
+ public function __construct(array $configurations, string $defaultFilesystem, array $drivers = [])
+ {
+ if ($configurations === []) {
+ throw new \InvalidArgumentException('At least one filesystem configuration is required.');
+ }
+
+ $normalizedConfigurations = [];
+ foreach ($configurations as $name => $configuration) {
+ if (!is_string($name)) {
+ throw new \InvalidArgumentException(
+ 'Filesystem configurations must map valid names to configuration arrays.',
+ );
+ }
+
+ $normalizedName = self::normalizeName($name);
+ if (isset($normalizedConfigurations[$normalizedName])) {
+ throw new \InvalidArgumentException(
+ "Filesystem '{$normalizedName}' is configured more than once.",
+ );
+ }
+
+ $normalizedConfigurations[$normalizedName] = self::normalizeConfiguration($configuration);
+ }
+
+ $defaultFilesystem = self::normalizeName($defaultFilesystem);
+ if (!isset($normalizedConfigurations[$defaultFilesystem])) {
+ throw new \InvalidArgumentException(
+ "Default filesystem '{$defaultFilesystem}' is not configured.",
+ );
+ }
+
+ $this->configurations = $normalizedConfigurations;
+ $this->defaultFilesystem = $defaultFilesystem;
+ $this->drivers = $this->normalizeDrivers($drivers);
+ }
+
+ /** @return array */
+ public function configuration(?string $name = null): array
+ {
+ return $this->configurations[$this->resolveName($name)];
+ }
+
+ public function defaultFilesystem(): string
+ {
+ return $this->defaultFilesystem;
+ }
+
+ public function filesystem(?string $name = null): FilesystemOperator
+ {
+ $resolved = $this->resolveName($name);
+
+ return $this->filesystems[$resolved] ??= $this->createFilesystem($this->configurations[$resolved]);
+ }
+
+ /** @return list */
+ public function filesystemNames(): array
+ {
+ return array_keys($this->configurations);
+ }
+
+ public function hasDriver(string $name): bool
+ {
+ return isset($this->drivers[self::normalizeName($name)]);
+ }
+
+ public function hasFilesystem(string $name): bool
+ {
+ return isset($this->configurations[self::normalizeName($name)]);
+ }
+
+ public function isLocal(?string $name = null): bool
+ {
+ $configuration = $this->configuration($name);
+ $driver = $configuration['driver'] ?? 'local';
+
+ return is_string($driver)
+ && strtolower(trim($driver)) === 'local'
+ && is_string($configuration['root'] ?? null)
+ && $configuration['root'] !== '';
+ }
+
+ public function localPath(string $path = '', ?string $name = null): string
+ {
+ [$resolvedName, $location] = $this->resolveIdentity($path, $name);
+ $configuration = $this->configurations[$resolvedName];
+ $driver = $configuration['driver'] ?? 'local';
+ $root = $configuration['root'] ?? null;
+
+ if (!is_string($driver) || strtolower(trim($driver)) !== 'local' || !is_string($root) || $root === '') {
+ throw new \InvalidArgumentException(
+ "Filesystem '{$resolvedName}' does not expose a local root path.",
+ );
+ }
+
+ $root = PathHelper::normalize($root);
+
+ return $location === '' ? $root : PathHelper::join($root, $location);
+ }
+
+ /**
+ * Return a canonical context path without registering a process-global mount.
+ */
+ public function path(string $path = '', ?string $name = null): string
+ {
+ [$resolvedName, $location] = $this->resolveIdentity($path, $name);
+
+ return $location === '' ? $resolvedName . '://' : $resolvedName . '://' . $location;
+ }
+
+ /**
+ * Resolve a logical path to its filesystem operator and adapter-relative location.
+ *
+ * @return array{FilesystemOperator, string}
+ */
+ public function resolve(string $path = '', ?string $name = null): array
+ {
+ [$resolvedName, $location] = $this->resolveIdentity($path, $name);
+
+ return [$this->filesystem($resolvedName), $location];
+ }
+
+ private static function isAbsoluteLogicalPath(string $path): bool
+ {
+ return str_starts_with($path, '/')
+ || preg_match('/^[a-zA-Z]:/', $path) === 1;
+ }
+
+ /** @return array */
+ private static function normalizeConfiguration(mixed $configuration): array
+ {
+ if (!is_array($configuration)) {
+ throw new \InvalidArgumentException(
+ 'Filesystem configurations must map valid names to configuration arrays.',
+ );
+ }
+
+ $normalized = [];
+ foreach ($configuration as $key => $value) {
+ if (!is_string($key)) {
+ throw new \InvalidArgumentException('Filesystem configuration keys must be strings.');
+ }
+
+ $normalized[$key] = $value;
+ }
+
+ return $normalized;
+ }
+
+ private static function normalizeLocation(string $location): string
+ {
+ $normalized = str_replace('\\', '/', trim($location));
+ if (str_contains($normalized, "\0")) {
+ throw new \InvalidArgumentException('Storage path cannot contain a null byte.');
+ }
+ if (preg_match('~(?:^|/)\.\.(?:/|$)~', $normalized) === 1) {
+ throw new \InvalidArgumentException('Storage path cannot contain parent-directory traversal.');
+ }
+
+ $segments = array_values(array_filter(
+ explode('/', trim($normalized, '/')),
+ static fn(string $segment): bool => $segment !== '' && $segment !== '.',
+ ));
+
+ return implode('/', $segments);
+ }
+
+ private static function normalizeName(string $name): string
+ {
+ $normalized = strtolower(trim($name));
+ if (preg_match('/^[a-z][a-z0-9._-]*$/D', $normalized) !== 1) {
+ throw new \InvalidArgumentException("Invalid storage name '{$name}'.");
+ }
+
+ return $normalized;
+ }
+
+ /** @param array $configuration */
+ private function createFilesystem(array $configuration): FilesystemOperator
+ {
+ $driver = $configuration['driver'] ?? null;
+ if (is_string($driver)) {
+ $normalizedDriver = self::normalizeName($driver);
+ if (isset($this->drivers[$normalizedDriver])) {
+ $filesystem = ($this->drivers[$normalizedDriver])($configuration);
+ if (!$filesystem instanceof FilesystemOperator) {
+ throw new \UnexpectedValueException(
+ "Storage driver '{$normalizedDriver}' must return a FilesystemOperator.",
+ );
+ }
+
+ return $filesystem;
+ }
+
+ if (!StorageFactory::isOfficialDriver($normalizedDriver)) {
+ throw new \InvalidArgumentException(
+ "Unsupported context storage driver '{$normalizedDriver}'. Supply it to StorageContext explicitly.",
+ );
+ }
+ }
+
+ return StorageFactory::createFilesystem($configuration);
+ }
+
+ /**
+ * @param array $drivers
+ * @return array): mixed>
+ */
+ private function normalizeDrivers(array $drivers): array
+ {
+ $normalized = [];
+ foreach ($drivers as $name => $factory) {
+ if (!is_string($name) || !is_callable($factory)) {
+ throw new \InvalidArgumentException(
+ 'Storage drivers must map valid names to callable factories.',
+ );
+ }
+
+ $driver = self::normalizeName($name);
+ if (StorageFactory::isOfficialDriver($driver)) {
+ throw new \InvalidArgumentException(
+ "Storage driver name '{$name}' is reserved by an official driver.",
+ );
+ }
+ if (isset($normalized[$driver])) {
+ throw new \InvalidArgumentException(
+ "Storage driver '{$driver}' is configured more than once.",
+ );
+ }
+
+ $normalized[$driver] = $factory;
+ }
+
+ return $normalized;
+ }
+
+ /** @return array{string, string} */
+ private function resolveIdentity(string $path, ?string $name): array
+ {
+ $normalizedPath = str_replace('\\', '/', trim($path));
+ if (preg_match('/^([a-zA-Z][a-zA-Z0-9._-]*):\/\/(.*)$/s', $normalizedPath, $matches) === 1) {
+ $scheme = self::normalizeName($matches[1]);
+ if (!isset($this->configurations[$scheme])) {
+ throw new \InvalidArgumentException("Filesystem '{$scheme}' is not configured.");
+ }
+
+ if ($name !== null && $this->resolveName($name) !== $scheme) {
+ throw new \InvalidArgumentException(
+ "Filesystem path '{$scheme}://' conflicts with explicitly selected filesystem '{$name}'.",
+ );
+ }
+
+ return [$scheme, self::normalizeLocation($matches[2])];
+ }
+
+ if ($normalizedPath !== '' && self::isAbsoluteLogicalPath($normalizedPath)) {
+ throw new \InvalidArgumentException(
+ 'StorageContext paths must be relative or use a configured filesystem scheme.',
+ );
+ }
+
+ return [$this->resolveName($name), self::normalizeLocation($normalizedPath)];
+ }
+
+ private function resolveName(?string $name): string
+ {
+ $resolved = $name === null || trim($name) === ''
+ ? $this->defaultFilesystem
+ : self::normalizeName($name);
+
+ if (!isset($this->configurations[$resolved])) {
+ throw new \InvalidArgumentException("Filesystem '{$resolved}' is not configured.");
+ }
+
+ return $resolved;
+ }
+}
diff --git a/src/Storage/StorageFactory.php b/src/Storage/StorageFactory.php
index fc5bee47..f804d92b 100644
--- a/src/Storage/StorageFactory.php
+++ b/src/Storage/StorageFactory.php
@@ -4,7 +4,6 @@
namespace Infocyph\Pathwise\Storage;
-use Infocyph\Pathwise\Utils\FlysystemHelper;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\FilesystemOperator;
@@ -31,86 +30,25 @@ final class StorageFactory
'zip-archive' => 'ziparchive',
];
- /**
- * @var array
- */
+ /** @var array */
private const array OFFICIAL_DRIVERS = [
- 'local' => [
- 'package' => 'league/flysystem-local',
- 'adapter_class' => LocalFilesystemAdapter::class,
- ],
- 'ftp' => [
- 'package' => 'league/flysystem-ftp',
- 'adapter_class' => 'League\\Flysystem\\Ftp\\FtpAdapter',
- ],
- 'inmemory' => [
- 'package' => 'league/flysystem-memory',
- 'adapter_class' => 'League\\Flysystem\\InMemory\\InMemoryFilesystemAdapter',
- ],
- 'read-only' => [
- 'package' => 'league/flysystem-read-only',
- 'adapter_class' => 'League\\Flysystem\\ReadOnly\\ReadOnlyFilesystemAdapter',
- ],
- 'path-prefixing' => [
- 'package' => 'league/flysystem-path-prefixing',
- 'adapter_class' => 'League\\Flysystem\\PathPrefixing\\PathPrefixedAdapter',
- ],
- 'aws-s3' => [
- 'package' => 'league/flysystem-aws-s3-v3',
- 'adapter_class' => 'League\\Flysystem\\AwsS3V3\\AwsS3V3Adapter',
- ],
- 'async-aws-s3' => [
- 'package' => 'league/flysystem-async-aws-s3',
- 'adapter_class' => 'League\\Flysystem\\AsyncAwsS3\\AsyncAwsS3Adapter',
- ],
- 'azure-blob-storage' => [
- 'package' => 'league/flysystem-azure-blob-storage',
- 'adapter_class' => 'League\\Flysystem\\AzureBlobStorage\\AzureBlobStorageAdapter',
- ],
- 'google-cloud-storage' => [
- 'package' => 'league/flysystem-google-cloud-storage',
- 'adapter_class' => 'League\\Flysystem\\GoogleCloudStorage\\GoogleCloudStorageAdapter',
- ],
- 'mongodb-gridfs' => [
- 'package' => 'league/flysystem-gridfs',
- 'adapter_class' => 'League\\Flysystem\\GridFS\\GridFSAdapter',
- ],
- 'sftp-v2' => [
- 'package' => 'league/flysystem-sftp-v2',
- 'adapter_class' => 'League\\Flysystem\\PhpseclibV2\\SftpAdapter',
- ],
- 'sftp-v3' => [
- 'package' => 'league/flysystem-sftp-v3',
- 'adapter_class' => 'League\\Flysystem\\PhpseclibV3\\SftpAdapter',
- ],
- 'webdav' => [
- 'package' => 'league/flysystem-webdav',
- 'adapter_class' => 'League\\Flysystem\\WebDAV\\WebDAVAdapter',
- ],
- 'ziparchive' => [
- 'package' => 'league/flysystem-ziparchive',
- 'adapter_class' => 'League\\Flysystem\\ZipArchive\\ZipArchiveAdapter',
- ],
+ 'local' => ['package' => 'league/flysystem-local', 'adapter_class' => LocalFilesystemAdapter::class],
+ 'ftp' => ['package' => 'league/flysystem-ftp', 'adapter_class' => 'League\\Flysystem\\Ftp\\FtpAdapter'],
+ 'inmemory' => ['package' => 'league/flysystem-memory', 'adapter_class' => 'League\\Flysystem\\InMemory\\InMemoryFilesystemAdapter'],
+ 'read-only' => ['package' => 'league/flysystem-read-only', 'adapter_class' => 'League\\Flysystem\\ReadOnly\\ReadOnlyFilesystemAdapter'],
+ 'path-prefixing' => ['package' => 'league/flysystem-path-prefixing', 'adapter_class' => 'League\\Flysystem\\PathPrefixing\\PathPrefixedAdapter'],
+ 'aws-s3' => ['package' => 'league/flysystem-aws-s3-v3', 'adapter_class' => 'League\\Flysystem\\AwsS3V3\\AwsS3V3Adapter'],
+ 'async-aws-s3' => ['package' => 'league/flysystem-async-aws-s3', 'adapter_class' => 'League\\Flysystem\\AsyncAwsS3\\AsyncAwsS3Adapter'],
+ 'azure-blob-storage' => ['package' => 'league/flysystem-azure-blob-storage', 'adapter_class' => 'League\\Flysystem\\AzureBlobStorage\\AzureBlobStorageAdapter'],
+ 'google-cloud-storage' => ['package' => 'league/flysystem-google-cloud-storage', 'adapter_class' => 'League\\Flysystem\\GoogleCloudStorage\\GoogleCloudStorageAdapter'],
+ 'mongodb-gridfs' => ['package' => 'league/flysystem-gridfs', 'adapter_class' => 'League\\Flysystem\\GridFS\\GridFSAdapter'],
+ 'sftp-v2' => ['package' => 'league/flysystem-sftp-v2', 'adapter_class' => 'League\\Flysystem\\PhpseclibV2\\SftpAdapter'],
+ 'sftp-v3' => ['package' => 'league/flysystem-sftp-v3', 'adapter_class' => 'League\\Flysystem\\PhpseclibV3\\SftpAdapter'],
+ 'webdav' => ['package' => 'league/flysystem-webdav', 'adapter_class' => 'League\\Flysystem\\WebDAV\\WebDAVAdapter'],
+ 'ziparchive' => ['package' => 'league/flysystem-ziparchive', 'adapter_class' => 'League\\Flysystem\\ZipArchive\\ZipArchiveAdapter'],
];
- /** @var array): FilesystemOperator> */
- private static array $drivers = [];
-
- /**
- * Clear all registered custom drivers.
- */
- public static function clearDrivers(): void
- {
- self::$drivers = [];
- }
-
- /**
- * Create a filesystem from configuration.
- *
- * @param array $config The filesystem configuration.
- * @return FilesystemOperator The created filesystem.
- * @throws \InvalidArgumentException If the driver is unsupported.
- */
+ /** @param array $config */
public static function createFilesystem(array $config): FilesystemOperator
{
self::assertUnambiguousConfig($config);
@@ -130,138 +68,26 @@ public static function createFilesystem(array $config): FilesystemOperator
if ($driver === 'local') {
return self::createLocalFilesystemFromConfig($config);
}
-
- $custom = self::createFromRegisteredDriver($driver, $config);
- if ($custom !== null) {
- return $custom;
- }
-
if (self::isOfficialDriver($driver)) {
return self::createOfficialFilesystem($driver, $config);
}
throw new \InvalidArgumentException(
- "Unsupported storage driver '{$driver}'. Register it via StorageFactory::registerDriver().",
+ "Unsupported storage driver '{$driver}'. Supply custom driver factories to StorageContext.",
);
}
- /**
- * Get the names of all registered custom drivers.
- *
- * @return list The driver names.
- */
- public static function driverNames(): array
- {
- return array_keys(self::$drivers);
- }
-
- /**
- * Check if a custom driver is registered.
- *
- * @param string $name The driver name.
- * @return bool True if the driver is registered, false otherwise.
- */
- public static function hasDriver(string $name): bool
- {
- return isset(self::$drivers[self::canonicalDriverName($name)]);
- }
-
- /**
- * Check if a driver is an official Flysystem driver.
- *
- * @param string $driver The driver name.
- * @return bool True if it's an official driver, false otherwise.
- */
public static function isOfficialDriver(string $driver): bool
{
return isset(self::OFFICIAL_DRIVERS[self::canonicalDriverName($driver)]);
}
- /**
- * Create and mount a filesystem under a name.
- *
- * @param string $name The mount name.
- * @param array $config The filesystem configuration.
- * @return FilesystemOperator The created filesystem.
- */
- public static function mount(string $name, array $config): FilesystemOperator
- {
- $filesystem = self::createFilesystem($config);
- FlysystemHelper::mount($name, $filesystem);
-
- return $filesystem;
- }
-
- /**
- * Mount multiple filesystems at once.
- *
- * @param array> $mounts Array of mount name => config pairs.
- */
- public static function mountMany(array $mounts): void
- {
- $prepared = [];
- foreach ($mounts as $name => $config) {
- if ($name === '') {
- throw new \InvalidArgumentException('Mount names must be non-empty strings.');
- }
- if (FlysystemHelper::hasMount($name) || array_key_exists($name, $prepared)) {
- throw new \InvalidArgumentException("Flysystem mount '{$name}' is already registered.");
- }
- $prepared[$name] = self::createFilesystem($config);
- }
-
- $mounted = [];
-
- try {
- foreach ($prepared as $name => $filesystem) {
- FlysystemHelper::mount($name, $filesystem);
- $mounted[] = $name;
- }
- } catch (\Throwable $exception) {
- foreach ($mounted as $name) {
- FlysystemHelper::unmount($name);
- }
-
- throw $exception;
- }
- }
-
- /**
- * Get all official driver metadata.
- *
- * @return array The official drivers.
- */
+ /** @return array */
public static function officialDrivers(): array
{
return self::OFFICIAL_DRIVERS;
}
- /**
- * Register a custom driver factory.
- *
- * @param string $name The driver name.
- * @param callable(array): FilesystemOperator $factory Factory that receives config and returns filesystem.
- * @throws \InvalidArgumentException If the driver name is empty.
- */
- public static function registerDriver(string $name, callable $factory): void
- {
- $driver = self::canonicalDriverName($name);
- if ($driver === '') {
- throw new \InvalidArgumentException('Driver name is required.');
- }
- if (isset(self::OFFICIAL_DRIVERS[$driver]) || isset(self::$drivers[$driver])) {
- throw new \InvalidArgumentException("Storage driver name '{$name}' is reserved or already registered.");
- }
-
- self::$drivers[$driver] = $factory;
- }
-
- /**
- * Get the suggested package for an official driver.
- *
- * @param string $driver The driver name.
- * @return string|null The package name, or null if not an official driver.
- */
public static function suggestedPackage(string $driver): ?string
{
$normalized = self::canonicalDriverName($driver);
@@ -269,16 +95,6 @@ public static function suggestedPackage(string $driver): ?string
return self::OFFICIAL_DRIVERS[$normalized]['package'] ?? null;
}
- /**
- * Unregister a custom driver.
- *
- * @param string $name The driver name to unregister.
- */
- public static function unregisterDriver(string $name): void
- {
- unset(self::$drivers[self::canonicalDriverName($name)]);
- }
-
/** @param array $config */
private static function assertUnambiguousConfig(array $config): void
{
@@ -299,26 +115,12 @@ private static function assertUnambiguousConfig(array $config): void
private static function canonicalDriverName(string $name): string
{
- $normalized = self::normalizeDriverName($name);
+ $normalized = strtolower(trim($name));
return self::DRIVER_ALIASES[$normalized] ?? $normalized;
}
- /**
- * @param array $config
- */
- private static function createFromRegisteredDriver(string $driver, array $config): ?FilesystemOperator
- {
- if (!isset(self::$drivers[$driver])) {
- return null;
- }
-
- return self::$drivers[$driver]($config);
- }
-
- /**
- * @param array $config
- */
+ /** @param array $config */
private static function createLocalFilesystem(array $config): FilesystemOperator
{
$root = $config['root'] ?? null;
@@ -329,9 +131,7 @@ private static function createLocalFilesystem(array $config): FilesystemOperator
return new Filesystem(new LocalFilesystemAdapter($root), self::resolveOptions($config));
}
- /**
- * @param array $config
- */
+ /** @param array $config */
private static function createLocalFilesystemFromConfig(array $config): FilesystemOperator
{
$adapter = self::resolveAdapter($config);
@@ -342,9 +142,7 @@ private static function createLocalFilesystemFromConfig(array $config): Filesyst
return self::createLocalFilesystem($config);
}
- /**
- * @param array $config
- */
+ /** @param array $config */
private static function createOfficialFilesystem(string $driver, array $config): FilesystemOperator
{
$driver = self::canonicalDriverName($driver);
@@ -373,10 +171,10 @@ private static function createOfficialFilesystem(string $driver, array $config):
$reflection = new \ReflectionClass($adapterClass);
$required = $reflection->getConstructor()?->getNumberOfRequiredParameters() ?? 0;
if ($required > 0) {
- throw new \InvalidArgumentException(
- "Storage driver '{$driver}' requires explicit constructor config.",
- );
+ throw new \InvalidArgumentException("Storage driver '{$driver}' requires explicit constructor config.");
}
+
+ /** @var FilesystemAdapter $adapter */
$adapter = $reflection->newInstance();
return new Filesystem($adapter, self::resolveOptions($config));
@@ -388,24 +186,17 @@ private static function createOfficialFilesystem(string $driver, array $config):
"Storage driver '{$driver}' requires either 'adapter' or 'constructor' config.",
);
}
-
if (!array_is_list($constructor)) {
throw new \InvalidArgumentException('Storage "constructor" must be a list of positional arguments.');
}
- $arguments = $constructor;
- $adapter = new \ReflectionClass($adapterClass)->newInstanceArgs($arguments);
- return new Filesystem($adapter, self::resolveOptions($config));
- }
+ /** @var FilesystemAdapter $adapter */
+ $adapter = new \ReflectionClass($adapterClass)->newInstanceArgs($constructor);
- private static function normalizeDriverName(string $name): string
- {
- return strtolower(trim($name));
+ return new Filesystem($adapter, self::resolveOptions($config));
}
- /**
- * @param array $config
- */
+ /** @param array $config */
private static function resolveAdapter(array $config): ?FilesystemAdapter
{
/** @var FilesystemAdapter|null $adapter */
@@ -414,9 +205,7 @@ private static function resolveAdapter(array $config): ?FilesystemAdapter
return $adapter;
}
- /**
- * @param array $config
- */
+ /** @param array $config */
private static function resolveDriver(array $config): string
{
$driverInput = $config['driver'] ?? 'local';
@@ -455,9 +244,7 @@ private static function resolveOptions(array $config): array
return $normalized;
}
- /**
- * @param array $config
- */
+ /** @param array $config */
private static function resolveProvidedFilesystem(array $config): ?FilesystemOperator
{
/** @var FilesystemOperator|null $filesystem */
diff --git a/src/StreamHandler/Concerns/StorageContextRoutingConcern.php b/src/StreamHandler/Concerns/StorageContextRoutingConcern.php
new file mode 100644
index 00000000..40c6ef8a
--- /dev/null
+++ b/src/StreamHandler/Concerns/StorageContextRoutingConcern.php
@@ -0,0 +1,274 @@
+storageContext = $storageContext;
+ }
+
+ private function storageChecksum(string $path, string $algorithm = 'sha256'): ?string
+ {
+ if (!in_array($algorithm, hash_algos(), true) || !$this->storageFileExists($path)) {
+ return null;
+ }
+
+ $resolved = $this->storageResolution($path);
+ if ($resolved === null) {
+ return FlysystemHelper::checksum($path, $algorithm);
+ }
+
+ [$filesystem, $location] = $resolved;
+
+ return $filesystem->checksum($location, ['checksum_algo' => $algorithm]);
+ }
+
+ private function storageCopy(string $source, string $destination): void
+ {
+ $sourceResolution = $this->storageResolution($source);
+ $destinationResolution = $this->storageResolution($destination);
+
+ if ($sourceResolution === null && $destinationResolution === null) {
+ FlysystemHelper::copy($source, $destination);
+
+ return;
+ }
+
+ if (
+ $sourceResolution !== null
+ && $destinationResolution !== null
+ && $sourceResolution[0] === $destinationResolution[0]
+ ) {
+ $sourceResolution[0]->copy($sourceResolution[1], $destinationResolution[1]);
+
+ return;
+ }
+
+ $stream = $this->storageReadStream($source);
+
+ try {
+ $this->storageWriteStream($destination, $stream);
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ private function storageCreateDirectory(string $path): void
+ {
+ $resolved = $this->storageResolution($path);
+ if ($resolved === null) {
+ FlysystemHelper::createDirectory($path);
+
+ return;
+ }
+
+ $resolved[0]->createDirectory($resolved[1]);
+ }
+
+ private function storageDelete(string $path): void
+ {
+ $resolved = $this->storageResolution($path);
+ if ($resolved === null) {
+ FlysystemHelper::delete($path);
+
+ return;
+ }
+
+ $resolved[0]->delete($resolved[1]);
+ }
+
+ private function storageDeleteDirectory(string $path): void
+ {
+ $resolved = $this->storageResolution($path);
+ if ($resolved === null) {
+ FlysystemHelper::deleteDirectory($path);
+
+ return;
+ }
+
+ $resolved[0]->deleteDirectory($resolved[1]);
+ }
+
+ private function storageDirectLocalPath(string $path): ?string
+ {
+ if ($this->storageUsesContext($path)) {
+ try {
+ return $this->storageContext?->localPath($path);
+ } catch (\InvalidArgumentException) {
+ return null;
+ }
+ }
+
+ return FlysystemHelper::isLocalPath($path) ? PathHelper::normalize($path) : null;
+ }
+
+ private function storageDirectoryExists(string $path): bool
+ {
+ $resolved = $this->storageResolution($path);
+
+ return $resolved === null
+ ? FlysystemHelper::directoryExists($path)
+ : $resolved[0]->directoryExists($resolved[1]);
+ }
+
+ private function storageFileExists(string $path): bool
+ {
+ $resolved = $this->storageResolution($path);
+
+ return $resolved === null
+ ? FlysystemHelper::fileExists($path)
+ : $resolved[0]->fileExists($resolved[1]);
+ }
+
+ private function storageIsLocalPath(string $path): bool
+ {
+ return $this->storageDirectLocalPath($path) !== null;
+ }
+
+ private function storageIsSameOrDescendant(string $root, string $path): bool
+ {
+ $rootUsesContext = $this->storageUsesContext($root);
+ $pathUsesContext = $this->storageUsesContext($path);
+ if ($rootUsesContext !== $pathUsesContext) {
+ return false;
+ }
+
+ if (!$rootUsesContext) {
+ return FlysystemHelper::isSameOrDescendant($root, $path);
+ }
+
+ $rootResolution = $this->storageContext?->resolve($root);
+ $pathResolution = $this->storageContext?->resolve($path);
+ if ($rootResolution === null || $pathResolution === null || $rootResolution[0] !== $pathResolution[0]) {
+ return false;
+ }
+
+ $rootLocation = trim(str_replace('\\', '/', $rootResolution[1]), '/');
+ $pathLocation = trim(str_replace('\\', '/', $pathResolution[1]), '/');
+ if ($rootLocation === '') {
+ return true;
+ }
+
+ return $pathLocation === $rootLocation || str_starts_with($pathLocation, $rootLocation . '/');
+ }
+
+ private function storageLastModified(string $path): int
+ {
+ $resolved = $this->storageResolution($path);
+
+ return $resolved === null
+ ? FlysystemHelper::lastModified($path)
+ : $resolved[0]->lastModified($resolved[1]);
+ }
+
+ private function storageMimeType(string $path): ?string
+ {
+ $resolved = $this->storageResolution($path);
+ if ($resolved === null) {
+ return MetadataHelper::getMimeType($path);
+ }
+
+ try {
+ return $resolved[0]->mimeType($resolved[1]);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ private function storageRead(string $path): string
+ {
+ $resolved = $this->storageResolution($path);
+
+ return $resolved === null
+ ? FlysystemHelper::read($path)
+ : $resolved[0]->read($resolved[1]);
+ }
+
+ /** @return resource */
+ private function storageReadStream(string $path): mixed
+ {
+ $resolved = $this->storageResolution($path);
+ $stream = $resolved === null
+ ? FlysystemHelper::readStream($path)
+ : $resolved[0]->readStream($resolved[1]);
+
+ if (!is_resource($stream)) {
+ throw new \RuntimeException("Unable to read storage stream: {$path}");
+ }
+
+ return $stream;
+ }
+
+ /** @return array{FilesystemOperator, string}|null */
+ private function storageResolution(string $path): ?array
+ {
+ if (!$this->storageUsesContext($path)) {
+ return null;
+ }
+
+ return $this->storageContext?->resolve($path);
+ }
+
+ private function storageSize(string $path): int
+ {
+ $resolved = $this->storageResolution($path);
+
+ return $resolved === null
+ ? FlysystemHelper::size($path)
+ : $resolved[0]->fileSize($resolved[1]);
+ }
+
+ private function storageUsesContext(string $path): bool
+ {
+ return $this->storageContext !== null
+ && (PathHelper::hasScheme($path) || !PathHelper::isAbsolute($path));
+ }
+
+ private function storageWrite(string $path, string $contents): void
+ {
+ $resolved = $this->storageResolution($path);
+ if ($resolved === null) {
+ FlysystemHelper::write($path, $contents);
+
+ return;
+ }
+
+ $resolved[0]->write($resolved[1], $contents);
+ }
+
+ private function storageWriteStream(string $path, mixed $stream): void
+ {
+ if (!is_resource($stream)) {
+ throw new \InvalidArgumentException('Storage write stream must be a resource.');
+ }
+
+ $resolved = $this->storageResolution($path);
+ if ($resolved === null) {
+ FlysystemHelper::writeStream($path, $stream);
+
+ return;
+ }
+
+ $resolved[0]->writeStream($resolved[1], $stream);
+ }
+}
diff --git a/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php b/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php
index 3ba1e48b..fcc482a1 100644
--- a/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php
+++ b/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php
@@ -6,7 +6,6 @@
use Infocyph\Pathwise\Exceptions\FileSizeExceededException;
use Infocyph\Pathwise\Exceptions\UploadException;
-use Infocyph\Pathwise\Utils\FlysystemHelper;
use Infocyph\Pathwise\Utils\PathHelper;
/**
@@ -31,7 +30,7 @@ private function appendChunkToStream(string $chunkPath, mixed $output, int $inde
throw new UploadException("Invalid merge stream for chunk index {$index}.");
}
- $input = FlysystemHelper::readStream($chunkPath);
+ $input = $this->storageReadStream($chunkPath);
if (!is_resource($input)) {
throw new UploadException("Failed to read chunk index {$index}.");
}
@@ -59,16 +58,14 @@ private function assertChunkManifestIdentity(
}
}
- /**
- */
private function cleanupChunkUploadArtifacts(string $uploadId, string $chunkDirectory): void
{
$manifestPath = $this->getChunkManifestPath($uploadId);
- if (FlysystemHelper::fileExists($manifestPath)) {
- FlysystemHelper::delete($manifestPath);
+ if ($this->storageFileExists($manifestPath)) {
+ $this->storageDelete($manifestPath);
}
- if (FlysystemHelper::directoryExists($chunkDirectory)) {
- FlysystemHelper::deleteDirectory($chunkDirectory);
+ if ($this->storageDirectoryExists($chunkDirectory)) {
+ $this->storageDeleteDirectory($chunkDirectory);
}
}
@@ -91,11 +88,11 @@ private function getChunkManifestPath(string $uploadId): string
private function loadChunkManifest(string $uploadId): ?array
{
$path = $this->getChunkManifestPath($uploadId);
- if (!FlysystemHelper::fileExists($path)) {
+ if (!$this->storageFileExists($path)) {
return null;
}
- $content = FlysystemHelper::read($path);
+ $content = $this->storageRead($path);
$manifest = json_decode($content, true);
if (!is_array($manifest)) {
@@ -127,8 +124,6 @@ private function loadChunkManifest(string $uploadId): ?array
];
}
- /**
- */
private function mergeChunksToDestination(string $chunkDirectory, int $totalChunks, string $destination): void
{
$output = fopen('php://temp', 'rb+');
@@ -145,7 +140,7 @@ private function mergeChunksToDestination(string $chunkDirectory, int $totalChun
}
rewind($output);
- FlysystemHelper::writeStream($destination, $output);
+ $this->storageWriteStream($destination, $output);
} finally {
fclose($output);
}
@@ -157,7 +152,7 @@ private function receivedChunkMap(string $chunkDirectory, int $totalChunks): arr
$received = [];
for ($index = 0; $index < $totalChunks; $index++) {
$name = sprintf('chunk_%06d.part', $index);
- if (FlysystemHelper::fileExists(PathHelper::join($chunkDirectory, $name))) {
+ if ($this->storageFileExists(PathHelper::join($chunkDirectory, $name))) {
$received[(string) $index] = $name;
}
}
@@ -165,12 +160,10 @@ private function receivedChunkMap(string $chunkDirectory, int $totalChunks): arr
return $received;
}
- /**
- */
private function resolveChunkPath(string $chunkDirectory, int $index): string
{
$chunkPath = PathHelper::join($chunkDirectory, sprintf('chunk_%06d.part', $index));
- if (!FlysystemHelper::fileExists($chunkPath)) {
+ if (!$this->storageFileExists($chunkPath)) {
throw new UploadException("Missing chunk file for index {$index}.");
}
@@ -213,7 +206,7 @@ private function saveChunkManifest(string $uploadId, array $manifest): void
throw new UploadException('Failed to persist chunk manifest.');
}
- FlysystemHelper::write($path, $json);
+ $this->storageWrite($path, $json);
}
/**
@@ -261,11 +254,11 @@ private function validateUploadId(string $uploadId): void
private function withChunkSessionLock(string $uploadId, callable $operation): mixed
{
$chunkDirectory = $this->getChunkDirectory($uploadId);
- if (!FlysystemHelper::isLocalPath($chunkDirectory)) {
+ if (!$this->storageIsLocalPath($chunkDirectory)) {
return $operation();
}
- $lockDirectory = dirname($chunkDirectory);
+ $lockDirectory = dirname($this->storageDirectLocalPath($chunkDirectory) ?? $chunkDirectory);
if (!is_dir($lockDirectory) && !mkdir($lockDirectory, 0700, true) && !is_dir($lockDirectory)) {
throw new UploadException('Unable to create chunk lock directory.');
}
diff --git a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php
index 69a584dc..e77ca242 100644
--- a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php
+++ b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php
@@ -6,9 +6,10 @@
use Infocyph\Pathwise\Exceptions\FileSizeExceededException;
use Infocyph\Pathwise\Exceptions\UploadException;
+use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
+use Infocyph\Pathwise\StreamHandler\MalwareScanRequest;
+use Infocyph\Pathwise\StreamHandler\MalwareScanVerdict;
use Infocyph\Pathwise\Utils\ExtensionPolicy;
-use Infocyph\Pathwise\Utils\FlysystemHelper;
-use Infocyph\Pathwise\Utils\MetadataHelper;
use Infocyph\Pathwise\Utils\PathHelper;
/**
@@ -21,6 +22,8 @@
*/
trait UploadProcessorValidationConcern
{
+ use StorageContextRoutingConcern;
+
/**
* Get a unique destination for the uploaded file.
*/
@@ -31,16 +34,26 @@ private function buildDestination(string $fileName): string
? PathHelper::join($this->uploadDir, $subDir)
: $this->uploadDir;
- if (!FlysystemHelper::directoryExists($destinationDir)) {
- FlysystemHelper::createDirectory($destinationDir);
+ if (!$this->storageDirectoryExists($destinationDir)) {
+ $this->storageCreateDirectory($destinationDir);
}
return PathHelper::join($destinationDir, $fileName);
}
+ private function cleanupMalwareScanInput(string $path, string $directory): void
+ {
+ if (is_file($path) || is_link($path)) {
+ $this->runSilently(static fn(): bool => unlink($path));
+ }
+ if (is_dir($directory) && !is_link($directory)) {
+ $this->runSilently(static fn(): bool => rmdir($directory));
+ }
+ }
+
private function copyImageToInspectionFile(string $filePath, string $tempFile): void
{
- $stream = FlysystemHelper::readStream($filePath);
+ $stream = $this->storageReadStream($filePath);
$target = fopen($tempFile, 'wb');
if (!is_resource($stream) || !is_resource($target)) {
if (is_resource($stream)) {
@@ -59,10 +72,39 @@ private function copyImageToInspectionFile(string $filePath, string $tempFile):
fclose($target);
}
+ private function copyToMalwareScanInput(string $filePath, string $target): void
+ {
+ $source = $this->storageReadStream($filePath);
+ $destination = fopen($target, 'xb');
+ if (!is_resource($source) || !is_resource($destination)) {
+ if (is_resource($source)) {
+ fclose($source);
+ }
+ if (is_resource($destination)) {
+ fclose($destination);
+ }
+
+ throw new UploadException('Unable to prepare malware scan input.');
+ }
+
+ try {
+ if (stream_copy_to_stream($source, $destination) === false) {
+ throw new UploadException('Unable to prepare malware scan input.');
+ }
+ } finally {
+ fclose($source);
+ fclose($destination);
+ }
+
+ if (!$this->runSilently(static fn(): bool => chmod($target, 0600))) {
+ throw new UploadException('Unable to secure malware scan input.');
+ }
+ }
+
private function deleteIncomingFile(string $path): void
{
- if (FlysystemHelper::fileExists($path)) {
- FlysystemHelper::delete($path);
+ if ($this->storageFileExists($path)) {
+ $this->storageDelete($path);
}
}
@@ -71,8 +113,8 @@ private function deleteIncomingFile(string $path): void
*/
private function ensureUploadDirectoryExists(): void
{
- if (!FlysystemHelper::directoryExists($this->uploadDir)) {
- FlysystemHelper::createDirectory($this->uploadDir);
+ if (!$this->storageDirectoryExists($this->uploadDir)) {
+ $this->storageCreateDirectory($this->uploadDir);
}
}
@@ -81,14 +123,14 @@ private function finalizeIncomingFile(string $source, string $extension): string
if ($this->namingStrategy === 'hash') {
$fileName = $this->generateFileName($source, $extension);
$destination = $this->buildDestination($fileName);
- if (!FlysystemHelper::fileExists($destination)) {
+ if (!$this->storageFileExists($destination)) {
$this->moveIncomingFile($source, $destination);
return $destination;
}
- $destinationChecksum = FlysystemHelper::checksum($destination, 'sha256');
- $sourceChecksum = FlysystemHelper::checksum($source, 'sha256');
+ $destinationChecksum = $this->storageChecksum($destination, 'sha256');
+ $sourceChecksum = $this->storageChecksum($source, 'sha256');
if (
is_string($destinationChecksum)
&& is_string($sourceChecksum)
@@ -104,7 +146,7 @@ private function finalizeIncomingFile(string $source, string $extension): string
for ($attempt = 0; $attempt < 5; $attempt++) {
$destination = $this->buildDestination($this->generateFileName(null, $extension));
- if (!FlysystemHelper::fileExists($destination)) {
+ if (!$this->storageFileExists($destination)) {
$this->moveIncomingFile($source, $destination);
return $destination;
@@ -119,7 +161,7 @@ private function finalizeIncomingFile(string $source, string $extension): string
*/
private function getFileMimeType(string $filePath): string
{
- $mimeType = MetadataHelper::getMimeType($filePath);
+ $mimeType = $this->storageMimeType($filePath);
if ($mimeType === null) {
throw new UploadException('Unable to determine file MIME type.');
}
@@ -135,17 +177,62 @@ private function isImage(string $fileType): bool
return str_starts_with($fileType, 'image/');
}
+ private function malwareScanDigest(string $path): string
+ {
+ $digest = hash_file('sha256', $path);
+ if (!is_string($digest)) {
+ throw new UploadException('Unable to verify malware scan input integrity.');
+ }
+
+ return $digest;
+ }
+
+ /**
+ * @return array{string, string}
+ */
+ private function materializeMalwareScanInput(string $filePath): array
+ {
+ $root = PathHelper::toAbsolutePath(sys_get_temp_dir());
+ $directory = '';
+
+ for ($attempt = 0; $attempt < 5; $attempt++) {
+ $candidate = PathHelper::join($root, 'pathwise-scan-' . bin2hex(random_bytes(16)));
+ if ($this->runSilently(static fn(): bool => mkdir($candidate, 0700))) {
+ $directory = $candidate;
+
+ break;
+ }
+ }
+
+ if ($directory === '') {
+ throw new UploadException('Unable to allocate malware scan staging directory.');
+ }
+
+ $target = PathHelper::join($directory, 'payload');
+
+ try {
+ $this->copyToMalwareScanInput($filePath, $target);
+ } catch (\Throwable $exception) {
+ $this->cleanupMalwareScanInput($target, $directory);
+
+ throw $exception;
+ }
+
+ return [$target, $directory];
+ }
+
private function moveIncomingFile(string $source, string $destination): void
{
+ $directDestination = $this->storageDirectLocalPath($destination);
if (is_uploaded_file($source)) {
- if (move_uploaded_file($source, $destination)) {
+ if ($directDestination !== null && move_uploaded_file($source, $directDestination)) {
return;
}
$stream = fopen($source, 'rb');
if (is_resource($stream)) {
try {
- FlysystemHelper::writeStream($destination, $stream);
+ $this->storageWriteStream($destination, $stream);
} finally {
fclose($stream);
}
@@ -158,26 +245,22 @@ private function moveIncomingFile(string $source, string $destination): void
throw new UploadException('Failed to move uploaded file.');
}
- if (PathHelper::hasScheme($source) || PathHelper::hasScheme($destination)) {
- try {
- FlysystemHelper::copy($source, $destination);
- } catch (\Throwable) {
- throw new UploadException('Failed to move incoming file.');
- }
-
- FlysystemHelper::delete($source);
-
+ $directSource = $this->storageDirectLocalPath($source);
+ if (
+ $directSource !== null
+ && $directDestination !== null
+ && $this->runSilently(static fn(): bool => rename($directSource, $directDestination))
+ ) {
return;
}
- if (!$this->runSilently(static fn(): bool => rename($source, $destination))) {
- try {
- FlysystemHelper::copy($source, $destination);
- } catch (\Throwable) {
- throw new UploadException('Failed to move incoming file.');
- }
- FlysystemHelper::delete($source);
+ try {
+ $this->storageCopy($source, $destination);
+ } catch (\Throwable) {
+ throw new UploadException('Failed to move incoming file.');
}
+
+ $this->storageDelete($source);
}
private function normalizeExtension(string $extension): string
@@ -230,8 +313,9 @@ private function normalizeUploadSize(int|string $size): int
*/
private function prepareImagePathForInspection(string $filePath): array
{
- if (!PathHelper::hasScheme($filePath) && is_file($filePath)) {
- return [$filePath, false];
+ $directLocalPath = $this->storageDirectLocalPath($filePath);
+ if ($directLocalPath !== null && is_file($directLocalPath)) {
+ return [$directLocalPath, false];
}
$tempFile = tempnam(sys_get_temp_dir(), 'pathwise_img_');
@@ -247,7 +331,7 @@ private function prepareImagePathForInspection(string $filePath): array
private function readHeaderBytes(string $filePath, int $length): ?string
{
$length = max(1, $length);
- $stream = FlysystemHelper::readStream($filePath);
+ $stream = $this->storageReadStream($filePath);
if (!is_resource($stream)) {
return null;
}
@@ -272,24 +356,61 @@ private function runSilently(callable $operation): mixed
}
}
- private function scanForMalware(string $filePath, string $fileType): void
+ private function scanForMalware(string $filePath, string $extension, int $expectedSize): void
{
- if (!is_callable($this->malwareScanner)) {
- if ($this->requireMalwareScan) {
+ if ($this->malwareScanMode === MalwareScanMode::OFF) {
+ return;
+ }
+
+ if ($this->malwareScanner === null) {
+ if ($this->malwareScanMode === MalwareScanMode::REQUIRED) {
throw new UploadException('Malware scanner is required but not configured.');
}
return;
}
+ [$scanPath, $scanDirectory] = $this->materializeMalwareScanInput($filePath);
+
try {
- $result = ($this->malwareScanner)($filePath, $fileType);
- } catch (\Throwable $e) {
- throw new UploadException('Malware scanner failed: ' . $e->getMessage(), 0, $e);
- }
+ $request = new MalwareScanRequest(
+ localPath: $scanPath,
+ extension: $this->normalizeExtension($extension),
+ );
+ if ($request->size !== $expectedSize || $this->storageSize($filePath) !== $expectedSize) {
+ throw new UploadException('Upload changed during malware scan preparation.');
+ }
+
+ $digestBeforeScan = $this->malwareScanDigest($scanPath);
- if ($result === false) {
- throw new UploadException('Malware scan failed.');
+ try {
+ $verdict = $this->malwareScanner->scan($request);
+ } catch (\Throwable $exception) {
+ throw new UploadException('Malware scanner failed.', 0, $exception);
+ }
+
+ clearstatcache(true, $scanPath);
+ if (is_link($scanPath) || !is_file($scanPath)) {
+ throw new UploadException('Malware scanner modified scan input.');
+ }
+
+ $sizeAfterScan = filesize($scanPath);
+ $digestAfterScan = $this->malwareScanDigest($scanPath);
+ if (
+ !is_int($sizeAfterScan)
+ || $sizeAfterScan !== $request->size
+ || !hash_equals($digestBeforeScan, $digestAfterScan)
+ ) {
+ throw new UploadException('Malware scanner modified scan input.');
+ }
+ if ($this->storageSize($filePath) !== $expectedSize) {
+ throw new UploadException('Upload changed during malware scanning.');
+ }
+ if ($verdict !== MalwareScanVerdict::CLEAN) {
+ throw new UploadException('Malware scan rejected the upload.');
+ }
+ } finally {
+ $this->cleanupMalwareScanInput($scanPath, $scanDirectory);
}
}
@@ -409,7 +530,7 @@ private function validateFileType(string $fileType): void
private function validateFinalizedUpload(string $destination): void
{
$extension = pathinfo($destination, PATHINFO_EXTENSION);
- $this->validateUploadedPayload($destination, $extension, true);
+ $this->validateUploadedPayload($destination, $extension);
}
/**
@@ -493,22 +614,20 @@ private function validateMimeTypeMatchesExtension(string $fileType, string $exte
}
}
- private function validateUploadedPayload(string $filePath, string $extension, bool $validateSize): string
+ private function validateUploadedPayload(string $filePath, string $extension): string
{
- if ($validateSize) {
- $this->validateFileSize(FlysystemHelper::size($filePath));
- }
+ $actualSize = $this->storageSize($filePath);
+ $this->validateFileSize($actualSize);
+ $this->validateFileExtension($extension);
+ $this->scanForMalware($filePath, $extension, $actualSize);
$fileType = $this->getFileMimeType($filePath);
- $this->validateFileExtension($extension);
$this->validateFileType($fileType);
$this->validateContentTypeIntegrity($filePath, $fileType, $extension);
if ($this->isImage($fileType)) {
$this->validateImageDimensions($filePath);
}
- $this->scanForMalware($filePath, $fileType);
-
return $fileType;
}
}
diff --git a/src/StreamHandler/DownloadProcessor.php b/src/StreamHandler/DownloadProcessor.php
index 7343ae7e..041d33e5 100644
--- a/src/StreamHandler/DownloadProcessor.php
+++ b/src/StreamHandler/DownloadProcessor.php
@@ -10,13 +10,14 @@
use Infocyph\Pathwise\Results\DownloadPreparation;
use Infocyph\Pathwise\Results\DownloadStreamResult;
use Infocyph\Pathwise\Results\RangeDownloadMetadata;
+use Infocyph\Pathwise\StreamHandler\Concerns\StorageContextRoutingConcern;
use Infocyph\Pathwise\Utils\ExtensionPolicy;
-use Infocyph\Pathwise\Utils\FlysystemHelper;
-use Infocyph\Pathwise\Utils\MetadataHelper;
use Infocyph\Pathwise\Utils\PathHelper;
class DownloadProcessor
{
+ use StorageContextRoutingConcern;
+
/** @var list */
private array $allowedExtensions = [];
@@ -56,7 +57,7 @@ public function prepareDownload(
$normalizedPath = PathHelper::normalize($path);
$this->validateDownloadPath($normalizedPath);
- $size = FlysystemHelper::size($normalizedPath);
+ $size = $this->storageSize($normalizedPath);
if ($this->maxDownloadSize > 0 && $size > $this->maxDownloadSize) {
throw new FileSizeExceededException('Download exceeds configured size limit.');
}
@@ -64,8 +65,8 @@ public function prepareDownload(
$extension = pathinfo($normalizedPath, PATHINFO_EXTENSION);
$this->validateExtension($extension);
- $mimeType = MetadataHelper::getMimeType($normalizedPath) ?? 'application/octet-stream';
- $lastModified = FlysystemHelper::lastModified($normalizedPath);
+ $mimeType = $this->storageMimeType($normalizedPath) ?? 'application/octet-stream';
+ $lastModified = $this->storageLastModified($normalizedPath);
[$rangeStart, $rangeEnd, $isPartial] = $this->resolveRange($rangeHeader, $size);
$contentLength = $rangeStart === null || $rangeEnd === null
? 0
@@ -203,52 +204,68 @@ public function setRangeRequestsEnabled(bool $enabled = true): void
}
/**
- * Stream a secure download to a writable resource and return the manifest.
+ * Yield the exact prepared response range as chunks.
*
- * @param string $path The file path to download.
- * @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).
- * @throws DownloadException If the output stream is invalid or download fails.
+ * The input stream is opened lazily when iteration starts and is closed in
+ * a finally block when iteration completes, fails, or the generator is
+ * disposed before exhaustion.
+ *
+ * @return \Generator
*/
- public function streamDownload(
- string $path,
- mixed $outputStream,
- ?string $downloadName = null,
- ?string $rangeHeader = null,
- ): DownloadStreamResult {
- if (!is_resource($outputStream)) {
- throw new DownloadException('Invalid output stream.');
+ public function streamChunks(DownloadPreparation $preparation): \Generator
+ {
+ $path = $this->validatePreparedDownload($preparation);
+ $remaining = $preparation->range->contentLength;
+ if ($remaining === 0) {
+ return;
}
- $manifest = $this->prepareDownload($path, $downloadName, $rangeHeader);
-
- $inputStream = FlysystemHelper::readStream($manifest->path);
+ $inputStream = $this->storageReadStream($path);
if (!is_resource($inputStream)) {
throw new DownloadException('Unable to open input stream for download.');
}
try {
- $this->seekStreamToOffset($inputStream, $manifest->range->start ?? 0);
+ $this->seekStreamToOffset($inputStream, $preparation->range->start ?? 0);
- $remaining = $manifest->range->contentLength;
- $bytesSent = 0;
while ($remaining > 0) {
$chunk = fread($inputStream, $this->readLength($remaining));
if (!is_string($chunk) || $chunk === '') {
- break;
+ throw new DownloadException('Download stream ended before the prepared range was complete.');
}
- $written = $this->writeFully($outputStream, $chunk);
- $bytesSent += $written;
- $remaining -= $written;
+ $remaining -= strlen($chunk);
+ yield $chunk;
}
} finally {
fclose($inputStream);
}
+ }
+
+ /**
+ * Stream a secure download to a writable resource and return the manifest.
+ *
+ * @param string $path The file path to download.
+ * @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).
+ * @throws DownloadException If the output stream is invalid or download fails.
+ */
+ public function streamDownload(
+ string $path,
+ mixed $outputStream,
+ ?string $downloadName = null,
+ ?string $rangeHeader = null,
+ ): DownloadStreamResult {
+ if (!is_resource($outputStream)) {
+ throw new DownloadException('Invalid output stream.');
+ }
+
+ $manifest = $this->prepareDownload($path, $downloadName, $rangeHeader);
+ $bytesSent = 0;
- if ($bytesSent !== $manifest->range->contentLength) {
- throw new DownloadException('Incomplete download stream copy.');
+ foreach ($this->streamChunks($manifest) as $chunk) {
+ $bytesSent += $this->writeFully($outputStream, $chunk);
}
return new DownloadStreamResult($manifest, $bytesSent);
@@ -342,7 +359,7 @@ private function pathWithinAllowedRoot(string $path): bool
return true;
}
- return array_any($this->allowedRoots, fn($root) => FlysystemHelper::isSameOrDescendant($root, $path));
+ return array_any($this->allowedRoots, fn($root) => $this->storageIsSameOrDescendant($root, $path));
}
/**
@@ -368,7 +385,6 @@ private function resolveDownloadName(?string $downloadName, string $path): strin
/** @return array{int, int, true} */
private function resolveExplicitRange(string $startRaw, string $endRaw, int $size): array
{
-
$start = (int) $startRaw;
if ($start < 0 || $start >= $size) {
throw new DownloadException('Invalid range header.');
@@ -496,7 +512,7 @@ private function seekStreamToOffset(mixed $stream, int $offset): void
private function validateDownloadPath(string $path): void
{
- if (!FlysystemHelper::fileExists($path)) {
+ if (!$this->storageFileExists($path)) {
throw new FileNotFoundException("File not found at {$path}.");
}
@@ -526,6 +542,43 @@ private function validateExtension(string $extension): void
));
}
+ private function validatePreparedDownload(DownloadPreparation $preparation): string
+ {
+ $path = PathHelper::normalize($preparation->path);
+ $this->validateDownloadPath($path);
+ $this->validateExtension(pathinfo($path, PATHINFO_EXTENSION));
+
+ $size = $this->storageSize($path);
+ if ($this->maxDownloadSize > 0 && $size > $this->maxDownloadSize) {
+ throw new FileSizeExceededException('Download exceeds configured size limit.');
+ }
+
+ $lastModified = $this->storageLastModified($path);
+ if ($size !== $preparation->size || $lastModified !== $preparation->lastModified) {
+ throw new DownloadException('Prepared download metadata is stale.');
+ }
+
+ if ($size === 0) {
+ if (
+ $preparation->range->start !== null
+ || $preparation->range->end !== null
+ || $preparation->range->contentLength !== 0
+ ) {
+ throw new DownloadException('Prepared download range is no longer valid.');
+ }
+
+ return $path;
+ }
+
+ $start = $preparation->range->start;
+ $end = $preparation->range->end;
+ if (!is_int($start) || !is_int($end) || $start < 0 || $end >= $size) {
+ throw new DownloadException('Prepared download range is no longer valid.');
+ }
+
+ return $path;
+ }
+
private function writeFully(mixed $stream, string $payload): int
{
if (!is_resource($stream)) {
diff --git a/src/StreamHandler/MalwareScanMode.php b/src/StreamHandler/MalwareScanMode.php
new file mode 100644
index 00000000..8d12f176
--- /dev/null
+++ b/src/StreamHandler/MalwareScanMode.php
@@ -0,0 +1,17 @@
+size = $size;
+ }
+}
diff --git a/src/StreamHandler/MalwareScanStatus.php b/src/StreamHandler/MalwareScanStatus.php
new file mode 100644
index 00000000..8a61de49
--- /dev/null
+++ b/src/StreamHandler/MalwareScanStatus.php
@@ -0,0 +1,21 @@
+validateConfiguration();
+ }
+
+ public function providerId(): string
+ {
+ return 'clamav';
+ }
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ if ($request->size > $this->maxStreamBytes) {
+ throw new MalwareScannerException('ClamAV scan input exceeds the configured stream limit.');
+ }
+
+ $input = fopen($request->localPath, 'rb');
+ if (!is_resource($input)) {
+ throw new MalwareScannerException('Unable to open ClamAV scan input.');
+ }
+
+ $socket = $this->connect();
+ $chunkSize = max(1, $this->chunkSize);
+
+ try {
+ $this->writeFully($socket, "zINSTREAM\0");
+
+ while (!feof($input)) {
+ $chunk = fread($input, $chunkSize);
+ if (!is_string($chunk)) {
+ throw new MalwareScannerException('Unable to read ClamAV scan input.');
+ }
+ if ($chunk === '') {
+ if (feof($input)) {
+ break;
+ }
+
+ throw new MalwareScannerException('ClamAV scan input stalled.');
+ }
+
+ $this->writeFully($socket, pack('N', strlen($chunk)) . $chunk);
+ }
+
+ $this->writeFully($socket, pack('N', 0));
+
+ return $this->parseResponse($this->readResponse($socket));
+ } finally {
+ fclose($input);
+ fclose($socket);
+ }
+ }
+
+ /** @return resource */
+ private function connect(): mixed
+ {
+ $errorNumber = 0;
+ $errorMessage = '';
+ set_error_handler(static fn(): bool => true);
+
+ try {
+ $socket = stream_socket_client(
+ $this->endpoint,
+ $errorNumber,
+ $errorMessage,
+ $this->connectTimeoutSeconds,
+ STREAM_CLIENT_CONNECT,
+ );
+ } finally {
+ restore_error_handler();
+ }
+
+ if (!is_resource($socket)) {
+ throw new MalwareScannerException(
+ sprintf('Unable to connect to ClamAV daemon (%d).', $errorNumber),
+ );
+ }
+
+ $seconds = (int) floor($this->ioTimeoutSeconds);
+ $microseconds = (int) (($this->ioTimeoutSeconds - $seconds) * 1_000_000);
+ if (!stream_set_timeout($socket, $seconds, $microseconds)) {
+ fclose($socket);
+
+ throw new MalwareScannerException('Unable to configure ClamAV socket timeout.');
+ }
+
+ return $socket;
+ }
+
+ private function isLoopbackHost(string $host): bool
+ {
+ $normalized = strtolower(trim($host, '[]'));
+
+ return $normalized === 'localhost'
+ || $normalized === '::1'
+ || $normalized === '127.0.0.1'
+ || str_starts_with($normalized, '127.');
+ }
+
+ private function parseResponse(string $response): MalwareScanVerdict
+ {
+ $response = trim($response, "\0\r\n \t");
+ if ($response === '') {
+ throw new MalwareScannerException('ClamAV returned an empty response.');
+ }
+ if (str_ends_with($response, ': OK')) {
+ return MalwareScanVerdict::CLEAN;
+ }
+ if (str_ends_with($response, ' FOUND')) {
+ return MalwareScanVerdict::MALICIOUS;
+ }
+
+ throw new MalwareScannerException('ClamAV returned a scan error or unsupported response.');
+ }
+
+ /** @param resource $socket */
+ private function readResponse(mixed $socket): string
+ {
+ $response = '';
+
+ while (strlen($response) < $this->maxResponseBytes) {
+ $remaining = $this->maxResponseBytes - strlen($response);
+ if ($remaining < 1) {
+ break;
+ }
+
+ $readLength = max(1, min(4_096, $remaining));
+ $chunk = fread($socket, $readLength);
+ if (!is_string($chunk) || $chunk === '') {
+ $metadata = stream_get_meta_data($socket);
+ if ($metadata['timed_out']) {
+ throw new MalwareScannerException('ClamAV response timed out.');
+ }
+ if (!is_string($chunk)) {
+ throw new MalwareScannerException('Unable to read ClamAV response.');
+ }
+ if (feof($socket)) {
+ return $response;
+ }
+
+ continue;
+ }
+
+ $response .= $chunk;
+ $terminator = strpos($response, "\0");
+ if ($terminator !== false) {
+ return substr($response, 0, $terminator + 1);
+ }
+ }
+
+ throw new MalwareScannerException('ClamAV response exceeds the configured limit.');
+ }
+
+ private function validateConfiguration(): void
+ {
+ $this->validateLimits();
+ $this->validateEndpoint();
+ }
+
+ private function validateEndpoint(): void
+ {
+ if ($this->endpoint === '' || str_contains($this->endpoint, "\0")) {
+ throw new \InvalidArgumentException('ClamAV endpoint must not be empty.');
+ }
+
+ if (str_starts_with($this->endpoint, 'unix://')) {
+ if (strlen($this->endpoint) <= strlen('unix://')) {
+ throw new \InvalidArgumentException('ClamAV Unix socket path is required.');
+ }
+
+ return;
+ }
+
+ if (!str_starts_with($this->endpoint, 'tcp://')) {
+ throw new \InvalidArgumentException('ClamAV endpoint must use unix:// or tcp://.');
+ }
+
+ $parts = parse_url($this->endpoint);
+ $host = is_array($parts) ? ($parts['host'] ?? null) : null;
+ $port = is_array($parts) ? ($parts['port'] ?? null) : null;
+ if (!is_string($host) || $host === '' || !is_int($port)) {
+ throw new \InvalidArgumentException('ClamAV TCP endpoint must include a valid host and port.');
+ }
+ if (!$this->allowRemoteTcp && !$this->isLoopbackHost($host)) {
+ throw new \InvalidArgumentException(
+ 'Remote ClamAV TCP endpoints require explicit allowRemoteTcp=true.',
+ );
+ }
+ }
+
+ private function validateLimits(): void
+ {
+ if ($this->connectTimeoutSeconds <= 0 || $this->ioTimeoutSeconds <= 0) {
+ throw new \InvalidArgumentException('ClamAV timeouts must be positive.');
+ }
+ if ($this->chunkSize < 1 || $this->chunkSize > 1_048_576) {
+ throw new \InvalidArgumentException('ClamAV chunk size must be between 1 byte and 1 MiB.');
+ }
+ if ($this->maxResponseBytes < 128 || $this->maxResponseBytes > 65_536) {
+ throw new \InvalidArgumentException('ClamAV response limit must be between 128 bytes and 64 KiB.');
+ }
+ if ($this->maxStreamBytes < 1) {
+ throw new \InvalidArgumentException('ClamAV stream limit must be positive.');
+ }
+ }
+
+ /** @param resource $stream */
+ private function writeFully(mixed $stream, string $payload): void
+ {
+ $offset = 0;
+ $length = strlen($payload);
+
+ while ($offset < $length) {
+ $written = fwrite($stream, substr($payload, $offset));
+ if (!is_int($written) || $written < 1) {
+ $metadata = stream_get_meta_data($stream);
+ if ($metadata['timed_out']) {
+ throw new MalwareScannerException('ClamAV request timed out.');
+ }
+
+ throw new MalwareScannerException('Unable to write ClamAV request.');
+ }
+
+ $offset += $written;
+ }
+ }
+}
diff --git a/src/StreamHandler/UploadMaterialization.php b/src/StreamHandler/UploadMaterialization.php
new file mode 100644
index 00000000..6d0914ba
--- /dev/null
+++ b/src/StreamHandler/UploadMaterialization.php
@@ -0,0 +1,92 @@
+path);
+
+ $directory = $this->cleanupDirectory;
+ if ($directory !== null && is_dir($directory) && !is_link($directory)) {
+ self::runSilently(static fn(): bool => rmdir($directory));
+ }
+ }
+
+ /**
+ * @return array{
+ * error: int,
+ * size: int,
+ * tmp_name: string,
+ * name: string,
+ * type: string|null
+ * }
+ */
+ public function toFileArray(): array
+ {
+ return [
+ 'error' => $this->error,
+ 'size' => $this->size,
+ 'tmp_name' => $this->path,
+ 'name' => $this->clientFilename,
+ 'type' => $this->clientMediaType,
+ ];
+ }
+
+ private static function runSilently(callable $operation): mixed
+ {
+ set_error_handler(static fn(): bool => true);
+
+ try {
+ return $operation();
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ private static function unlinkSilently(string $path): void
+ {
+ if (!is_file($path) && !is_link($path)) {
+ return;
+ }
+
+ self::runSilently(static fn(): bool => unlink($path));
+ }
+}
diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php
index 14941b99..1cfddc63 100644
--- a/src/StreamHandler/UploadProcessor.php
+++ b/src/StreamHandler/UploadProcessor.php
@@ -4,12 +4,11 @@
namespace Infocyph\Pathwise\StreamHandler;
+use Infocyph\Pathwise\Exceptions\FileSizeExceededException;
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;
use Infocyph\Pathwise\Utils\PathHelper;
use Psr\Log\LoggerInterface;
@@ -39,7 +38,10 @@
* namingStrategy: string,
* validationProfile: string|null,
* hasMalwareScanner: bool,
- * requireMalwareScan: bool,
+ * malwareScannerClass: string|null,
+ * malwareScannerProvider: string|null,
+ * malwareScanMode: string,
+ * malwareScanStatus: string,
* strictContentTypeValidation: bool
* }
*/
@@ -86,7 +88,9 @@ class UploadProcessor
private LoggerInterface $logger;
- private mixed $malwareScanner = null;
+ private MalwareScanMode $malwareScanMode = MalwareScanMode::WHEN_CONFIGURED;
+
+ private ?MalwareScannerInterface $malwareScanner = null;
private int $maxChunkCount = 0;
@@ -100,8 +104,6 @@ class UploadProcessor
private string $namingStrategy = 'hash';
- private bool $requireMalwareScan = false;
-
private bool $strictContentTypeValidation = true;
private ?string $tempDir = null;
@@ -144,8 +146,8 @@ public function finalizeChunkUpload(string $uploadId): string
$this->validateFinalizedUpload($stagingPath);
$destination = $this->finalizeIncomingFile($stagingPath, $extension);
} catch (\Throwable $exception) {
- if (FlysystemHelper::fileExists($stagingPath)) {
- FlysystemHelper::delete($stagingPath);
+ if ($this->storageFileExists($stagingPath)) {
+ $this->storageDelete($stagingPath);
}
throw $exception;
@@ -176,8 +178,11 @@ public function getInfo(): array
'maxChunkSize' => $this->maxChunkSize,
'namingStrategy' => $this->namingStrategy,
'validationProfile' => $this->validationProfile,
- 'hasMalwareScanner' => is_callable($this->malwareScanner),
- 'requireMalwareScan' => $this->requireMalwareScan,
+ 'hasMalwareScanner' => $this->malwareScanner !== null,
+ 'malwareScannerClass' => $this->malwareScanner !== null ? $this->malwareScanner::class : null,
+ 'malwareScannerProvider' => $this->malwareScannerProvider(),
+ 'malwareScanMode' => $this->malwareScanMode->value,
+ 'malwareScanStatus' => $this->malwareScanStatus()->value,
'strictContentTypeValidation' => $this->strictContentTypeValidation,
];
}
@@ -203,6 +208,24 @@ public function ingestFile(array $file, array $metadata = []): string
return $this->processIncomingFile($file, false, $metadata);
}
+ /**
+ * Ingest a framework-neutral source through a Pathwise-owned local staging file.
+ *
+ * @param array $metadata Explicit audit metadata for the log entry.
+ */
+ public function ingestSource(UploadSource $source, array $metadata = []): string
+ {
+ $this->assertUploadDirectoryConfigured();
+ $this->assertSourceReady($source);
+ $materialization = $source->materialize($this->tempDir);
+
+ try {
+ return $this->processIncomingFile($materialization->toFileArray(), false, $metadata);
+ } finally {
+ $materialization->cleanup();
+ }
+ }
+
/**
* Process an upload chunk and persist resumable state.
*
@@ -220,9 +243,7 @@ public function processChunkUpload(
int $totalChunks,
string $originalFilename,
): ChunkUploadState {
- if (!isset($this->uploadDir) || $this->uploadDir === '') {
- throw new UploadException('Upload directory is not set.');
- }
+ $this->assertUploadDirectoryConfigured();
$chunkFile = $this->validateFile($chunkFile);
$this->validateChunkUploadRequest($chunkFile, $uploadId, $chunkIndex, $totalChunks, $originalFilename);
@@ -234,8 +255,8 @@ public function processChunkUpload(
$originalFilename,
): ChunkUploadState {
$chunkDirectory = $this->getChunkDirectory($uploadId);
- if (!FlysystemHelper::directoryExists($chunkDirectory)) {
- FlysystemHelper::createDirectory($chunkDirectory);
+ if (!$this->storageDirectoryExists($chunkDirectory)) {
+ $this->storageCreateDirectory($chunkDirectory);
}
/** @var ChunkManifest $manifest */
@@ -261,6 +282,33 @@ public function processChunkUpload(
});
}
+ /**
+ * Process a framework-neutral upload source as one resumable chunk.
+ */
+ public function processChunkUploadSource(
+ UploadSource $source,
+ string $uploadId,
+ int $chunkIndex,
+ int $totalChunks,
+ string $originalFilename,
+ ): ChunkUploadState {
+ $this->assertUploadDirectoryConfigured();
+ $this->assertSourceReady($source);
+ $materialization = $source->materialize($this->tempDir);
+
+ try {
+ return $this->processChunkUpload(
+ $materialization->toFileArray(),
+ $uploadId,
+ $chunkIndex,
+ $totalChunks,
+ $originalFilename,
+ );
+ } finally {
+ $materialization->cleanup();
+ }
+ }
+
/**
* Process the upload and save the file.
*
@@ -348,13 +396,17 @@ public function setLogger(LoggerInterface $logger): void
}
/**
- * Configure an optional malware scanner callback.
- *
- * Signature: fn(string $filePath, string $mimeType): bool
- *
- * @param callable $scanner The malware scanner callback.
+ * Configure malware scan policy.
*/
- public function setMalwareScanner(callable $scanner): void
+ public function setMalwareScanMode(MalwareScanMode $mode): void
+ {
+ $this->malwareScanMode = $mode;
+ }
+
+ /**
+ * Configure or clear the malware scanner used before content parsing.
+ */
+ public function setMalwareScanner(?MalwareScannerInterface $scanner): void
{
$this->malwareScanner = $scanner;
}
@@ -373,16 +425,6 @@ public function setNamingStrategy(string $namingStrategy): void
$this->namingStrategy = $namingStrategy;
}
- /**
- * Require malware scanning before upload acceptance.
- *
- * @param bool $required If true, require malware scanning.
- */
- public function setRequireMalwareScan(bool $required = true): void
- {
- $this->requireMalwareScan = $required;
- }
-
/**
* Enable strict content checks (MIME-extension agreement + magic signature).
*
@@ -429,6 +471,23 @@ public function setValidationSettings(array $allowedFileTypes, int $maxFileSize)
$this->validationProfile = null;
}
+ private function assertSourceReady(UploadSource $source): void
+ {
+ match ($source->error) {
+ UPLOAD_ERR_OK => null,
+ UPLOAD_ERR_NO_FILE => throw new UploadException('No file sent.'),
+ UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => throw new FileSizeExceededException('Exceeded file size limit.'),
+ default => throw new UploadException('Unknown errors.'),
+ };
+ }
+
+ private function assertUploadDirectoryConfigured(): void
+ {
+ if (!isset($this->uploadDir) || $this->uploadDir === '') {
+ throw new UploadException('Upload directory is not set.');
+ }
+ }
+
/**
* Generate a unique file name based on the strategy and caller info.
*/
@@ -437,7 +496,7 @@ private function generateFileName(?string $dataSource, string $extension): strin
$identifier = match ($this->namingStrategy) {
'timestamp' => sprintf('%d_%s', time(), bin2hex(random_bytes(8))),
default => $dataSource !== null
- ? FlysystemHelper::checksum($dataSource, 'sha256')
+ ? $this->storageChecksum($dataSource, 'sha256')
: bin2hex(random_bytes(32)),
};
if (!is_string($identifier)) {
@@ -451,6 +510,30 @@ private function generateFileName(?string $dataSource, string $extension): strin
: sprintf('upload_%s', $identifier);
}
+ private function malwareScannerProvider(): ?string
+ {
+ if (!$this->malwareScanner instanceof MalwareScannerProviderInterface) {
+ return null;
+ }
+
+ $provider = trim($this->malwareScanner->providerId());
+
+ return $provider !== '' ? $provider : null;
+ }
+
+ private function malwareScanStatus(): MalwareScanStatus
+ {
+ return match ($this->malwareScanMode) {
+ MalwareScanMode::OFF => MalwareScanStatus::DISABLED,
+ MalwareScanMode::REQUIRED => $this->malwareScanner === null
+ ? MalwareScanStatus::REQUIRED_UNCONFIGURED
+ : MalwareScanStatus::REQUIRED_READY,
+ MalwareScanMode::WHEN_CONFIGURED => $this->malwareScanner === null
+ ? MalwareScanStatus::UNCONFIGURED
+ : MalwareScanStatus::CONFIGURED,
+ };
+ }
+
/**
* @param array $file
* @param array $metadata
@@ -460,9 +543,7 @@ private function processIncomingFile(array $file, bool $requireHttpUpload, array
$logFileName = is_string($file['name'] ?? null) ? $file['name'] : null;
try {
- if (!isset($this->uploadDir) || $this->uploadDir === '') {
- throw new UploadException('Upload directory is not set.');
- }
+ $this->assertUploadDirectoryConfigured();
$file = $this->validateFile($file);
$tmpName = $file['tmp_name'];
@@ -470,7 +551,7 @@ private function processIncomingFile(array $file, bool $requireHttpUpload, array
throw new UploadException('File is not a valid HTTP upload.');
}
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
- $fileType = $this->validateUploadedPayload($tmpName, $extension, false);
+ $fileType = $this->validateUploadedPayload($tmpName, $extension);
$destination = $this->finalizeIncomingFile($tmpName, $extension);
$fileName = basename($destination);
diff --git a/src/StreamHandler/UploadSource.php b/src/StreamHandler/UploadSource.php
new file mode 100644
index 00000000..b2e904eb
--- /dev/null
+++ b/src/StreamHandler/UploadSource.php
@@ -0,0 +1,275 @@
+materializer)($target);
+ if (is_link($target) || !is_file($target)) {
+ throw new UploadException('Upload source did not produce a regular staging file.');
+ }
+ self::secureStagingFile($target);
+
+ clearstatcache(true, $target);
+ $size = filesize($target);
+ if (!is_int($size)) {
+ throw new UploadException('Unable to determine materialized upload size.');
+ }
+
+ return new UploadMaterialization(
+ path: $target,
+ size: $size,
+ clientFilename: $this->clientFilename,
+ clientMediaType: $this->clientMediaType,
+ error: $this->error,
+ cleanupDirectory: $directory,
+ );
+ } catch (\Throwable $exception) {
+ self::unlinkSilently($target);
+ self::removeDirectorySilently($directory);
+
+ if ($exception instanceof UploadException) {
+ throw $exception;
+ }
+
+ throw new UploadException('Unable to materialize upload source.', 0, $exception);
+ }
+ }
+
+ private static function allocateStagingDirectory(string $root): string
+ {
+ for ($attempt = 0; $attempt < 5; $attempt++) {
+ $directory = PathHelper::join($root, 'pathwise-upload-' . bin2hex(random_bytes(16)));
+ if (self::runSilently(static fn(): bool => mkdir($directory, 0700))) {
+ return $directory;
+ }
+ }
+
+ throw new UploadException('Unable to allocate upload staging directory.');
+ }
+
+ private static function copyStreamToTarget(mixed $stream, string $target): void
+ {
+ if (!is_resource($stream)) {
+ throw new UploadException('Upload source stream is no longer readable.');
+ }
+
+ $output = fopen($target, 'xb');
+ if (!is_resource($output)) {
+ throw new UploadException('Unable to create upload staging file.');
+ }
+
+ try {
+ if (stream_copy_to_stream($stream, $output) === false) {
+ throw new UploadException('Unable to copy upload source stream.');
+ }
+ } finally {
+ fclose($output);
+ }
+ }
+
+ private static function ensureDirectory(string $directory): void
+ {
+ if (is_dir($directory)) {
+ return;
+ }
+
+ if (!mkdir($directory, 0700, true) && !is_dir($directory)) {
+ throw new UploadException('Unable to create upload staging directory.');
+ }
+ }
+
+ private static function materializationRoot(?string $preferred): string
+ {
+ if ($preferred === null || trim($preferred) === '') {
+ return self::systemTempDirectory();
+ }
+
+ $normalized = PathHelper::normalize($preferred);
+ if (
+ PathHelper::hasScheme($normalized)
+ || (FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($normalized))
+ ) {
+ return self::systemTempDirectory();
+ }
+
+ $directory = PathHelper::toAbsolutePath($normalized);
+ self::ensureDirectory($directory);
+
+ return $directory;
+ }
+
+ private static function removeDirectorySilently(string $directory): void
+ {
+ if (!is_dir($directory) || is_link($directory)) {
+ return;
+ }
+
+ self::runSilently(static fn(): bool => rmdir($directory));
+ }
+
+ private static function runSilently(callable $operation): mixed
+ {
+ set_error_handler(static fn(): bool => true);
+
+ try {
+ return $operation();
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ private static function secureStagingFile(string $path): void
+ {
+ if (!self::runSilently(static fn(): bool => chmod($path, 0600))) {
+ throw new UploadException('Unable to secure upload staging file.');
+ }
+ }
+
+ private static function systemTempDirectory(): string
+ {
+ $directory = PathHelper::toAbsolutePath(sys_get_temp_dir());
+ self::ensureDirectory($directory);
+
+ return $directory;
+ }
+
+ private static function unlinkSilently(string $path): void
+ {
+ if (!is_file($path) && !is_link($path)) {
+ return;
+ }
+
+ self::runSilently(static fn(): bool => unlink($path));
+ }
+}
diff --git a/src/Utils/FileWatcher.php b/src/Utils/FileWatcher.php
index 22ab015b..c29f3038 100644
--- a/src/Utils/FileWatcher.php
+++ b/src/Utils/FileWatcher.php
@@ -6,23 +6,20 @@
use FilesystemIterator;
use Infocyph\Pathwise\Results\SnapshotDiff;
-
use Infocyph\Pathwise\Results\WatchResult;
+use InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* @phpstan-type SnapshotEntry array{mtime: int, size: int}
* @phpstan-type SnapshotMap array
- * @phpstan-type DiffReport array{created: list, modified: list, deleted: list}
*/
final class FileWatcher
{
/**
- * Compare snapshots and return change report.
- *
- * @param SnapshotMap $previousSnapshot The previous snapshot data.
- * @param SnapshotMap $currentSnapshot The current snapshot data.
+ * @param SnapshotMap $previousSnapshot
+ * @param SnapshotMap $currentSnapshot
*/
public static function diff(array $previousSnapshot, array $currentSnapshot): SnapshotDiff
{
@@ -49,16 +46,14 @@ public static function diff(array $previousSnapshot, array $currentSnapshot): Sn
}
}
+ sort($created);
+ sort($modified);
+ sort($deleted);
+
return new SnapshotDiff($created, $modified, $deleted);
}
- /**
- * Build a snapshot map for a file or directory.
- *
- * @param string $path The path to snapshot.
- * @param bool $recursive Whether to include subdirectories recursively.
- * @return SnapshotMap The snapshot map with file paths as keys.
- */
+ /** @return SnapshotMap */
public static function snapshot(string $path, bool $recursive = true): array
{
$normalized = PathHelper::normalize($path);
@@ -91,11 +86,7 @@ public static function snapshot(string $path, bool $recursive = true): array
: new FilesystemIterator($normalized, FilesystemIterator::SKIP_DOTS);
foreach ($iterator as $item) {
- if (!$item instanceof \SplFileInfo) {
- continue;
- }
-
- if ($item->isDir()) {
+ if (!$item instanceof \SplFileInfo || $item->isDir()) {
continue;
}
@@ -106,10 +97,7 @@ public static function snapshot(string $path, bool $recursive = true): array
}
$filePath = PathHelper::normalize($item->getPathname());
- $entries[$filePath] = [
- 'mtime' => $mtime,
- 'size' => $size,
- ];
+ $entries[$filePath] = ['mtime' => $mtime, 'size' => $size];
}
ksort($entries);
@@ -117,15 +105,6 @@ public static function snapshot(string $path, bool $recursive = true): array
return $entries;
}
- /**
- * Poll for file-system changes and invoke callback on each non-empty diff.
- *
- * @param string $path The path to watch.
- * @param callable $onChange Callback invoked when changes detected. Receives diff 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.
- */
public static function watch(
string $path,
callable $onChange,
@@ -133,29 +112,41 @@ public static function watch(
int $intervalMilliseconds = 500,
bool $recursive = true,
): WatchResult {
+ if ($durationSeconds < 1) {
+ throw new InvalidArgumentException('Watcher duration must be at least one second.');
+ }
+ if ($intervalMilliseconds < 10) {
+ throw new InvalidArgumentException('Watcher interval must be at least 10 milliseconds.');
+ }
+
$snapshot = self::snapshot($path, $recursive);
- $endAt = microtime(true) + max(1, $durationSeconds);
+ $endAt = microtime(true) + $durationSeconds;
$changeSets = 0;
- while (microtime(true) < $endAt) {
- usleep(max(10, $intervalMilliseconds) * 1000);
+ while (true) {
+ $remainingMicroseconds = (int) floor(($endAt - microtime(true)) * 1_000_000);
+ if ($remainingMicroseconds <= 0) {
+ break;
+ }
+
+ usleep(min($intervalMilliseconds * 1000, $remainingMicroseconds));
+ if (microtime(true) >= $endAt) {
+ break;
+ }
+
$current = self::snapshot($path, $recursive);
$diff = self::diff($snapshot, $current);
-
if (!$diff->isEmpty()) {
$onChange($diff);
$changeSets++;
}
-
$snapshot = $current;
}
return new WatchResult($snapshot, $changeSets);
}
- /**
- * @return SnapshotMap
- */
+ /** @return SnapshotMap */
private static function snapshotViaFlysystem(string $path, bool $recursive): array
{
$entries = [];
@@ -170,11 +161,7 @@ private static function snapshotViaFlysystem(string $path, bool $recursive): arr
$resolved = PathHelper::join($path, $relative);
$lastModified = $item->lastModified() ?? 0;
$fileSize = $item instanceof \League\Flysystem\FileAttributes ? ($item->fileSize() ?? 0) : 0;
-
- $entries[$resolved] = [
- 'mtime' => $lastModified,
- 'size' => $fileSize,
- ];
+ $entries[$resolved] = ['mtime' => $lastModified, 'size' => $fileSize];
}
ksort($entries);
diff --git a/tests/Feature/ArchiveSecurityTest.php b/tests/Feature/ArchiveSecurityTest.php
index e9239de9..2dcb4e20 100644
--- a/tests/Feature/ArchiveSecurityTest.php
+++ b/tests/Feature/ArchiveSecurityTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use Infocyph\Pathwise\DirectoryManager\DirectoryOperations;
+use Infocyph\Pathwise\Exceptions\CompressionException;
use Infocyph\Pathwise\Exceptions\UnsafeArchiveEntryException;
use Infocyph\Pathwise\FileManager\FileCompression;
use Infocyph\Pathwise\Security\ZipEntryValidator;
@@ -78,6 +79,39 @@
->toThrow(UnsafeArchiveEntryException::class, 'Symbolic-link ZIP entry');
});
+test('archive validation rejects special file entries', function () {
+ $zip = new ZipArchive();
+ expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue();
+ $zip->addFromString('unsafe-fifo', 'payload');
+ $zip->setExternalAttributesName('unsafe-fifo', ZipArchive::OPSYS_UNIX, 0010644 << 16);
+ $zip->close();
+
+ expect(fn () => (new FileCompression($this->archivePath))->decompress($this->extractPath))
+ ->toThrow(UnsafeArchiveEntryException::class, 'Special-file ZIP entry');
+});
+
+test('archive validation rejects case conflicting entry names', function () {
+ $zip = new ZipArchive();
+ expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue();
+ $zip->addFromString('Report.txt', 'one');
+ $zip->addFromString('report.txt', 'two');
+ $zip->close();
+
+ expect(fn () => (new FileCompression($this->archivePath))->decompress($this->extractPath))
+ ->toThrow(UnsafeArchiveEntryException::class, 'Duplicate or case-conflicting ZIP entry');
+});
+
+test('archive validation rejects file directory conflicts', function () {
+ $zip = new ZipArchive();
+ expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue();
+ $zip->addFromString('node', 'file');
+ $zip->addFromString('node/child.txt', 'child');
+ $zip->close();
+
+ expect(fn () => (new DirectoryOperations($this->extractPath))->unzip($this->archivePath))
+ ->toThrow(UnsafeArchiveEntryException::class, 'nested below an archive file');
+});
+
test('archive validation rejects extraction through an existing destination symlink', function () {
if (PHP_OS_FAMILY === 'Windows') {
$zip = new ZipArchive();
@@ -116,6 +150,20 @@
->and(file_exists($this->extractPath . DIRECTORY_SEPARATOR . 'safe.txt'))->toBeFalse();
});
+test('batch extraction rejects duplicate output targets before writing', function () {
+ $zip = new ZipArchive();
+ expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue();
+ $zip->addFromString('one.txt', 'one');
+ $zip->addFromString('two.txt', 'two');
+ $zip->close();
+
+ expect(fn () => (new FileCompression($this->archivePath))->batchExtractFiles(
+ ['one.txt' => 'same.txt', 'two.txt' => 'SAME.txt'],
+ $this->extractPath,
+ ))->toThrow(CompressionException::class, 'same extraction path')
+ ->and(file_exists($this->extractPath . DIRECTORY_SEPARATOR . 'same.txt'))->toBeFalse();
+});
+
test('archive limits are checked before creating destination content', function () {
$zip = new ZipArchive();
expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue();
@@ -143,3 +191,36 @@
->decompress($this->extractPath))
->toThrow(UnsafeArchiveEntryException::class);
});
+
+test('failed local extraction restores overwritten files and removes created files', function () {
+ file_put_contents($this->extractPath . DIRECTORY_SEPARATOR . 'existing.txt', 'original');
+ file_put_contents($this->extractPath . DIRECTORY_SEPARATOR . 'blocked', 'not-a-directory');
+
+ $zip = new ZipArchive();
+ expect($zip->open($this->archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE))->toBeTrue();
+ $zip->addFromString('existing.txt', 'replacement');
+ $zip->addFromString('blocked/child.txt', 'child');
+ $zip->close();
+
+ expect(fn () => (new FileCompression($this->archivePath))->decompress($this->extractPath))
+ ->toThrow(UnsafeArchiveEntryException::class)
+ ->and(file_get_contents($this->extractPath . DIRECTORY_SEPARATOR . 'existing.txt'))->toBe('original')
+ ->and(file_exists($this->extractPath . DIRECTORY_SEPARATOR . 'blocked' . DIRECTORY_SEPARATOR . 'child.txt'))->toBeFalse();
+});
+
+test('local ZIP creation refuses to follow symbolic links', function () {
+ if (PHP_OS_FAMILY === 'Windows') {
+ expect(PHP_OS_FAMILY)->toBe('Windows');
+
+ return;
+ }
+
+ $source = $this->securityRoot . DIRECTORY_SEPARATOR . 'source';
+ $outside = $this->securityRoot . DIRECTORY_SEPARATOR . 'outside.txt';
+ mkdir($source, 0755, true);
+ file_put_contents($outside, 'outside');
+ symlink($outside, $source . DIRECTORY_SEPARATOR . 'linked.txt');
+
+ expect(fn () => (new FileCompression($this->archivePath, true))->compress($source))
+ ->toThrow(CompressionException::class, 'Symbolic links are not followed');
+});
diff --git a/tests/Feature/AuditTrailTest.php b/tests/Feature/AuditTrailTest.php
index 57a718d6..47c57a15 100644
--- a/tests/Feature/AuditTrailTest.php
+++ b/tests/Feature/AuditTrailTest.php
@@ -32,6 +32,34 @@
}
});
+test('local audit state uses private permissions', function () {
+ if (PHP_OS_FAMILY === 'Windows') {
+ expect(true)->toBeTrue();
+
+ return;
+ }
+
+ $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_private_audit_', true);
+ $logFile = $root . DIRECTORY_SEPARATOR . 'state' . DIRECTORY_SEPARATOR . 'events.jsonl';
+
+ try {
+ $audit = new AuditTrail($logFile);
+ $audit->log('private');
+
+ $directoryMode = fileperms(dirname($logFile));
+ $fileMode = fileperms($logFile);
+
+ expect($directoryMode)->toBeInt()
+ ->and($directoryMode & 0777)->toBe(0700)
+ ->and($fileMode)->toBeInt()
+ ->and($fileMode & 0777)->toBe(0600);
+ } finally {
+ if (is_dir($root)) {
+ (new Infocyph\Pathwise\DirectoryManager\DirectoryOperations($root))->delete(true);
+ }
+ }
+});
+
test('it fails without corrupting the log when context cannot be encoded', function () {
$logFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('audit_', true) . '.jsonl';
$audit = new AuditTrail($logFile);
@@ -82,6 +110,20 @@
$files = array_values(array_filter($objects, static fn ($entry): bool => $entry->isFile()));
expect($files)->toHaveCount(2);
+
+ if (PHP_OS_FAMILY !== 'Windows') {
+ $firstPath = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $files[0]->path());
+ $fileMode = fileperms($firstPath);
+ $directoryMode = fileperms(dirname($firstPath));
+ $rootMode = fileperms($root);
+
+ expect($fileMode)->toBeInt()
+ ->and($fileMode & 0777)->toBe(0600)
+ ->and($directoryMode)->toBeInt()
+ ->and($directoryMode & 0777)->toBe(0700)
+ ->and($rootMode)->toBeInt()
+ ->and($rootMode & 0777)->toBe(0755);
+ }
} finally {
FlysystemHelper::unmount('audit-partition');
(new Infocyph\Pathwise\DirectoryManager\DirectoryOperations($root))->delete(true);
diff --git a/tests/Feature/ClamAvDaemonScannerTest.php b/tests/Feature/ClamAvDaemonScannerTest.php
new file mode 100644
index 00000000..50d3fcf9
--- /dev/null
+++ b/tests/Feature/ClamAvDaemonScannerTest.php
@@ -0,0 +1,295 @@
+, script: string, endpoint: string}
+ */
+function startFakeClamd(string $response, int $responseDelayMicroseconds = 0): array
+{
+ $script = tempnam(sys_get_temp_dir(), 'pathwise_fake_clamd_');
+ if ($script === false) {
+ throw new RuntimeException('Unable to create fake clamd script.');
+ }
+
+ $code = <<<'PHP'
+ 0) {
+ usleep($delay);
+ }
+
+ $response = base64_decode((string) ($argv[1] ?? ''), true);
+ if (!is_string($response)) {
+ exit(7);
+ }
+
+ fwrite($connection, $response);
+} finally {
+ fclose($connection);
+ fclose($server);
+}
+PHP;
+
+ file_put_contents($script, $code);
+
+ $pipes = [];
+ $process = proc_open(
+ [PHP_BINARY, $script, base64_encode($response), (string) $responseDelayMicroseconds],
+ [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+ if (!is_resource($process) || !isset($pipes[0], $pipes[1], $pipes[2])) {
+ if (is_resource($process)) {
+ proc_terminate($process);
+ proc_close($process);
+ }
+ unlink($script);
+
+ throw new RuntimeException('Unable to start fake clamd process.');
+ }
+
+ fclose($pipes[0]);
+ unset($pipes[0]);
+ $endpoint = fgets($pipes[1]);
+ if (!is_string($endpoint) || trim($endpoint) === '') {
+ $stderr = stream_get_contents($pipes[2]);
+ proc_terminate($process);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+ unlink($script);
+
+ throw new RuntimeException('Fake clamd failed to start: ' . (is_string($stderr) ? $stderr : ''));
+ }
+
+ return [
+ 'process' => $process,
+ 'pipes' => $pipes,
+ 'script' => $script,
+ 'endpoint' => 'tcp://' . trim($endpoint),
+ ];
+}
+
+/** @param array{process: resource, pipes: array, script: string, endpoint: string} $server */
+function stopFakeClamd(array $server): void
+{
+ foreach ($server['pipes'] as $pipe) {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+ }
+
+ $status = proc_get_status($server['process']);
+ if (is_array($status) && ($status['running'] ?? false)) {
+ proc_terminate($server['process']);
+ }
+ proc_close($server['process']);
+
+ if (is_file($server['script'])) {
+ unlink($server['script']);
+ }
+}
+
+function clamAvRequest(string $contents = 'scan-me'): MalwareScanRequest
+{
+ $path = tempnam(sys_get_temp_dir(), 'pathwise_clamd_input_');
+ if ($path === false) {
+ throw new RuntimeException('Unable to create ClamAV test input.');
+ }
+ file_put_contents($path, $contents);
+
+ return new MalwareScanRequest($path, 'txt');
+}
+
+test('clamav daemon scanner accepts a clean INSTREAM response', function (): void {
+ $server = startFakeClamd("stream: OK\0");
+ $request = clamAvRequest();
+
+ try {
+ $scanner = new ClamAvDaemonScanner($server['endpoint']);
+
+ expect($scanner->scan($request))->toBe(MalwareScanVerdict::CLEAN);
+ } finally {
+ if (is_file($request->localPath)) {
+ unlink($request->localPath);
+ }
+ stopFakeClamd($server);
+ }
+});
+
+test('clamav daemon scanner maps FOUND to malicious', function (): void {
+ $server = startFakeClamd("stream: Eicar-Test-Signature FOUND\0");
+ $request = clamAvRequest();
+
+ try {
+ $scanner = new ClamAvDaemonScanner($server['endpoint']);
+
+ expect($scanner->scan($request))->toBe(MalwareScanVerdict::MALICIOUS);
+ } finally {
+ if (is_file($request->localPath)) {
+ unlink($request->localPath);
+ }
+ stopFakeClamd($server);
+ }
+});
+
+test('clamav daemon scanner fails closed on daemon error responses', function (): void {
+ $server = startFakeClamd("stream: INSTREAM size limit exceeded. ERROR\0");
+ $request = clamAvRequest();
+
+ try {
+ $scanner = new ClamAvDaemonScanner($server['endpoint']);
+
+ expect(fn () => $scanner->scan($request))
+ ->toThrow(MalwareScannerException::class, 'scan error or unsupported response');
+ } finally {
+ if (is_file($request->localPath)) {
+ unlink($request->localPath);
+ }
+ stopFakeClamd($server);
+ }
+});
+
+test('clamav daemon scanner fails closed on unsupported responses', function (): void {
+ $server = startFakeClamd("stream: UNKNOWN\0");
+ $request = clamAvRequest();
+
+ try {
+ $scanner = new ClamAvDaemonScanner($server['endpoint']);
+
+ expect(fn () => $scanner->scan($request))
+ ->toThrow(MalwareScannerException::class, 'scan error or unsupported response');
+ } finally {
+ if (is_file($request->localPath)) {
+ unlink($request->localPath);
+ }
+ stopFakeClamd($server);
+ }
+});
+
+test('clamav daemon scanner bounds response wait time', function (): void {
+ $server = startFakeClamd("stream: OK\0", 200_000);
+ $request = clamAvRequest();
+
+ try {
+ $scanner = new ClamAvDaemonScanner(
+ endpoint: $server['endpoint'],
+ ioTimeoutSeconds: 0.05,
+ );
+
+ expect(fn () => $scanner->scan($request))
+ ->toThrow(MalwareScannerException::class, 'response timed out');
+ } finally {
+ if (is_file($request->localPath)) {
+ unlink($request->localPath);
+ }
+ stopFakeClamd($server);
+ }
+});
+
+test('clamav daemon scanner bounds response bytes', function (): void {
+ $server = startFakeClamd(str_repeat('x', 256));
+ $request = clamAvRequest();
+
+ try {
+ $scanner = new ClamAvDaemonScanner(
+ endpoint: $server['endpoint'],
+ maxResponseBytes: 128,
+ );
+
+ expect(fn () => $scanner->scan($request))
+ ->toThrow(MalwareScannerException::class, 'response exceeds the configured limit');
+ } finally {
+ if (is_file($request->localPath)) {
+ unlink($request->localPath);
+ }
+ stopFakeClamd($server);
+ }
+});
+
+test('clamav daemon scanner rejects inputs above its stream limit before connecting', function (): void {
+ $request = clamAvRequest('12345');
+
+ try {
+ $scanner = new ClamAvDaemonScanner(
+ endpoint: 'tcp://127.0.0.1:9',
+ maxStreamBytes: 4,
+ );
+
+ expect(fn () => $scanner->scan($request))
+ ->toThrow(MalwareScannerException::class, 'scan input exceeds the configured stream limit');
+ } finally {
+ if (is_file($request->localPath)) {
+ unlink($request->localPath);
+ }
+ }
+});
+
+test('clamav daemon scanner rejects remote TCP unless explicitly allowed', function (): void {
+ expect(fn () => new ClamAvDaemonScanner('tcp://192.0.2.10:3310'))
+ ->toThrow(InvalidArgumentException::class, 'allowRemoteTcp=true');
+});
diff --git a/tests/Feature/DownloadChunkStreamTest.php b/tests/Feature/DownloadChunkStreamTest.php
new file mode 100644
index 00000000..2e37add7
--- /dev/null
+++ b/tests/Feature/DownloadChunkStreamTest.php
@@ -0,0 +1,112 @@
+workingDir = downloadChunkTempDirectory();
+ $this->downloadProcessor = new DownloadProcessor();
+ $this->downloadProcessor->setAllowedRoots([$this->workingDir]);
+});
+
+afterEach(function (): void {
+ if (is_dir($this->workingDir)) {
+ FlysystemHelper::deleteDirectory($this->workingDir);
+ }
+
+ FlysystemHelper::reset();
+});
+
+test('it yields a prepared download using the configured chunk size', function (): void {
+ $path = PathHelper::join($this->workingDir, 'chunked.txt');
+ file_put_contents($path, 'abcdefghij');
+ $this->downloadProcessor->setChunkSize(4);
+
+ $manifest = $this->downloadProcessor->prepareDownload($path);
+ $chunks = iterator_to_array($this->downloadProcessor->streamChunks($manifest), false);
+
+ expect($chunks)->toBe(['abcd', 'efgh', 'ij'])
+ ->and(implode('', $chunks))->toBe('abcdefghij');
+});
+
+test('it yields only the exact prepared byte range', function (): void {
+ $path = PathHelper::join($this->workingDir, 'range.txt');
+ file_put_contents($path, '0123456789');
+ $this->downloadProcessor->setChunkSize(2);
+
+ $manifest = $this->downloadProcessor->prepareDownload($path, null, 'bytes=3-7');
+ $chunks = iterator_to_array($this->downloadProcessor->streamChunks($manifest), false);
+
+ expect($manifest->status)->toBe(206)
+ ->and($chunks)->toBe(['34', '56', '7'])
+ ->and(implode('', $chunks))->toBe('34567');
+});
+
+test('it models an empty prepared download as an empty chunk iterable', function (): void {
+ $path = PathHelper::join($this->workingDir, 'empty.txt');
+ touch($path);
+
+ $manifest = $this->downloadProcessor->prepareDownload($path);
+
+ expect(iterator_to_array($this->downloadProcessor->streamChunks($manifest), false))->toBe([]);
+});
+
+test('it revalidates download policy before streaming a prepared manifest', function (): void {
+ $outside = PathHelper::join($this->workingDir, 'outside');
+ $allowed = PathHelper::join($this->workingDir, 'allowed');
+ mkdir($outside, 0700);
+ mkdir($allowed, 0700);
+ $path = PathHelper::join($outside, 'report.txt');
+ file_put_contents($path, 'policy-content');
+
+ $this->downloadProcessor->setAllowedRoots([]);
+ $manifest = $this->downloadProcessor->prepareDownload($path);
+ $this->downloadProcessor->setAllowedRoots([$allowed]);
+
+ expect(fn () => iterator_to_array($this->downloadProcessor->streamChunks($manifest), false))
+ ->toThrow(DownloadException::class, 'outside allowed roots');
+});
+
+test('it rejects a prepared manifest when source metadata becomes stale', function (): void {
+ $path = PathHelper::join($this->workingDir, 'stale.txt');
+ file_put_contents($path, 'original');
+ $manifest = $this->downloadProcessor->prepareDownload($path);
+ file_put_contents($path, 'original-mutated');
+ clearstatcache(true, $path);
+
+ expect(fn () => iterator_to_array($this->downloadProcessor->streamChunks($manifest), false))
+ ->toThrow(DownloadException::class, 'metadata is stale');
+});
+
+test('disposing a partially consumed chunk generator releases the input file', function (): void {
+ $path = PathHelper::join($this->workingDir, 'release.txt');
+ $moved = PathHelper::join($this->workingDir, 'released.txt');
+ file_put_contents($path, 'abcdefghij');
+ $this->downloadProcessor->setChunkSize(4);
+
+ $manifest = $this->downloadProcessor->prepareDownload($path);
+ $chunks = $this->downloadProcessor->streamChunks($manifest);
+ $chunks->rewind();
+
+ expect($chunks->current())->toBe('abcd');
+
+ unset($chunks);
+
+ expect(rename($path, $moved))->toBeTrue()
+ ->and(file_get_contents($moved))->toBe('abcdefghij');
+});
diff --git a/tests/Feature/FileFacadeTest.php b/tests/Feature/FileFacadeTest.php
index 1f417c96..8ad4fd25 100644
--- a/tests/Feature/FileFacadeTest.php
+++ b/tests/Feature/FileFacadeTest.php
@@ -2,33 +2,26 @@
declare(strict_types=1);
-use Infocyph\Pathwise\PathwiseFacade;
use Infocyph\Pathwise\DirectoryManager\DirectoryOperations;
-use Infocyph\Pathwise\Storage\StorageFactory;
+use Infocyph\Pathwise\PathwiseFacade;
use Infocyph\Pathwise\Utils\FlysystemHelper;
-beforeEach(function () {
+beforeEach(function (): void {
FlysystemHelper::reset();
- StorageFactory::clearDrivers();
$this->workspace = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_file_facade_', true);
mkdir($this->workspace, 0755, true);
});
-afterEach(function () {
+afterEach(function (): void {
FlysystemHelper::reset();
- StorageFactory::clearDrivers();
-
- if (!is_dir($this->workspace)) {
- return;
+ if (is_dir($this->workspace)) {
+ (new DirectoryOperations($this->workspace))->delete(true);
}
-
- (new DirectoryOperations($this->workspace))->delete(true);
});
-test('it provides path-bound file accessors', function () {
+test('it provides path-bound file accessors', function (): void {
$filePath = $this->workspace . DIRECTORY_SEPARATOR . 'sample.txt';
$entry = PathwiseFacade::at($filePath);
-
$entry->file()->create("line-1\n");
$writer = $entry->writer(true);
@@ -47,7 +40,7 @@
->and($entry->metadata())->toBeArray();
});
-test('it provides path-bound directory and compression accessors', function () {
+test('it provides path-bound directory and compression accessors', function (): void {
$sourceDir = $this->workspace . DIRECTORY_SEPARATOR . 'source';
mkdir($sourceDir, 0755, true);
file_put_contents($sourceDir . DIRECTORY_SEPARATOR . 'a.txt', 'A');
@@ -60,20 +53,14 @@
PathwiseFacade::at($zipPath)->compression()->decompress($extractDir)->save();
expect(FlysystemHelper::fileExists($zipPath))->toBeTrue()
- ->and(FlysystemHelper::fileExists($extractDir . DIRECTORY_SEPARATOR . 'a.txt'))->toBeTrue()
->and(FlysystemHelper::read($extractDir . DIRECTORY_SEPARATOR . 'a.txt'))->toBe('A');
});
-test('it provides static gateways for processors policy storage and ops tooling', function () {
+test('it provides stateless gateways for processors policy storage and ops tooling', function (): void {
$root = $this->workspace . DIRECTORY_SEPARATOR . 'storage';
mkdir($root, 0755, true);
-
- PathwiseFacade::mountStorage('facade', [
- 'driver' => 'local',
- 'root' => $root,
- ]);
-
- FlysystemHelper::write('facade://data/file.txt', 'hello');
+ $filesystem = PathwiseFacade::createFilesystem(['driver' => 'local', 'root' => $root]);
+ $filesystem->write('data/file.txt', 'hello');
$upload = PathwiseFacade::upload();
$download = PathwiseFacade::download();
@@ -85,8 +72,7 @@
$stats = $queue->stats();
$auditFile = $this->workspace . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . 'audit.jsonl';
- $audit = PathwiseFacade::audit($auditFile);
- $audit->log('facade.test', ['ok' => true]);
+ PathwiseFacade::audit($auditFile)->log('facade.test', ['ok' => true]);
$watchPath = $this->workspace . DIRECTORY_SEPARATOR . 'watch.txt';
file_put_contents($watchPath, 'v1');
@@ -101,13 +87,13 @@
file_put_contents($dupDir . DIRECTORY_SEPARATOR . 'b.txt', 'dup');
$index = PathwiseFacade::index($dupDir);
$duplicates = PathwiseFacade::duplicates($dupDir);
-
$retention = PathwiseFacade::retain($this->workspace . DIRECTORY_SEPARATOR . 'empty-retention');
expect($upload)->toBeInstanceOf(\Infocyph\Pathwise\StreamHandler\UploadProcessor::class)
->and($download)->toBeInstanceOf(\Infocyph\Pathwise\StreamHandler\DownloadProcessor::class)
->and($policy->isAllowed('read', 'anything'))->toBeTrue()
- ->and(FlysystemHelper::read('facade://data/file.txt'))->toBe('hello')
+ ->and($filesystem->read('data/file.txt'))->toBe('hello')
+ ->and(FlysystemHelper::hasMount('facade'))->toBeFalse()
->and($stats['pending'])->toBe(1)
->and(FlysystemHelper::fileExists($auditFile))->toBeTrue()
->and($diff->modified)->toContain($watchPath)
@@ -115,6 +101,4 @@
->and($duplicates)->not->toBeEmpty()
->and($retention->deleted)->toBe([])
->and($retention->kept)->toBe([]);
-
- FlysystemHelper::unmount('facade');
});
diff --git a/tests/Feature/FileJobQueueTest.php b/tests/Feature/FileJobQueueTest.php
index e7965acf..c9968089 100644
--- a/tests/Feature/FileJobQueueTest.php
+++ b/tests/Feature/FileJobQueueTest.php
@@ -2,7 +2,9 @@
declare(strict_types=1);
+use Infocyph\Pathwise\Exceptions\QueueException;
use Infocyph\Pathwise\Queue\FileJobQueue;
+use Infocyph\Pathwise\Queue\QueueReservation;
use Infocyph\Pathwise\Utils\FlysystemHelper;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
@@ -12,8 +14,15 @@
});
afterEach(function () {
- if (is_file($this->queueFile)) {
- unlink($this->queueFile);
+ foreach (glob($this->queueFile . '.tmp.*') ?: [] as $temporary) {
+ if (is_file($temporary)) {
+ unlink($temporary);
+ }
+ }
+ foreach ([$this->queueFile, $this->queueFile . '.lock'] as $path) {
+ if (is_file($path)) {
+ unlink($path);
+ }
}
FlysystemHelper::reset();
});
@@ -21,22 +30,60 @@
test('it rejects malformed jobs instead of dropping them', function () {
new FileJobQueue($this->queueFile);
file_put_contents($this->queueFile, json_encode([
- 'pending' => [['id' => '', 'type' => 'x', 'payload' => [], 'priority' => 0, 'createdAt' => time()]],
+ 'version' => 1,
+ 'pending' => [[
+ 'id' => '',
+ 'type' => 'x',
+ 'payload' => [],
+ 'priority' => 0,
+ 'createdAt' => time(),
+ ]],
'processing' => [],
'failed' => [],
], JSON_THROW_ON_ERROR));
expect(fn () => (new FileJobQueue($this->queueFile))->stats())
- ->toThrow(RuntimeException::class, 'malformed job');
+ ->toThrow(QueueException::class, 'malformed job identifier');
+});
+
+test('queue-created local state and stable lock use private permissions', function () {
+ if (PHP_OS_FAMILY === 'Windows') {
+ expect(true)->toBeTrue();
+
+ return;
+ }
+
+ $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_private_queue_', true);
+ $queueFile = $root . DIRECTORY_SEPARATOR . 'state' . DIRECTORY_SEPARATOR . 'jobs.json';
+
+ try {
+ $queue = new FileJobQueue($queueFile);
+ $queue->enqueue('private');
+
+ $directoryMode = fileperms(dirname($queueFile));
+ $fileMode = fileperms($queueFile);
+ $lockMode = fileperms($queueFile . '.lock');
+
+ expect($directoryMode)->toBeInt()
+ ->and($directoryMode & 0777)->toBe(0700)
+ ->and($fileMode)->toBeInt()
+ ->and($fileMode & 0777)->toBe(0600)
+ ->and($lockMode)->toBeInt()
+ ->and($lockMode & 0777)->toBe(0600);
+ } finally {
+ if (is_dir($root)) {
+ (new Infocyph\Pathwise\DirectoryManager\DirectoryOperations($root))->delete(true);
+ }
+ }
});
test('it enforces payload and total job bounds', function () {
$queue = new FileJobQueue($this->queueFile, maxJobs: 1, maxPayloadBytes: 8);
expect(fn () => $queue->enqueue('too-large', ['value' => 'payload']))
- ->toThrow(RuntimeException::class, 'payload exceeds')
+ ->toThrow(QueueException::class, 'payload exceeds')
->and($queue->enqueue('first'))->toStartWith('job_')
- ->and(fn () => $queue->enqueue('second'))->toThrow(RuntimeException::class, 'job-count');
+ ->and(fn () => $queue->enqueue('second'))->toThrow(QueueException::class, 'job-count');
});
test('it rejects mounted and default-filesystem queue paths', function () {
@@ -46,26 +93,26 @@
try {
expect(fn () => new FileJobQueue('queue://jobs.json'))
- ->toThrow(RuntimeException::class, 'direct-local');
+ ->toThrow(QueueException::class, 'direct-local');
FlysystemHelper::setDefaultFilesystem(new Filesystem(new LocalFilesystemAdapter($root)));
expect(fn () => new FileJobQueue('jobs.json'))
- ->toThrow(RuntimeException::class, 'direct-local');
+ ->toThrow(QueueException::class, 'direct-local');
} finally {
FlysystemHelper::reset();
rmdir($root);
}
});
-test('it processes queued jobs by priority', function () {
+test('it processes queued jobs by priority with typed reservations', function () {
$queue = new FileJobQueue($this->queueFile);
$order = [];
$queue->enqueue('low', ['id' => 1], 1);
$queue->enqueue('high', ['id' => 2], 10);
- $result = $queue->process(function (array $job) use (&$order): void {
- $order[] = $job['type'];
+ $result = $queue->process(function (QueueReservation $reservation) use (&$order): void {
+ $order[] = $reservation->type;
});
expect($result->processed)->toBe(2)
@@ -75,7 +122,7 @@
test('it tracks failed jobs', function () {
$queue = new FileJobQueue($this->queueFile);
- $queue->enqueue('failing-job', [], 0);
+ $queue->enqueue('failing-job');
$result = $queue->process(function (): void {
throw new RuntimeException('boom');
@@ -102,12 +149,119 @@
->and($stats)->toMatchArray(['pending' => 1, 'processing' => 0, 'failed' => 1]);
});
-test('it creates opaque job identifiers and rejects corrupt queue data', function () {
+test('it exposes an explicit lease lifecycle', function () {
+ $queue = new FileJobQueue($this->queueFile, reservationTimeout: 30);
+ $jobId = $queue->enqueue('manual', ['key' => 'value'], 7);
+
+ $reservation = $queue->reserve();
+
+ expect($reservation)->toBeInstanceOf(QueueReservation::class)
+ ->and($reservation?->id)->toBe($jobId)
+ ->and($reservation?->leaseToken)->toMatch('/^lease_[a-f0-9]{32}$/')
+ ->and($reservation?->type)->toBe('manual')
+ ->and($reservation?->payload)->toBe(['key' => 'value'])
+ ->and($reservation?->priority)->toBe(7)
+ ->and($reservation?->expiresAt)->toBe(($reservation?->reservedAt ?? 0) + 30)
+ ->and($queue->stats())->toMatchArray(['pending' => 0, 'processing' => 1, 'failed' => 0]);
+
+ $renewed = $queue->renew($reservation);
+ expect($renewed->leaseToken)->toBe($reservation->leaseToken)
+ ->and($renewed->reservedAt)->toBeGreaterThanOrEqual($reservation->reservedAt);
+
+ $queue->release($renewed);
+ expect($queue->stats())->toMatchArray(['pending' => 1, 'processing' => 0, 'failed' => 0]);
+
+ $second = $queue->reserve();
+ expect($second)->toBeInstanceOf(QueueReservation::class)
+ ->and($second?->leaseToken)->not->toBe($reservation->leaseToken);
+
+ $queue->acknowledge($second);
+ expect($queue->stats())->toMatchArray(['pending' => 0, 'processing' => 0, 'failed' => 0]);
+});
+
+test('an expired or reclaimed lease cannot mutate queue state', function () {
+ $queue = new FileJobQueue($this->queueFile, reservationTimeout: 5);
+ $queue->enqueue('leased');
+ $workerA = $queue->reserve();
+ expect($workerA)->toBeInstanceOf(QueueReservation::class);
+
+ $state = json_decode((string) file_get_contents($this->queueFile), true, 512, JSON_THROW_ON_ERROR);
+ $state['processing'][0]['reservedAt'] = time() - 10;
+ file_put_contents($this->queueFile, json_encode($state, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
+
+ expect(fn () => $queue->acknowledge($workerA))->toThrow(QueueException::class, 'stale')
+ ->and(fn () => $queue->release($workerA))->toThrow(QueueException::class, 'stale')
+ ->and(fn () => $queue->renew($workerA))->toThrow(QueueException::class, 'stale')
+ ->and(fn () => $queue->fail($workerA, 'late failure'))->toThrow(QueueException::class, 'stale');
+
+ $workerB = $queue->reserve();
+ expect($workerB)->toBeInstanceOf(QueueReservation::class)
+ ->and($workerB?->id)->toBe($workerA?->id)
+ ->and($workerB?->leaseToken)->not->toBe($workerA?->leaseToken);
+
+ expect(fn () => $queue->acknowledge($workerA))->toThrow(QueueException::class, 'stale')
+ ->and($queue->stats())->toMatchArray(['pending' => 0, 'processing' => 1, 'failed' => 0]);
+
+ $queue->acknowledge($workerB);
+ expect($queue->stats())->toMatchArray(['pending' => 0, 'processing' => 0, 'failed' => 0]);
+});
+
+test('it creates opaque identifiers and rejects corrupt queue data', function () {
$queue = new FileJobQueue($this->queueFile);
$jobId = $queue->enqueue('opaque');
expect($jobId)->toMatch('/^job_[a-f0-9]{32}$/');
file_put_contents($this->queueFile, '{invalid');
- expect(fn() => $queue->stats())->toThrow(RuntimeException::class, 'invalid JSON');
+ expect(fn () => $queue->stats())->toThrow(QueueException::class, 'invalid JSON');
+});
+
+test('it rejects empty truncated and unsupported state instead of guessing recovery', function () {
+ $queue = new FileJobQueue($this->queueFile);
+ $queue->enqueue('kept');
+
+ file_put_contents($this->queueFile, '');
+ expect(fn () => new FileJobQueue($this->queueFile))->toThrow(QueueException::class, 'empty or truncated');
+
+ file_put_contents($this->queueFile, json_encode([
+ 'version' => 999,
+ 'pending' => [],
+ 'processing' => [],
+ 'failed' => [],
+ ], JSON_THROW_ON_ERROR));
+ expect(fn () => new FileJobQueue($this->queueFile))->toThrow(QueueException::class, 'version');
+});
+
+test('it discards orphan temporary state while preserving the committed queue', function () {
+ $queue = new FileJobQueue($this->queueFile);
+ $queue->enqueue('committed');
+
+ $orphan = $this->queueFile . '.tmp.state_' . str_repeat('a', 32);
+ file_put_contents($orphan, '{uncommitted');
+
+ $reopened = new FileJobQueue($this->queueFile);
+
+ expect(is_file($orphan))->toBeFalse()
+ ->and($reopened->stats())->toMatchArray(['pending' => 1, 'processing' => 0, 'failed' => 0]);
+});
+
+test('it rejects duplicate job identifiers across queue buckets', function () {
+ new FileJobQueue($this->queueFile);
+ $jobId = 'job_' . str_repeat('a', 32);
+ $job = [
+ 'id' => $jobId,
+ 'type' => 'duplicate',
+ 'payload' => [],
+ 'priority' => 0,
+ 'createdAt' => time(),
+ ];
+ file_put_contents($this->queueFile, json_encode([
+ 'version' => 1,
+ 'pending' => [$job],
+ 'processing' => [],
+ 'failed' => [[...$job, 'error' => 'failed', 'failedAt' => time()]],
+ ], JSON_THROW_ON_ERROR));
+
+ expect(fn () => new FileJobQueue($this->queueFile))
+ ->toThrow(QueueException::class, 'duplicate job identifier');
});
diff --git a/tests/Feature/MalwareScannerIntegrityTest.php b/tests/Feature/MalwareScannerIntegrityTest.php
new file mode 100644
index 00000000..ed9b96d7
--- /dev/null
+++ b/tests/Feature/MalwareScannerIntegrityTest.php
@@ -0,0 +1,51 @@
+setDirectorySettings($uploadDir);
+ $processor->setValidationSettings(['text/plain'], 1024 * 1024);
+ $processor->setMalwareScanner(new class implements MalwareScannerInterface {
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ file_put_contents($request->localPath, 'altered-content');
+
+ return MalwareScanVerdict::CLEAN;
+ }
+ });
+
+ try {
+ expect(fn () => $processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'integrity.txt',
+ ]))->toThrow(UploadException::class, 'Malware scanner modified scan input.');
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ if (is_dir($uploadDir)) {
+ FlysystemHelper::deleteDirectory($uploadDir);
+ }
+ FlysystemHelper::reset();
+ }
+});
diff --git a/tests/Feature/MalwareScannerProviderInfoTest.php b/tests/Feature/MalwareScannerProviderInfoTest.php
new file mode 100644
index 00000000..e3359f5a
--- /dev/null
+++ b/tests/Feature/MalwareScannerProviderInfoTest.php
@@ -0,0 +1,80 @@
+setDirectorySettings($directory);
+
+ return $processor;
+}
+
+test('upload info reports default malware scanner readiness', function (): void {
+ $processor = scannerInfoProcessor();
+
+ try {
+ $info = $processor->getInfo();
+
+ expect($info['malwareScanMode'])->toBe(MalwareScanMode::WHEN_CONFIGURED->value)
+ ->and($info['malwareScanStatus'])->toBe('unconfigured')
+ ->and($info['hasMalwareScanner'])->toBeFalse()
+ ->and($info['malwareScannerProvider'])->toBeNull();
+ } finally {
+ $directory = $processor->getInfo()['uploadDir'];
+ if (is_string($directory) && is_dir($directory)) {
+ rmdir($directory);
+ }
+ }
+});
+
+test('upload info exposes clamav provider metadata', function (): void {
+ $processor = scannerInfoProcessor();
+
+ try {
+ $processor->setMalwareScanner(new ClamAvDaemonScanner('tcp://127.0.0.1:3310'));
+ $info = $processor->getInfo();
+
+ expect($info['hasMalwareScanner'])->toBeTrue()
+ ->and($info['malwareScannerProvider'])->toBe('clamav')
+ ->and($info['malwareScanStatus'])->toBe('configured');
+
+ $processor->setMalwareScanMode(MalwareScanMode::REQUIRED);
+ expect($processor->getInfo()['malwareScanStatus'])->toBe('required_ready');
+ } finally {
+ $directory = $processor->getInfo()['uploadDir'];
+ if (is_string($directory) && is_dir($directory)) {
+ rmdir($directory);
+ }
+ }
+});
+
+test('custom scanners without provider metadata remain supported', function (): void {
+ $processor = scannerInfoProcessor();
+
+ try {
+ $processor->setMalwareScanner(new class implements MalwareScannerInterface {
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ unset($request);
+
+ return MalwareScanVerdict::CLEAN;
+ }
+ });
+
+ expect($processor->getInfo()['malwareScannerProvider'])->toBeNull();
+ } finally {
+ $directory = $processor->getInfo()['uploadDir'];
+ if (is_string($directory) && is_dir($directory)) {
+ rmdir($directory);
+ }
+ }
+});
diff --git a/tests/Feature/MalwareScannerTest.php b/tests/Feature/MalwareScannerTest.php
new file mode 100644
index 00000000..2338309c
--- /dev/null
+++ b/tests/Feature/MalwareScannerTest.php
@@ -0,0 +1,390 @@
+uploadDir = malwareScannerTempDirectory();
+ $this->processor = new UploadProcessor();
+ $this->processor->setDirectorySettings($this->uploadDir);
+ $this->processor->setValidationSettings(['text/plain'], 1024 * 1024);
+});
+
+afterEach(function (): void {
+ if (is_dir($this->uploadDir)) {
+ FlysystemHelper::deleteDirectory($this->uploadDir);
+ }
+
+ FlysystemHelper::reset();
+});
+
+test('default scan mode skips malware scanning when no scanner is configured', function (): void {
+ $input = malwareScannerInput();
+
+ try {
+ $destination = $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'plain.txt',
+ ]);
+ $info = $this->processor->getInfo();
+
+ expect(is_file($destination))->toBeTrue()
+ ->and($info['malwareScanMode'])->toBe(MalwareScanMode::WHEN_CONFIGURED->value)
+ ->and($info['malwareScanStatus'])->toBe('unconfigured')
+ ->and($info['hasMalwareScanner'])->toBeFalse();
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('off scan mode bypasses a configured scanner', function (): void {
+ $scanner = new class implements MalwareScannerInterface {
+ public bool $called = false;
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ $this->called = true;
+ unset($request);
+
+ return MalwareScanVerdict::MALICIOUS;
+ }
+ };
+ $this->processor->setMalwareScanner($scanner);
+ $this->processor->setMalwareScanMode(MalwareScanMode::OFF);
+ $input = malwareScannerInput();
+
+ try {
+ $destination = $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'off.txt',
+ ]);
+ $info = $this->processor->getInfo();
+
+ expect($scanner->called)->toBeFalse()
+ ->and(is_file($destination))->toBeTrue()
+ ->and($info['malwareScanStatus'])->toBe('disabled');
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('clean verdict accepts content through a private local scan copy', function (): void {
+ $scanner = new class implements MalwareScannerInterface {
+ public ?MalwareScanRequest $request = null;
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ $this->request = $request;
+
+ return MalwareScanVerdict::CLEAN;
+ }
+ };
+ $this->processor->setMalwareScanner($scanner);
+ $input = malwareScannerInput();
+
+ try {
+ $destination = $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'clean.txt',
+ ]);
+
+ expect($scanner->request)->toBeInstanceOf(MalwareScanRequest::class)
+ ->and($scanner->request?->localPath)->not->toBe($input)
+ ->and($scanner->request?->extension)->toBe('txt')
+ ->and($scanner->request?->size)->toBe(strlen('scanner-content'))
+ ->and(is_file($destination))->toBeTrue()
+ ->and(is_file((string) $scanner->request?->localPath))->toBeFalse();
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('all non-clean verdicts fail closed', function (MalwareScanVerdict $verdict): void {
+ $scanner = new class($verdict) implements MalwareScannerInterface {
+ public function __construct(private MalwareScanVerdict $verdict) {}
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ unset($request);
+
+ return $this->verdict;
+ }
+ };
+ $this->processor->setMalwareScanner($scanner);
+ $input = malwareScannerInput();
+
+ try {
+ expect(fn () => $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'unsafe.txt',
+ ]))->toThrow(UploadException::class, 'Malware scan rejected the upload.');
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+})->with([
+ 'malicious' => MalwareScanVerdict::MALICIOUS,
+ 'suspicious' => MalwareScanVerdict::SUSPICIOUS,
+ 'unknown' => MalwareScanVerdict::UNKNOWN,
+]);
+
+test('malware scan happens before mime allowlist validation', function (): void {
+ $scanner = new class implements MalwareScannerInterface {
+ public bool $called = false;
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ $this->called = is_file($request->localPath);
+
+ return MalwareScanVerdict::MALICIOUS;
+ }
+ };
+ $this->processor->setValidationSettings(['image/png'], 1024 * 1024);
+ $this->processor->setMalwareScanner($scanner);
+ $input = malwareScannerInput('plain text');
+
+ try {
+ expect(fn () => $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'malicious.txt',
+ ]))->toThrow(UploadException::class, 'Malware scan rejected the upload.')
+ ->and($scanner->called)->toBeTrue();
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('blocked extension is rejected before scanner execution', function (): void {
+ $scanner = new class implements MalwareScannerInterface {
+ public bool $called = false;
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ $this->called = true;
+ unset($request);
+
+ return MalwareScanVerdict::CLEAN;
+ }
+ };
+ $this->processor->setMalwareScanner($scanner);
+ $input = malwareScannerInput();
+
+ try {
+ expect(fn () => $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'blocked.php',
+ ]))->toThrow(UploadException::class, 'Blocked file extension.')
+ ->and($scanner->called)->toBeFalse();
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('actual size is enforced before scanning even when metadata understates it', function (): void {
+ $scanner = new class implements MalwareScannerInterface {
+ public bool $called = false;
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ $this->called = true;
+ unset($request);
+
+ return MalwareScanVerdict::CLEAN;
+ }
+ };
+ $this->processor->setValidationSettings(['text/plain'], 4);
+ $this->processor->setMalwareScanner($scanner);
+ $input = malwareScannerInput('longer-than-four');
+
+ try {
+ expect(fn () => $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => 1,
+ 'tmp_name' => $input,
+ 'name' => 'oversized.txt',
+ ]))->toThrow(FileSizeExceededException::class, 'Exceeded file size limit.')
+ ->and($scanner->called)->toBeFalse();
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('required scan mode fails before content parsing when scanner is missing', function (): void {
+ $this->processor->setMalwareScanMode(MalwareScanMode::REQUIRED);
+ $this->processor->setValidationSettings(['image/png'], 1024 * 1024);
+ $input = malwareScannerInput('not-an-image');
+
+ try {
+ expect(fn () => $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'payload.txt',
+ ]))->toThrow(UploadException::class, 'Malware scanner is required but not configured.')
+ ->and($this->processor->getInfo()['malwareScanStatus'])->toBe('required_unconfigured');
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('scanner backend failures fail closed without leaking backend messages', function (): void {
+ $scanner = new class implements MalwareScannerInterface {
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ unset($request);
+
+ throw new RuntimeException('sensitive-scanner-backend-detail');
+ }
+ };
+ $this->processor->setMalwareScanner($scanner);
+ $input = malwareScannerInput();
+
+ try {
+ $caught = null;
+ try {
+ $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'backend.txt',
+ ]);
+ } catch (UploadException $exception) {
+ $caught = $exception;
+ }
+
+ expect($caught)->toBeInstanceOf(UploadException::class)
+ ->and($caught?->getMessage())->toBe('Malware scanner failed.')
+ ->and($caught?->getMessage())->not->toContain('sensitive-scanner-backend-detail')
+ ->and($caught?->getPrevious())->toBeInstanceOf(RuntimeException::class)
+ ->and($caught?->getPrevious()?->getMessage())->toBe('sensitive-scanner-backend-detail');
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('scanner mutation of the private scan copy fails closed', function (): void {
+ $scanner = new class implements MalwareScannerInterface {
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ file_put_contents($request->localPath, 'mutated');
+
+ return MalwareScanVerdict::CLEAN;
+ }
+ };
+ $this->processor->setMalwareScanner($scanner);
+ $input = malwareScannerInput();
+
+ try {
+ expect(fn () => $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => filesize($input),
+ 'tmp_name' => $input,
+ 'name' => 'mutation.txt',
+ ]))->toThrow(UploadException::class, 'Malware scanner modified scan input.');
+ } finally {
+ if (is_file($input)) {
+ unlink($input);
+ }
+ }
+});
+
+test('mounted files are materialized to a local private file before scanning', function (): void {
+ $mountRoot = malwareScannerTempDirectory();
+ FlysystemHelper::mount('scanner-mount', new Filesystem(new LocalFilesystemAdapter($mountRoot)));
+ FlysystemHelper::write('scanner-mount://input.txt', 'mounted-scan-content');
+
+ $scanner = new class implements MalwareScannerInterface {
+ public ?string $scanPath = null;
+
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ $this->scanPath = $request->localPath;
+
+ return is_file($request->localPath)
+ ? MalwareScanVerdict::CLEAN
+ : MalwareScanVerdict::UNKNOWN;
+ }
+ };
+ $this->processor->setMalwareScanner($scanner);
+
+ try {
+ $destination = $this->processor->ingestFile([
+ 'error' => UPLOAD_ERR_OK,
+ 'size' => strlen('mounted-scan-content'),
+ 'tmp_name' => 'scanner-mount://input.txt',
+ 'name' => 'input.txt',
+ ]);
+
+ expect($scanner->scanPath)->not->toBeNull()
+ ->and(str_contains((string) $scanner->scanPath, '://'))->toBeFalse()
+ ->and(is_file((string) $scanner->scanPath))->toBeFalse()
+ ->and(is_file($destination))->toBeTrue();
+ } finally {
+ FlysystemHelper::unmount('scanner-mount');
+ if (is_dir($mountRoot)) {
+ FlysystemHelper::deleteDirectory($mountRoot);
+ }
+ }
+});
diff --git a/tests/Feature/NativeExecutionTest.php b/tests/Feature/NativeExecutionTest.php
index abd2b193..28bfc356 100644
--- a/tests/Feature/NativeExecutionTest.php
+++ b/tests/Feature/NativeExecutionTest.php
@@ -6,6 +6,9 @@
use Infocyph\Pathwise\Exceptions\NativeExecutionException;
use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException;
use Infocyph\Pathwise\FileManager\FileOperations;
+use Infocyph\Pathwise\Native\NativeCommandRunner;
+use Infocyph\Pathwise\Native\NativeExecutionFailure;
+use Infocyph\Pathwise\Native\NativeExecutionLimits;
use Infocyph\Pathwise\Native\NativeOperationsAdapter;
use Infocyph\Pathwise\Results\NativeExecutionResult;
use Infocyph\Pathwise\Utils\FlysystemHelper;
@@ -50,7 +53,182 @@
expect($result)->toBeInstanceOf(NativeExecutionResult::class)
->and($result->exitCode)->toBeInt()
- ->and($result->output)->toBeArray();
+ ->and($result->output)->toBeArray()
+ ->and($result->stdout)->toBeArray()
+ ->and($result->stderr)->toBeArray();
+});
+
+test('bounded native execution is capability based instead of emulated', function () {
+ if (PHP_OS_FAMILY === 'Windows') {
+ $result = NativeCommandRunner::run([PHP_BINARY, '-r', 'echo "unused";']);
+
+ expect(NativeCommandRunner::supportsBoundedExecution())->toBeFalse()
+ ->and($result->success)->toBeFalse()
+ ->and($result->failure)->toBe(NativeExecutionFailure::UNSUPPORTED)
+ ->and(NativeOperationsAdapter::canUseNativeFileCopy())->toBeFalse()
+ ->and(NativeOperationsAdapter::canUseNativeDirectoryCopy())->toBeFalse()
+ ->and(NativeOperationsAdapter::canUseNativeSearch())->toBeFalse()
+ ->and(NativeOperationsAdapter::canUseNativeZipCompression())->toBeFalse()
+ ->and(NativeOperationsAdapter::canUseNativeZipDecompression())->toBeFalse();
+
+ return;
+ }
+
+ expect(NativeCommandRunner::supportsBoundedExecution())->toBeTrue();
+});
+
+test('native command runner captures stdout and stderr without shell execution', function () {
+ if (!NativeCommandRunner::supportsBoundedExecution()) {
+ expect(NativeCommandRunner::run([PHP_BINARY, '-r', 'echo "unused";'])->failure)
+ ->toBe(NativeExecutionFailure::UNSUPPORTED);
+
+ return;
+ }
+
+ $result = NativeCommandRunner::run([
+ PHP_BINARY,
+ '-r',
+ 'fwrite(STDOUT, "out\\n"); fwrite(STDERR, "err\\n");',
+ ]);
+
+ expect($result->success)->toBeTrue()
+ ->and($result->failure)->toBeNull()
+ ->and($result->exitCode)->toBe(0)
+ ->and($result->stdout)->toBe(['out'])
+ ->and($result->stderr)->toBe(['err'])
+ ->and($result->output)->toBe(['out', 'err']);
+});
+
+test('native command runner terminates commands that exceed the deadline', function () {
+ if (!NativeCommandRunner::supportsBoundedExecution()) {
+ expect(NativeCommandRunner::run([PHP_BINARY, '-r', 'echo "unused";'])->failure)
+ ->toBe(NativeExecutionFailure::UNSUPPORTED);
+
+ return;
+ }
+
+ $started = microtime(true);
+ $result = NativeCommandRunner::run(
+ [PHP_BINARY, '-r', 'usleep(5000000);'],
+ limits: new NativeExecutionLimits(
+ timeoutSeconds: 0.10,
+ terminationGraceSeconds: 0.10,
+ pollIntervalMicroseconds: 1_000,
+ ),
+ );
+ $elapsed = microtime(true) - $started;
+
+ expect($result->success)->toBeFalse()
+ ->and($result->failure)->toBe(NativeExecutionFailure::TIMEOUT)
+ ->and($result->exitCode)->toBe(124)
+ ->and($elapsed)->toBeLessThan(2.0);
+});
+
+test('native command runner bounds stdout', function () {
+ if (!NativeCommandRunner::supportsBoundedExecution()) {
+ expect(NativeCommandRunner::run([PHP_BINARY, '-r', 'echo "unused";'])->failure)
+ ->toBe(NativeExecutionFailure::UNSUPPORTED);
+
+ return;
+ }
+
+ $result = NativeCommandRunner::run(
+ [PHP_BINARY, '-r', 'fwrite(STDOUT, str_repeat("x", 8192));'],
+ limits: new NativeExecutionLimits(
+ stdoutBytes: 1_024,
+ stderrBytes: 1_024,
+ terminationGraceSeconds: 0.10,
+ pollIntervalMicroseconds: 1_000,
+ ),
+ );
+
+ expect($result->success)->toBeFalse()
+ ->and($result->failure)->toBe(NativeExecutionFailure::STDOUT_LIMIT)
+ ->and($result->exitCode)->toBe(125)
+ ->and(strlen(implode("\n", $result->stdout)))->toBeLessThanOrEqual(1_024);
+});
+
+test('native command runner bounds stderr', function () {
+ if (!NativeCommandRunner::supportsBoundedExecution()) {
+ expect(NativeCommandRunner::run([PHP_BINARY, '-r', 'echo "unused";'])->failure)
+ ->toBe(NativeExecutionFailure::UNSUPPORTED);
+
+ return;
+ }
+
+ $result = NativeCommandRunner::run(
+ [PHP_BINARY, '-r', 'fwrite(STDERR, str_repeat("e", 8192));'],
+ limits: new NativeExecutionLimits(
+ stdoutBytes: 1_024,
+ stderrBytes: 1_024,
+ terminationGraceSeconds: 0.10,
+ pollIntervalMicroseconds: 1_000,
+ ),
+ );
+
+ expect($result->success)->toBeFalse()
+ ->and($result->failure)->toBe(NativeExecutionFailure::STDERR_LIMIT)
+ ->and($result->exitCode)->toBe(125)
+ ->and(strlen(implode("\n", $result->stderr)))->toBeLessThanOrEqual(1_024);
+});
+
+test('native command runner drains stdout and stderr concurrently without deadlock', function () {
+ if (!NativeCommandRunner::supportsBoundedExecution()) {
+ expect(NativeCommandRunner::run([PHP_BINARY, '-r', 'echo "unused";'])->failure)
+ ->toBe(NativeExecutionFailure::UNSUPPORTED);
+
+ return;
+ }
+
+ $script = <<<'PHP'
+for ($i = 0; $i < 32; $i++) {
+ fwrite(STDOUT, str_repeat('o', 4096));
+ fwrite(STDERR, str_repeat('e', 4096));
+}
+PHP;
+
+ $result = NativeCommandRunner::run(
+ [PHP_BINARY, '-r', $script],
+ limits: new NativeExecutionLimits(
+ stdoutBytes: 262_144,
+ stderrBytes: 262_144,
+ timeoutSeconds: 5.0,
+ pollIntervalMicroseconds: 1_000,
+ ),
+ );
+
+ expect($result->success)->toBeTrue()
+ ->and($result->failure)->toBeNull()
+ ->and(strlen(implode("\n", $result->stdout)))->toBe(131_072)
+ ->and(strlen(implode("\n", $result->stderr)))->toBe(131_072);
+});
+
+test('native command runner exposes non-zero exit codes as typed failures', function () {
+ if (!NativeCommandRunner::supportsBoundedExecution()) {
+ expect(NativeCommandRunner::run([PHP_BINARY, '-r', 'echo "unused";'])->failure)
+ ->toBe(NativeExecutionFailure::UNSUPPORTED);
+
+ return;
+ }
+
+ $result = NativeCommandRunner::run([PHP_BINARY, '-r', 'exit(7);']);
+
+ expect($result->success)->toBeFalse()
+ ->and($result->failure)->toBe(NativeExecutionFailure::EXIT_CODE)
+ ->and($result->exitCode)->toBe(7);
+});
+
+test('native execution limits reject invalid bounds', function () {
+ expect(fn () => new NativeExecutionLimits(timeoutSeconds: 0.0))
+ ->toThrow(InvalidArgumentException::class)
+ ->and(fn () => new NativeExecutionLimits(stdoutBytes: 0))
+ ->toThrow(InvalidArgumentException::class)
+ ->and(fn () => new NativeExecutionLimits(stderrBytes: 0))
+ ->toThrow(InvalidArgumentException::class)
+ ->and(fn () => new NativeExecutionLimits(terminationGraceSeconds: 0.0))
+ ->toThrow(InvalidArgumentException::class)
+ ->and(fn () => new NativeExecutionLimits(pollIntervalMicroseconds: 0))
+ ->toThrow(InvalidArgumentException::class);
});
test('forced native file operations reject mounted paths', function () {
diff --git a/tests/Feature/OptionalAdapterContractTest.php b/tests/Feature/OptionalAdapterContractTest.php
index d4ef9abf..068a37a5 100644
--- a/tests/Feature/OptionalAdapterContractTest.php
+++ b/tests/Feature/OptionalAdapterContractTest.php
@@ -40,7 +40,10 @@
if (class_exists($memoryAdapterClass)) {
test('storage-neutral contracts run against the in-memory adapter', function () {
- StorageFactory::mount('memory-contract', ['driver' => 'inmemory']);
+ FlysystemHelper::mount(
+ 'memory-contract',
+ StorageFactory::createFilesystem(['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');
@@ -55,10 +58,13 @@
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],
- ]);
+ FlysystemHelper::mount(
+ 'read-only-contract',
+ StorageFactory::createFilesystem([
+ 'driver' => 'read-only',
+ 'constructor' => [$localAdapter],
+ ]),
+ );
$file = new FileOperations('read-only-contract://readable.txt');
expect($file->read())->toBe('read-only')
@@ -69,10 +75,13 @@
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'],
- ]);
+ FlysystemHelper::mount(
+ 'prefix-contract',
+ StorageFactory::createFilesystem([
+ 'driver' => 'path-prefixing',
+ 'constructor' => [new LocalFilesystemAdapter($this->adapterRoot), 'tenant-a'],
+ ]),
+ );
$file = new FileOperations('prefix-contract://nested/file.txt');
$file->create('prefixed');
diff --git a/tests/Feature/PolicyEngineTest.php b/tests/Feature/PolicyEngineTest.php
index a2f80ad0..06099cb9 100644
--- a/tests/Feature/PolicyEngineTest.php
+++ b/tests/Feature/PolicyEngineTest.php
@@ -5,6 +5,20 @@
use Infocyph\Pathwise\Exceptions\PolicyViolationException;
use Infocyph\Pathwise\Security\PolicyEngine;
+test('it denies unmatched operations by default', function () {
+ $policy = new PolicyEngine();
+
+ expect($policy->isAllowed('read', '/tmp/file.txt'))->toBeFalse()
+ ->and(fn () => $policy->assertAllowed('read', '/tmp/file.txt'))
+ ->toThrow(PolicyViolationException::class);
+});
+
+test('it allows explicitly permissive default policy', function () {
+ $policy = new PolicyEngine(defaultAllow: true);
+
+ expect($policy->isAllowed('read', '/tmp/file.txt'))->toBeTrue();
+});
+
test('it applies allow and deny rules with last match winning', function () {
$policy = new PolicyEngine();
$policy->deny('*', '*');
diff --git a/tests/Feature/SafeFileWriterTest.php b/tests/Feature/SafeFileWriterTest.php
index aa87d9e8..939497d6 100644
--- a/tests/Feature/SafeFileWriterTest.php
+++ b/tests/Feature/SafeFileWriterTest.php
@@ -54,7 +54,6 @@
->and($writer->count())->toBe(2);
});
-
test('it writes CSV data to the file', function () {
$writer = new SafeFileWriter($this->tempFilePath);
$writer->writeCsv(['Name', 'Age']);
@@ -82,7 +81,6 @@
expect($normalizedContent)->toBe($expectedJson);
});
-
test('it writes XML data to the file', function () {
$xml = new SimpleXMLElement('- Value
');
$writer = new SafeFileWriter($this->tempFilePath);
@@ -96,7 +94,6 @@
$writer->writeSerialized(['key' => 'value']);
$writer->writeSerialized(['another' => 'entry']);
- // Deserialize all lines
$lines = file($this->tempFilePath, FILE_IGNORE_NEW_LINES);
$content = array_map(fn($line) => unserialize($line), $lines);
expect($content)->toBe([
@@ -105,7 +102,6 @@
]);
});
-
test('it writes a JSON array to the file', function () {
$writer = new SafeFileWriter($this->tempFilePath);
$writer->writeJsonArray([['key' => 'value']]);
@@ -133,7 +129,6 @@
expect($normalizedContent)->toBe("Locked Content\n");
});
-
test('it counts total write operations', function () {
$writer = new SafeFileWriter($this->tempFilePath);
$writer->writeLine('Line 1');
@@ -171,7 +166,7 @@
->and(json_encode($writer))->toContain('"filename"');
});
-test('it supports atomic write mode', function () {
+test('it supports atomic local replacement mode', function () {
file_put_contents($this->tempFilePath, 'before');
$writer = (new SafeFileWriter($this->tempFilePath))
@@ -185,6 +180,13 @@
expect($normalizedContent)->toBe("after\n");
});
+test('it rejects atomic mode for adapter-backed paths', function () {
+ $writer = new SafeFileWriter('writer://remote.txt');
+
+ expect(fn () => $writer->enableAtomicWrite())
+ ->toThrow(FileAccessException::class, 'requires a direct-local filesystem path');
+});
+
test('it verifies checksum after writing', function () {
$writer = new SafeFileWriter($this->tempFilePath);
$result = $writer->writeAndVerify('checksum-content');
diff --git a/tests/Feature/SafeSymlinkManagerTest.php b/tests/Feature/SafeSymlinkManagerTest.php
new file mode 100644
index 00000000..c1cf1f3a
--- /dev/null
+++ b/tests/Feature/SafeSymlinkManagerTest.php
@@ -0,0 +1,188 @@
+ true);
+
+ try {
+ if (unlink($link)) {
+ return true;
+ }
+
+ return rmdir($link);
+ } finally {
+ restore_error_handler();
+ }
+}
+
+function symlinkTestDirectory(string $prefix): string
+{
+ $directory = PathHelper::join(sys_get_temp_dir(), $prefix . bin2hex(random_bytes(8)));
+ if (!mkdir($directory, 0700, true) && !is_dir($directory)) {
+ throw new RuntimeException("Unable to create test directory '{$directory}'.");
+ }
+
+ return $directory;
+}
+
+function supportsSymlinkCreation(string $directory): bool
+{
+ $target = PathHelper::join($directory, 'probe-target');
+ $link = PathHelper::join($directory, 'probe-link');
+ mkdir($target, 0700);
+
+ set_error_handler(static fn(): bool => true);
+ try {
+ $created = symlink($target, $link);
+ } finally {
+ restore_error_handler();
+ }
+
+ if ($created) {
+ removeTestSymlink($link);
+ }
+ rmdir($target);
+
+ return $created;
+}
+
+beforeEach(function (): void {
+ $this->workingDir = symlinkTestDirectory('pathwise_symlink_');
+ $this->linkRoot = PathHelper::join($this->workingDir, 'public');
+ $this->targetRoot = PathHelper::join($this->workingDir, 'storage');
+ mkdir($this->linkRoot, 0700);
+ mkdir($this->targetRoot, 0700);
+
+ if (!supportsSymlinkCreation($this->workingDir)) {
+ $this->markTestSkipped('Symbolic link creation is not available on this platform/runtime.');
+ }
+
+ $this->manager = new SafeSymlinkManager($this->linkRoot, $this->targetRoot);
+});
+
+afterEach(function (): void {
+ if (!isset($this->workingDir) || !is_dir($this->workingDir)) {
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($this->workingDir, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST,
+ );
+
+ foreach ($iterator as $item) {
+ if ($item->isLink()) {
+ removeTestSymlink($item->getPathname());
+
+ continue;
+ }
+ if ($item->isFile()) {
+ unlink($item->getPathname());
+
+ continue;
+ }
+
+ rmdir($item->getPathname());
+ }
+
+ rmdir($this->workingDir);
+});
+
+test('it creates a missing target directory and an idempotent safe symlink', function (): void {
+ $created = $this->manager->create('assets', 'generated/assets', true);
+ $status = $this->manager->status('assets', 'generated/assets');
+
+ expect($created)->toBeTrue()
+ ->and($this->manager->create('assets', 'generated/assets', true))->toBeFalse()
+ ->and($status->exists)->toBeTrue()
+ ->and($status->linked)->toBeTrue()
+ ->and($status->matches)->toBeTrue()
+ ->and($status->broken)->toBeFalse()
+ ->and(is_dir(PathHelper::join($this->targetRoot, 'generated/assets')))->toBeTrue();
+});
+
+test('it rejects link and target paths outside their configured roots', function (): void {
+ $outside = PathHelper::join($this->workingDir, 'outside');
+ mkdir($outside, 0700);
+
+ expect(fn () => $this->manager->create(PathHelper::join($outside, 'link'), 'inside', true))
+ ->toThrow(PolicyViolationException::class, 'must remain inside')
+ ->and(fn () => $this->manager->create('link', PathHelper::join($outside, 'target'), true))
+ ->toThrow(PolicyViolationException::class, 'must remain inside');
+});
+
+test('it rejects parent-directory traversal in link and target paths', function (): void {
+ expect(fn () => $this->manager->create('../escape', 'safe', true))
+ ->toThrow(PolicyViolationException::class, 'parent-directory traversal')
+ ->and(fn () => $this->manager->create('safe', '../escape', true))
+ ->toThrow(PolicyViolationException::class, 'parent-directory traversal');
+});
+
+test('it rejects a symlinked link parent that escapes the allowed link root', function (): void {
+ $outside = PathHelper::join($this->workingDir, 'outside-link-parent');
+ mkdir($outside, 0700);
+ symlink($outside, PathHelper::join($this->linkRoot, 'escape'));
+
+ expect(fn () => $this->manager->create('escape/public-link', 'target', true))
+ ->toThrow(PolicyViolationException::class, 'Symlink parent must remain inside');
+});
+
+test('it rejects a symlinked target ancestor that escapes the allowed target root', function (): void {
+ $outside = PathHelper::join($this->workingDir, 'outside-target-parent');
+ mkdir($outside, 0700);
+ symlink($outside, PathHelper::join($this->targetRoot, 'escape'));
+
+ expect(fn () => $this->manager->create('public-link', 'escape/generated', true))
+ ->toThrow(PolicyViolationException::class, 'Symlink target ancestor must remain inside');
+});
+
+test('it never clobbers an existing non-link path', function (): void {
+ $link = PathHelper::join($this->linkRoot, 'existing.txt');
+ file_put_contents($link, 'keep-me');
+ expect(fn () => $this->manager->create('existing.txt', 'target', true))
+ ->toThrow(PolicyViolationException::class, 'already exists')
+ ->and(file_get_contents($link))->toBe('keep-me')
+ ->and(is_dir(PathHelper::join($this->targetRoot, 'target')))->toBeFalse();
+});
+
+test('it refuses to replace or remove a symlink that points elsewhere', function (): void {
+ mkdir(PathHelper::join($this->targetRoot, 'first'), 0700);
+ mkdir(PathHelper::join($this->targetRoot, 'second'), 0700);
+ $this->manager->create('current', 'first');
+
+ expect(fn () => $this->manager->create('current', 'second'))
+ ->toThrow(PolicyViolationException::class, 'different symbolic link')
+ ->and(fn () => $this->manager->remove('current', 'second'))
+ ->toThrow(PolicyViolationException::class, 'does not match')
+ ->and($this->manager->status('current', 'second')->matches)->toBeFalse()
+ ->and($this->manager->remove('current', 'first'))->toBeTrue()
+ ->and($this->manager->remove('current', 'first'))->toBeFalse();
+});
+
+test('it can safely identify and remove a broken link created for the expected target', function (): void {
+ $this->manager->create('broken', 'disposable', true);
+ rmdir(PathHelper::join($this->targetRoot, 'disposable'));
+
+ $status = $this->manager->status('broken', 'disposable');
+
+ expect($status->exists)->toBeTrue()
+ ->and($status->linked)->toBeTrue()
+ ->and($status->matches)->toBeTrue()
+ ->and($status->broken)->toBeTrue()
+ ->and($this->manager->remove('broken', 'disposable'))->toBeTrue();
+});
+
+test('it validates target-directory permissions', function (): void {
+ expect(fn () => $this->manager->create('link', 'target', true, 01000))
+ ->toThrow(InvalidArgumentException::class, 'permissions');
+});
diff --git a/tests/Feature/StorageContextProcessorTest.php b/tests/Feature/StorageContextProcessorTest.php
new file mode 100644
index 00000000..85bd6b03
--- /dev/null
+++ b/tests/Feature/StorageContextProcessorTest.php
@@ -0,0 +1,111 @@
+ ['driver' => 'local', 'root' => $rootA]], 'files');
+ $contextB = new StorageContext(['files' => ['driver' => 'local', 'root' => $rootB]], 'files');
+
+ $uploaderA = new UploadProcessor();
+ $uploaderA->setStorageContext($contextA);
+ $uploaderA->setDirectorySettings('files://uploads');
+ $pathA = $uploaderA->ingestSource(UploadSource::fromPath($sourceA, 'report.txt'));
+
+ $uploaderB = new UploadProcessor();
+ $uploaderB->setStorageContext($contextB);
+ $uploaderB->setDirectorySettings('files://uploads');
+ $pathB = $uploaderB->ingestSource(UploadSource::fromPath($sourceB, 'report.txt'));
+
+ expect($pathA)->toStartWith('files://uploads/')
+ ->and($pathB)->toStartWith('files://uploads/')
+ ->and($contextA->filesystem()->read(substr($pathA, strlen('files://'))))->toBe('context-a')
+ ->and($contextB->filesystem()->read(substr($pathB, strlen('files://'))))->toBe('context-b')
+ ->and(FlysystemHelper::hasMount('files'))->toBeFalse();
+ } finally {
+ if (is_file($sourceA)) {
+ unlink($sourceA);
+ }
+ if (is_file($sourceB)) {
+ unlink($sourceB);
+ }
+ if (is_dir($rootA)) {
+ FlysystemHelper::deleteDirectory($rootA);
+ }
+ if (is_dir($rootB)) {
+ FlysystemHelper::deleteDirectory($rootB);
+ }
+ }
+});
+
+test('download processors stream through isolated storage contexts without global mounts', function (): void {
+ $rootA = processorContextTempDirectory('pathwise_download_a_');
+ $rootB = processorContextTempDirectory('pathwise_download_b_');
+
+ try {
+ $contextA = new StorageContext(['files' => ['driver' => 'local', 'root' => $rootA]], 'files');
+ $contextB = new StorageContext(['files' => ['driver' => 'local', 'root' => $rootB]], 'files');
+ $contextA->filesystem()->write('downloads/report.txt', 'context-a');
+ $contextB->filesystem()->write('downloads/report.txt', 'context-b');
+
+ $downloadsA = new DownloadProcessor();
+ $downloadsA->setStorageContext($contextA);
+ $downloadsA->setAllowedRoots(['files://downloads']);
+ $preparationA = $downloadsA->prepareDownload('files://downloads/report.txt');
+
+ $downloadsB = new DownloadProcessor();
+ $downloadsB->setStorageContext($contextB);
+ $downloadsB->setAllowedRoots(['files://downloads']);
+ $preparationB = $downloadsB->prepareDownload('files://downloads/report.txt');
+
+ expect(implode('', iterator_to_array($downloadsA->streamChunks($preparationA))))->toBe('context-a')
+ ->and(implode('', iterator_to_array($downloadsB->streamChunks($preparationB))))->toBe('context-b')
+ ->and($contextA->localPath('downloads/report.txt'))->toBe(PathHelper::join($rootA, 'downloads/report.txt'))
+ ->and($contextB->localPath('downloads/report.txt'))->toBe(PathHelper::join($rootB, 'downloads/report.txt'))
+ ->and(FlysystemHelper::hasMount('files'))->toBeFalse();
+ } finally {
+ if (is_dir($rootA)) {
+ FlysystemHelper::deleteDirectory($rootA);
+ }
+ if (is_dir($rootB)) {
+ FlysystemHelper::deleteDirectory($rootB);
+ }
+ }
+});
diff --git a/tests/Feature/StorageContextTest.php b/tests/Feature/StorageContextTest.php
new file mode 100644
index 00000000..2130ad1f
--- /dev/null
+++ b/tests/Feature/StorageContextTest.php
@@ -0,0 +1,182 @@
+ ['driver' => 'local', 'root' => $root]], 'local');
+ [$filesystem, $location] = $context->resolve('nested/file.txt');
+ $filesystem->write($location, 'context-data');
+
+ expect($context->filesystem())->toBe($filesystem)
+ ->and($context->filesystem('LOCAL'))->toBe($filesystem)
+ ->and($filesystem->read('nested/file.txt'))->toBe('context-data')
+ ->and($context->path('nested/file.txt'))->toBe('local://nested/file.txt')
+ ->and($context->localPath('nested/file.txt'))->toBe(PathHelper::join($root, 'nested/file.txt'))
+ ->and($context->isLocal())->toBeTrue()
+ ->and(FlysystemHelper::hasMount('local'))->toBeFalse();
+ } finally {
+ FlysystemHelper::deleteDirectory($root);
+ }
+});
+
+test('two contexts can reuse the same logical filesystem name without cross talk', function (): void {
+ $rootA = storageContextTempDirectory('pathwise_context_a_');
+ $rootB = storageContextTempDirectory('pathwise_context_b_');
+
+ try {
+ $contextA = new StorageContext(['assets' => ['driver' => 'local', 'root' => $rootA]], 'assets');
+ $contextB = new StorageContext(['assets' => ['driver' => 'local', 'root' => $rootB]], 'assets');
+ [$filesystemA, $locationA] = $contextA->resolve('assets://same.txt');
+ [$filesystemB, $locationB] = $contextB->resolve('assets://same.txt');
+ $filesystemA->write($locationA, 'A');
+ $filesystemB->write($locationB, 'B');
+
+ expect($filesystemA)->not->toBe($filesystemB)
+ ->and($filesystemA->read('same.txt'))->toBe('A')
+ ->and($filesystemB->read('same.txt'))->toBe('B')
+ ->and(FlysystemHelper::hasMount('assets'))->toBeFalse();
+ } finally {
+ FlysystemHelper::deleteDirectory($rootA);
+ FlysystemHelper::deleteDirectory($rootB);
+ }
+});
+
+test('custom drivers are isolated per context', function (): void {
+ $rootA = storageContextTempDirectory('pathwise_driver_a_');
+ $rootB = storageContextTempDirectory('pathwise_driver_b_');
+ $factory = static fn (string $root): Closure => static function (array $configuration) use ($root): FilesystemOperator {
+ unset($configuration);
+
+ return new Filesystem(new LocalFilesystemAdapter($root));
+ };
+
+ try {
+ $contextA = new StorageContext(['tenant' => ['driver' => 'isolated']], 'tenant', ['isolated' => $factory($rootA)]);
+ $contextB = new StorageContext(['tenant' => ['driver' => 'isolated']], 'tenant', ['isolated' => $factory($rootB)]);
+ $contextA->filesystem()->write('value.txt', 'A');
+ $contextB->filesystem()->write('value.txt', 'B');
+
+ expect($contextA->hasDriver('isolated'))->toBeTrue()
+ ->and($contextB->hasDriver('isolated'))->toBeTrue()
+ ->and($contextA->filesystem()->read('value.txt'))->toBe('A')
+ ->and($contextB->filesystem()->read('value.txt'))->toBe('B')
+ ->and(fn () => StorageFactory::createFilesystem(['driver' => 'isolated']))
+ ->toThrow(InvalidArgumentException::class, 'StorageContext');
+ } finally {
+ FlysystemHelper::deleteDirectory($rootA);
+ FlysystemHelper::deleteDirectory($rootB);
+ }
+});
+
+test('a context never invents a missing custom driver', function (): void {
+ $context = new StorageContext(['tenant' => ['driver' => 'custom-only']], 'tenant');
+
+ expect(fn () => $context->filesystem())
+ ->toThrow(InvalidArgumentException::class, 'Supply it to StorageContext explicitly');
+});
+
+test('context drivers must return filesystem operators', function (): void {
+ $context = new StorageContext(
+ ['tenant' => ['driver' => 'broken']],
+ 'tenant',
+ ['broken' => static function (array $configuration): object {
+ unset($configuration);
+
+ return new stdClass();
+ }],
+ );
+
+ expect(fn () => $context->filesystem())
+ ->toThrow(UnexpectedValueException::class, 'must return a FilesystemOperator');
+});
+
+test('it resolves explicit schemes and rejects conflicting selection', function (): void {
+ $rootA = storageContextTempDirectory('pathwise_scheme_a_');
+ $rootB = storageContextTempDirectory('pathwise_scheme_b_');
+
+ try {
+ $context = new StorageContext([
+ 'primary' => ['driver' => 'local', 'root' => $rootA],
+ 'archive' => ['driver' => 'local', 'root' => $rootB],
+ ], 'primary');
+ [$archive, $location] = $context->resolve('archive://reports/q1.txt');
+
+ expect($location)->toBe('reports/q1.txt')
+ ->and($archive)->toBe($context->filesystem('archive'))
+ ->and($context->filesystemNames())->toBe(['primary', 'archive'])
+ ->and($context->defaultFilesystem())->toBe('primary')
+ ->and(fn () => $context->resolve('archive://q1.txt', 'primary'))
+ ->toThrow(InvalidArgumentException::class, 'conflicts');
+ } finally {
+ FlysystemHelper::deleteDirectory($rootA);
+ FlysystemHelper::deleteDirectory($rootB);
+ }
+});
+
+test('it rejects invalid topology and unsafe logical paths', function (): void {
+ $root = storageContextTempDirectory('pathwise_context_invalid_');
+
+ try {
+ $context = new StorageContext(['local' => ['driver' => 'local', 'root' => $root]], 'local');
+
+ expect(fn () => new StorageContext([], 'local'))
+ ->toThrow(InvalidArgumentException::class, 'At least one filesystem')
+ ->and(fn () => new StorageContext(['local' => ['driver' => 'local', 'root' => $root]], 'missing'))
+ ->toThrow(InvalidArgumentException::class, 'Default filesystem')
+ ->and(fn () => new StorageContext(['local' => [0 => 'invalid']], 'local'))
+ ->toThrow(InvalidArgumentException::class, 'configuration keys must be strings')
+ ->and(fn () => new StorageContext(
+ ['local' => ['driver' => 'local', 'root' => $root]],
+ 'local',
+ ['s3' => static fn (array $config): FilesystemOperator => StorageFactory::createFilesystem($config)],
+ ))->toThrow(InvalidArgumentException::class, 'reserved')
+ ->and(fn () => $context->resolve('../outside.txt'))
+ ->toThrow(InvalidArgumentException::class, 'parent-directory traversal')
+ ->and(fn () => $context->localPath('local://safe/../../outside.txt'))
+ ->toThrow(InvalidArgumentException::class, 'parent-directory traversal');
+ } finally {
+ FlysystemHelper::deleteDirectory($root);
+ }
+});
+
+test('it rejects absolute logical paths consistently across platforms', function (string $path): void {
+ $context = new StorageContext(['local' => ['driver' => 'local', 'root' => sys_get_temp_dir()]], 'local');
+
+ expect(fn () => $context->resolve($path))
+ ->toThrow(InvalidArgumentException::class, 'must be relative');
+})->with([
+ 'unix root' => '/outside.txt',
+ 'windows drive absolute slash' => 'C:/outside.txt',
+ 'windows drive absolute backslash' => 'C:\\outside.txt',
+ 'windows drive relative' => 'C:outside.txt',
+ 'UNC path' => '\\\\server\\share.txt',
+]);
diff --git a/tests/Feature/StorageFactoryTest.php b/tests/Feature/StorageFactoryTest.php
index ba12f3ad..81a99e8c 100644
--- a/tests/Feature/StorageFactoryTest.php
+++ b/tests/Feature/StorageFactoryTest.php
@@ -7,62 +7,36 @@
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
-beforeEach(function () {
+beforeEach(function (): void {
FlysystemHelper::reset();
- StorageFactory::clearDrivers();
});
-afterEach(function () {
+afterEach(function (): void {
FlysystemHelper::reset();
- StorageFactory::clearDrivers();
});
-test('it creates a local filesystem from driver config', function () {
+test('it creates a local filesystem without mutating global routing state', function (): void {
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_local_', true);
mkdir($root, 0755, true);
try {
- $filesystem = StorageFactory::createFilesystem([
- 'driver' => 'local',
- 'root' => $root,
- ]);
-
+ $filesystem = StorageFactory::createFilesystem(['driver' => 'local', 'root' => $root]);
$filesystem->write('a.txt', 'hello');
- expect($filesystem->read('a.txt'))->toBe('hello');
- } finally {
- FlysystemHelper::deleteDirectory($root);
- }
-});
-
-test('it mounts a filesystem from local driver config', function () {
- $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_mount_', true);
- mkdir($root, 0755, true);
-
- try {
- StorageFactory::mount('assets', [
- 'driver' => 'local',
- 'root' => $root,
- ]);
-
- FlysystemHelper::write('assets://reports/q1.txt', 'Q1');
-
- expect(FlysystemHelper::read('assets://reports/q1.txt'))->toBe('Q1');
+ expect($filesystem->read('a.txt'))->toBe('hello')
+ ->and(FlysystemHelper::hasDefaultFilesystem())->toBeFalse()
+ ->and(FlysystemHelper::hasMount('local'))->toBeFalse();
} finally {
- FlysystemHelper::unmount('assets');
FlysystemHelper::deleteDirectory($root);
}
});
-test('it creates a filesystem from a provided adapter', function () {
+test('it creates a filesystem from a provided adapter', function (): void {
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_adapter_', true);
mkdir($root, 0755, true);
try {
- $filesystem = StorageFactory::createFilesystem([
- 'adapter' => new LocalFilesystemAdapter($root),
- ]);
-
+ $filesystem = StorageFactory::createFilesystem(['adapter' => new LocalFilesystemAdapter($root)]);
$filesystem->write('b.txt', 'world');
expect($filesystem->read('b.txt'))->toBe('world');
@@ -71,112 +45,46 @@
}
});
-test('it returns the provided filesystem instance as-is', function () {
+test('it returns a provided filesystem instance as-is', function (): void {
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_passthrough_', true);
mkdir($root, 0755, true);
try {
$filesystem = new Filesystem(new LocalFilesystemAdapter($root));
- $resolved = StorageFactory::createFilesystem(['filesystem' => $filesystem]);
-
- expect($resolved)->toBe($filesystem);
- } finally {
- FlysystemHelper::deleteDirectory($root);
- }
-});
-
-test('it supports custom registered drivers', function () {
- $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_custom_', true);
- mkdir($root, 0755, true);
-
- StorageFactory::registerDriver('custom-local', function (array $config) use ($root): Filesystem {
- $base = (string) ($config['root'] ?? $root);
-
- return new Filesystem(new LocalFilesystemAdapter($base));
- });
-
- try {
- StorageFactory::mount('custom', [
- 'driver' => 'custom-local',
- 'root' => $root,
- ]);
-
- FlysystemHelper::write('custom://nested/file.txt', 'custom-data');
-
- expect(FlysystemHelper::read('custom://nested/file.txt'))->toBe('custom-data')
- ->and(StorageFactory::hasDriver('custom-local'))->toBeTrue()
- ->and(StorageFactory::driverNames())->toContain('custom-local');
+ expect(StorageFactory::createFilesystem(['filesystem' => $filesystem]))->toBe($filesystem);
} finally {
- FlysystemHelper::unmount('custom');
FlysystemHelper::deleteDirectory($root);
}
});
-test('it exposes official adapter metadata and package lookup', function () {
+test('it exposes official adapter metadata and package lookup', function (): void {
$official = StorageFactory::officialDrivers();
expect($official)->toHaveKeys([
- 'local',
- 'ftp',
- 'inmemory',
- 'read-only',
- 'path-prefixing',
- 'aws-s3',
- 'async-aws-s3',
- 'azure-blob-storage',
- 'google-cloud-storage',
- 'mongodb-gridfs',
- 'sftp-v2',
- 'sftp-v3',
- 'webdav',
- 'ziparchive',
+ 'local', 'ftp', 'inmemory', 'read-only', 'path-prefixing', 'aws-s3', 'async-aws-s3',
+ 'azure-blob-storage', 'google-cloud-storage', 'mongodb-gridfs', 'sftp-v2', 'sftp-v3',
+ 'webdav', 'ziparchive',
])
->and(StorageFactory::suggestedPackage('s3'))->toBe('league/flysystem-aws-s3-v3')
->and(StorageFactory::suggestedPackage('in-memory'))->toBe('league/flysystem-memory')
->and(StorageFactory::suggestedPackage('zip'))->toBe('league/flysystem-ziparchive');
});
-test('it mounts multiple storages from config map', function () {
- $rootA = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_many_a_', true);
- $rootB = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_many_b_', true);
- mkdir($rootA, 0755, true);
- mkdir($rootB, 0755, true);
-
- try {
- StorageFactory::mountMany([
- 'a' => ['driver' => 'local', 'root' => $rootA],
- 'b' => ['driver' => 'local', 'root' => $rootB],
- ]);
-
- FlysystemHelper::write('a://one.txt', 'A');
- FlysystemHelper::write('b://two.txt', 'B');
-
- expect(FlysystemHelper::read('a://one.txt'))->toBe('A')
- ->and(FlysystemHelper::read('b://two.txt'))->toBe('B');
- } finally {
- FlysystemHelper::unmount('a');
- FlysystemHelper::unmount('b');
- FlysystemHelper::deleteDirectory($rootA);
- FlysystemHelper::deleteDirectory($rootB);
- }
-});
-
-test('it throws for unsupported driver', function () {
- expect(fn () => StorageFactory::createFilesystem(['driver' => 'made-up-driver']))
- ->toThrow(InvalidArgumentException::class, 'Unsupported storage driver');
+test('unsupported custom drivers direct callers to StorageContext', function (): void {
+ expect(fn () => StorageFactory::createFilesystem(['driver' => 'tenant-driver']))
+ ->toThrow(InvalidArgumentException::class, 'StorageContext');
});
-test('it throws for local driver without root', function () {
+test('it throws for local driver without root', function (): void {
expect(fn () => StorageFactory::createFilesystem(['driver' => 'local']))
->toThrow(InvalidArgumentException::class, 'Local driver requires a non-empty "root" path');
});
-test('it provides package guidance for missing official drivers', function () {
- $adapterClass = StorageFactory::officialDrivers()['aws-s3']['adapter_class'];
-
- if (!class_exists($adapterClass)) {
+test('it provides package guidance for missing official drivers', function (): void {
+ $metadata = StorageFactory::officialDrivers()['aws-s3'];
+ if (!class_exists($metadata['adapter_class'])) {
expect(fn () => StorageFactory::createFilesystem(['driver' => 's3']))
- ->toThrow(InvalidArgumentException::class, 'league/flysystem-aws-s3-v3');
+ ->toThrow(InvalidArgumentException::class, $metadata['package']);
return;
}
@@ -185,11 +93,9 @@
->toThrow(InvalidArgumentException::class, "requires either 'adapter' or 'constructor'");
});
-test('it supports in-memory driver when adapter package exists', function () {
+test('it supports in-memory driver when the optional adapter exists', function (): void {
$metadata = StorageFactory::officialDrivers()['inmemory'];
- $adapterClass = $metadata['adapter_class'];
-
- if (!class_exists($adapterClass)) {
+ if (!class_exists($metadata['adapter_class'])) {
expect(fn () => StorageFactory::createFilesystem(['driver' => 'in-memory']))
->toThrow(InvalidArgumentException::class, $metadata['package']);
@@ -202,7 +108,7 @@
expect($filesystem->read('memory.txt'))->toBe('memory-data');
});
-test('it rejects conflicting configuration modes and malformed options', function () {
+test('it rejects conflicting configuration modes and malformed options', function (): void {
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_conflict_', true);
mkdir($root);
$adapter = new LocalFilesystemAdapter($root);
@@ -216,34 +122,3 @@
rmdir($root);
}
});
-
-test('it rejects duplicate and official custom driver names', function () {
- $root = sys_get_temp_dir();
- $factory = static fn (array $config): Filesystem => new Filesystem(new LocalFilesystemAdapter(
- is_string($config['root'] ?? null) ? $config['root'] : $root,
- ));
- StorageFactory::registerDriver('custom-driver', $factory);
-
- expect(fn () => StorageFactory::registerDriver('custom-driver', $factory))
- ->toThrow(InvalidArgumentException::class, 'already registered')
- ->and(fn () => StorageFactory::registerDriver('s3', $factory))
- ->toThrow(InvalidArgumentException::class, 'reserved');
-});
-
-test('mountMany rolls back earlier mounts when a later mount fails', function () {
- $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_rollback_', true);
- mkdir($root);
- FlysystemHelper::mount('occupied', new Filesystem(new LocalFilesystemAdapter($root)));
-
- try {
- expect(fn () => StorageFactory::mountMany([
- 'prepared' => ['driver' => 'local', 'root' => $root],
- 'occupied' => ['driver' => 'local', 'root' => $root],
- ]))->toThrow(InvalidArgumentException::class)
- ->and(FlysystemHelper::hasMount('prepared'))->toBeFalse()
- ->and(FlysystemHelper::hasMount('occupied'))->toBeTrue();
- } finally {
- FlysystemHelper::reset();
- rmdir($root);
- }
-});
diff --git a/tests/Feature/SubsystemHardeningTest.php b/tests/Feature/SubsystemHardeningTest.php
new file mode 100644
index 00000000..8482efff
--- /dev/null
+++ b/tests/Feature/SubsystemHardeningTest.php
@@ -0,0 +1,106 @@
+subsystemRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_subsystems_', true);
+ mkdir($this->subsystemRoot, 0755, true);
+});
+
+afterEach(function (): void {
+ if (!is_dir($this->subsystemRoot)) {
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($this->subsystemRoot, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ foreach ($iterator as $item) {
+ if ($item->isFile() || $item->isLink()) {
+ unlink($item->getPathname());
+ } else {
+ rmdir($item->getPathname());
+ }
+ }
+ rmdir($this->subsystemRoot);
+});
+
+test('retention preview matches apply without mutating during preview', function (): void {
+ $paths = [];
+ for ($index = 0; $index < 3; $index++) {
+ $path = $this->subsystemRoot . DIRECTORY_SEPARATOR . "retention-{$index}.txt";
+ file_put_contents($path, (string) $index);
+ touch($path, time() - (30 - $index));
+ $paths[] = $path;
+ }
+
+ $preview = RetentionManager::preview($this->subsystemRoot, keepLast: 2);
+ expect($preview->deleted)->toHaveCount(1)
+ ->and($preview->kept)->toHaveCount(2)
+ ->and(array_filter($paths, 'is_file'))->toHaveCount(3);
+
+ $applied = RetentionManager::apply($this->subsystemRoot, keepLast: 2);
+ expect($applied->deleted)->toBe($preview->deleted)
+ ->and($applied->kept)->toBe($preview->kept)
+ ->and(array_filter($paths, 'is_file'))->toHaveCount(2);
+});
+
+test('checksum iteration streams deterministic checksum path records', function (): void {
+ file_put_contents($this->subsystemRoot . DIRECTORY_SEPARATOR . 'a.txt', 'A');
+ file_put_contents($this->subsystemRoot . DIRECTORY_SEPARATOR . 'b.txt', 'B');
+
+ $iterator = ChecksumIndexer::iterate($this->subsystemRoot);
+ expect($iterator)->toBeInstanceOf(Generator::class);
+
+ $records = iterator_to_array($iterator, false);
+ expect($records)->toHaveCount(2)
+ ->and($records[0])->toHaveKeys(['checksum', 'path'])
+ ->and($records[0]['checksum'])->toHaveLength(64);
+});
+
+test('watcher rejects ambiguous polling bounds', function (): void {
+ expect(fn () => FileWatcher::watch($this->subsystemRoot, static fn (): null => null, durationSeconds: 0))
+ ->toThrow(InvalidArgumentException::class, 'duration')
+ ->and(fn () => FileWatcher::watch(
+ $this->subsystemRoot,
+ static fn (): null => null,
+ durationSeconds: 1,
+ intervalMilliseconds: 9,
+ ))->toThrow(InvalidArgumentException::class, 'interval');
+});
+
+test('snapshot diffs are ordered deterministically', function (): void {
+ $diff = FileWatcher::diff(
+ ['z.txt' => ['mtime' => 1, 'size' => 1], 'gone.txt' => ['mtime' => 1, 'size' => 1]],
+ ['z.txt' => ['mtime' => 2, 'size' => 1], 'b.txt' => ['mtime' => 1, 'size' => 1], 'a.txt' => ['mtime' => 1, 'size' => 1]],
+ );
+
+ expect($diff->created)->toBe(['a.txt', 'b.txt'])
+ ->and($diff->modified)->toBe(['z.txt'])
+ ->and($diff->deleted)->toBe(['gone.txt']);
+});
+
+test('transaction journal restores file content and metadata boundary', function (): void {
+ $path = $this->subsystemRoot . DIRECTORY_SEPARATOR . 'transaction.txt';
+ file_put_contents($path, 'before');
+ if (PHP_OS_FAMILY !== 'Windows') {
+ chmod($path, 0640);
+ }
+
+ $journal = new FileTransactionJournal($path);
+ $journal->record($path);
+ file_put_contents($path, 'after');
+ $journal->rollback();
+
+ expect(file_get_contents($path))->toBe('before');
+ if (PHP_OS_FAMILY !== 'Windows') {
+ $mode = fileperms($path);
+ expect($mode)->toBeInt()->and($mode & 0777)->toBe(0640);
+ }
+});
diff --git a/tests/Feature/UploadProcessorTest.php b/tests/Feature/UploadProcessorTest.php
index 8d1ac08f..963d1164 100644
--- a/tests/Feature/UploadProcessorTest.php
+++ b/tests/Feature/UploadProcessorTest.php
@@ -4,6 +4,10 @@
use Infocyph\Pathwise\Exceptions\FileSizeExceededException;
use Infocyph\Pathwise\Exceptions\UploadException;
+use Infocyph\Pathwise\StreamHandler\MalwareScannerInterface;
+use Infocyph\Pathwise\StreamHandler\MalwareScanMode;
+use Infocyph\Pathwise\StreamHandler\MalwareScanRequest;
+use Infocyph\Pathwise\StreamHandler\MalwareScanVerdict;
use Infocyph\Pathwise\StreamHandler\UploadProcessor;
use Infocyph\Pathwise\Utils\FlysystemHelper;
use League\Flysystem\Filesystem;
@@ -137,16 +141,19 @@
test('it exposes malware scanner state in info', function () {
$this->uploadProcessor->setDirectorySettings($this->uploadDir);
- $this->uploadProcessor->setMalwareScanner(function (string $path, string $mime): bool {
- unset($path, $mime);
+ $this->uploadProcessor->setMalwareScanner(new class implements MalwareScannerInterface {
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ unset($request);
- return true;
+ return MalwareScanVerdict::CLEAN;
+ }
});
expect($this->uploadProcessor->getInfo()['hasMalwareScanner'])->toBeTrue();
});
-test('it blocks finalize when malware scan fails', function () {
+test('it blocks finalize when malware scan rejects the upload', function () {
$this->uploadProcessor->setDirectorySettings($this->uploadDir, false, $this->uploadDir);
$this->uploadProcessor->setValidationProfile('document');
@@ -161,14 +168,17 @@
'name' => 'chunk.part',
], $uploadId, 0, 1, 'merged.txt');
- $this->uploadProcessor->setMalwareScanner(function (string $path, string $mime): bool {
- unset($path, $mime);
+ $this->uploadProcessor->setMalwareScanner(new class implements MalwareScannerInterface {
+ public function scan(MalwareScanRequest $request): MalwareScanVerdict
+ {
+ unset($request);
- return false;
+ return MalwareScanVerdict::MALICIOUS;
+ }
});
expect(fn() => $this->uploadProcessor->finalizeChunkUpload($uploadId))
- ->toThrow(UploadException::class, 'Malware scan failed');
+ ->toThrow(UploadException::class, 'Malware scan rejected the upload.');
});
test('it blocks upload when extension is blocked', function () {
@@ -194,9 +204,9 @@
}
});
-test('it requires malware scanner when configured', function () {
+test('it requires malware scanner in required mode', function () {
$this->uploadProcessor->setDirectorySettings($this->uploadDir);
- $this->uploadProcessor->setRequireMalwareScan(true);
+ $this->uploadProcessor->setMalwareScanMode(MalwareScanMode::REQUIRED);
$tmpFile = $this->uploadDir . DIRECTORY_SEPARATOR . uniqid('upload_scan_', true) . '.txt';
file_put_contents($tmpFile, 'plain text');
diff --git a/tests/Feature/UploadSourceTest.php b/tests/Feature/UploadSourceTest.php
new file mode 100644
index 00000000..7ca2fe2b
--- /dev/null
+++ b/tests/Feature/UploadSourceTest.php
@@ -0,0 +1,245 @@
+uploadDir = uploadSourceTempDirectory('pathwise_upload_source_dest_');
+ $this->stagingDir = uploadSourceTempDirectory('pathwise_upload_source_stage_');
+ $this->sourceDir = uploadSourceTempDirectory('pathwise_upload_source_input_');
+ $this->processor = new UploadProcessor();
+ $this->processor->setDirectorySettings($this->uploadDir, false, $this->stagingDir);
+ $this->processor->setValidationSettings(['text/plain'], 1024 * 1024);
+});
+
+afterEach(function (): void {
+ foreach ([$this->uploadDir, $this->stagingDir, $this->sourceDir] as $directory) {
+ if (is_dir($directory)) {
+ FlysystemHelper::deleteDirectory($directory);
+ }
+ }
+
+ FlysystemHelper::reset();
+});
+
+test('it ingests framework mover sources and cleans Pathwise staging', function (): void {
+ $stagedPath = null;
+ $source = UploadSource::fromMover(
+ static function (string $target) use (&$stagedPath): void {
+ $stagedPath = $target;
+ file_put_contents($target, 'framework-content');
+ },
+ clientFilename: 'report.txt',
+ size: 1,
+ clientMediaType: 'application/not-trusted',
+ );
+
+ $destination = $this->processor->ingestSource($source);
+ $remaining = glob($this->stagingDir . DIRECTORY_SEPARATOR . 'pathwise-upload-*');
+
+ expect(file_get_contents($destination))->toBe('framework-content')
+ ->and(is_string($stagedPath))->toBeTrue()
+ ->and(uploadSourcePathExists($stagedPath))->toBeFalse()
+ ->and($remaining === false ? [] : $remaining)->toBe([]);
+});
+
+test('materialized upload state uses private permissions', function (): void {
+ if (PHP_OS_FAMILY === 'Windows') {
+ expect(true)->toBeTrue();
+
+ return;
+ }
+
+ $source = UploadSource::fromMover(
+ static function (string $target): void {
+ file_put_contents($target, 'private-content');
+ },
+ clientFilename: 'private.txt',
+ );
+ $materialized = $source->materialize($this->stagingDir);
+
+ try {
+ $directoryMode = fileperms(dirname($materialized->path));
+ $fileMode = fileperms($materialized->path);
+
+ expect($directoryMode)->toBeInt()
+ ->and($directoryMode & 0777)->toBe(0700)
+ ->and($fileMode)->toBeInt()
+ ->and($fileMode & 0777)->toBe(0600);
+ } finally {
+ $materialized->cleanup();
+ }
+});
+
+test('it cleans materialized sources when upload validation fails', function (): void {
+ $stagedPath = null;
+ $source = UploadSource::fromMover(
+ static function (string $target) use (&$stagedPath): void {
+ $stagedPath = $target;
+ file_put_contents($target, ' $this->processor->ingestSource($source))
+ ->toThrow(UploadException::class, 'Blocked file extension')
+ ->and(is_string($stagedPath))->toBeTrue()
+ ->and(uploadSourcePathExists($stagedPath))->toBeFalse();
+});
+
+test('it rejects source upload errors before invoking a framework mover', function (): void {
+ $called = false;
+ $source = UploadSource::fromMover(
+ static function (string $target) use (&$called): void {
+ $called = true;
+ file_put_contents($target, 'should-not-run');
+ },
+ clientFilename: 'missing.txt',
+ error: UPLOAD_ERR_NO_FILE,
+ );
+
+ expect(fn () => $this->processor->ingestSource($source))
+ ->toThrow(UploadException::class, 'No file sent')
+ ->and($called)->toBeFalse();
+});
+
+test('it validates the actual materialized size instead of trusting source metadata', function (): void {
+ $this->processor->setValidationSettings(['text/plain'], 5);
+ $stagedPath = null;
+ $source = UploadSource::fromMover(
+ static function (string $target) use (&$stagedPath): void {
+ $stagedPath = $target;
+ file_put_contents($target, '1234567890');
+ },
+ clientFilename: 'oversized.txt',
+ size: 1,
+ );
+
+ expect(fn () => $this->processor->ingestSource($source))
+ ->toThrow(FileSizeExceededException::class, 'Exceeded file size limit')
+ ->and(is_string($stagedPath))->toBeTrue()
+ ->and(uploadSourcePathExists($stagedPath))->toBeFalse();
+});
+
+test('borrowed paths remain and owned paths are consumed after staging', function (): void {
+ $borrowed = PathHelper::join($this->sourceDir, 'borrowed.txt');
+ file_put_contents($borrowed, 'borrowed-content');
+
+ $borrowedDestination = $this->processor->ingestSource(
+ UploadSource::fromPath($borrowed, owned: false),
+ );
+
+ expect(is_file($borrowed))->toBeTrue()
+ ->and(file_get_contents($borrowedDestination))->toBe('borrowed-content');
+
+ $owned = PathHelper::join($this->sourceDir, 'owned.php');
+ file_put_contents($owned, ' $this->processor->ingestSource(
+ UploadSource::fromPath($owned, owned: true),
+ ))->toThrow(UploadException::class, 'Blocked file extension')
+ ->and(is_file($owned))->toBeFalse();
+});
+
+test('caller owned streams stay open and are read from their current position', function (): void {
+ $stream = tmpfile();
+ if (!is_resource($stream)) {
+ throw new RuntimeException('Unable to create test stream.');
+ }
+
+ fwrite($stream, 'prefix-body');
+ fseek($stream, 7);
+
+ $destination = $this->processor->ingestSource(
+ UploadSource::fromStream($stream, 'stream.txt'),
+ );
+
+ expect(file_get_contents($destination))->toBe('body')
+ ->and(is_resource($stream))->toBeTrue();
+
+ fclose($stream);
+});
+
+test('it fails cleanly when a caller closes a stream before materialization', function (): void {
+ $stream = tmpfile();
+ if (!is_resource($stream)) {
+ throw new RuntimeException('Unable to create test stream.');
+ }
+
+ $source = UploadSource::fromStream($stream, 'closed.txt');
+ fclose($stream);
+
+ expect(fn () => $this->processor->ingestSource($source))
+ ->toThrow(UploadException::class, 'no longer readable');
+
+ $remaining = glob($this->stagingDir . DIRECTORY_SEPARATOR . 'pathwise-upload-*');
+ expect($remaining === false ? [] : $remaining)->toBe([]);
+});
+
+test('typed sources work for resumable chunks and staging is cleaned', function (): void {
+ $stagedPath = null;
+ $source = UploadSource::fromMover(
+ static function (string $target) use (&$stagedPath): void {
+ $stagedPath = $target;
+ file_put_contents($target, 'chunk-body');
+ },
+ clientFilename: 'chunk.txt',
+ );
+
+ $state = $this->processor->processChunkUploadSource(
+ source: $source,
+ uploadId: 'typed_source_chunk',
+ chunkIndex: 0,
+ totalChunks: 1,
+ originalFilename: 'merged.txt',
+ );
+ $destination = $this->processor->finalizeChunkUpload('typed_source_chunk');
+
+ expect($state->complete)->toBeTrue()
+ ->and($state->receivedChunks)->toBe(1)
+ ->and(file_get_contents($destination))->toBe('chunk-body')
+ ->and(is_string($stagedPath))->toBeTrue()
+ ->and(uploadSourcePathExists($stagedPath))->toBeFalse();
+});
+
+test('materialization failure removes a partially written staging file', function (): void {
+ $stagedPath = null;
+ $source = UploadSource::fromMover(
+ static function (string $target) use (&$stagedPath): void {
+ $stagedPath = $target;
+ file_put_contents($target, 'partial');
+ throw new RuntimeException('mover failed');
+ },
+ clientFilename: 'partial.txt',
+ );
+
+ expect(fn () => $source->materialize($this->stagingDir))
+ ->toThrow(UploadException::class, 'Unable to materialize upload source')
+ ->and(is_string($stagedPath))->toBeTrue()
+ ->and(uploadSourcePathExists($stagedPath))->toBeFalse();
+
+ $remaining = glob($this->stagingDir . DIRECTORY_SEPARATOR . 'pathwise-upload-*');
+ expect($remaining === false ? [] : $remaining)->toBe([]);
+});