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 -[![Security & Standards](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/Pathwise/actions/workflows/security-standards.yml) -![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/Pathwise?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2FPathwise) -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) -![Packagist Version](https://img.shields.io/packagist/v/infocyph/Pathwise) -![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/Pathwise/php) -![GitHub Code Size](https://img.shields.io/github/languages/code-size/infocyph/Pathwise) -[![Documentation](https://img.shields.io/badge/Documentation-Pathwise-blue?logo=readthedocs&logoColor=white)](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 -
- Made with ❤️ for the PHP community
- MIT Licensed
- Documentation • - Security • - Code of Conduct • - Contributing
- 🗂️ - Bug • - Feature • - Documentation • - Question • - CI failure
- 🔀 - General • - Bug fix • - Feature • - Refactor • - Performance • - Security & reliability • - Documentation • - Maintenance -
+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([]); +});