From 9f1faef963e9b49991552faf6be461142292be43 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 13 Aug 2026 20:33:06 +0600 Subject: [PATCH 1/2] updated & fixed ops/tech issues --- .gitignore | 2 + README.md | 6 +- benchmarks/FlysystemHelperBench.php | 12 + benchmarks/ReleaseWorkloadsBench.php | 191 +++++++++++ benchmarks/StreamHandlerBench.php | 2 +- docs/_static/theme.css | 2 +- docs/conf.py | 8 +- docs/download-processing.rst | 7 +- docs/file-manager.rst | 4 +- docs/queue.rst | 9 +- docs/recipes.rst | 5 +- docs/retention.rst | 7 + docs/security.rst | 5 + docs/storage-contracts.rst | 6 +- docs/upload-processing.rst | 22 +- .../DirectoryOperationsEntryConcern.php | 16 +- .../DirectoryOperationsSyncConcern.php | 6 +- .../DirectoryOperationsZipConcern.php | 103 ++++-- src/DirectoryManager/DirectoryOperations.php | 151 ++++++++- src/Exceptions/AuditException.php | 7 + src/Exceptions/NativeExecutionException.php | 13 +- src/Exceptions/QueueException.php | 7 + .../TransactionRollbackException.php | 26 ++ .../FileCompressionArchiveConcern.php | 78 ++++- .../FileCompressionRuntimeConcern.php | 6 +- src/FileManager/Concerns/FsConcern.php | 2 +- .../Concerns/SafeFileWriterWriteConcern.php | 54 ++- src/FileManager/FileCompression.php | 76 +++-- src/FileManager/FileOperations.php | 133 +++++--- src/FileManager/FileTransactionJournal.php | 74 +++- src/FileManager/SafeFileReader.php | 187 ++++++----- src/FileManager/SafeFileWriter.php | 56 ++-- src/Indexing/ChecksumIndexer.php | 101 +++--- src/Native/NativeCommandRunner.php | 106 +++++- src/Native/NativeOperationsAdapter.php | 316 ++++++++---------- src/Observability/AuditTrail.php | 6 +- src/Observability/LocalJsonlAuditSink.php | 12 +- src/Observability/PartitionedAuditSink.php | 15 +- src/PathwiseFacade.php | 16 +- src/Queue/FileJobQueue.php | 233 ++++++++----- src/Results/ChunkUploadState.php | 11 +- src/Results/DownloadPreparation.php | 14 +- src/Results/DownloadStreamResult.php | 9 +- src/Results/QueueProcessResult.php | 9 +- src/Results/RangeDownloadMetadata.php | 22 +- src/Retention/RetentionManager.php | 13 +- src/Security/PolicyEngine.php | 14 +- src/Security/ZipEntryValidator.php | 102 +++++- src/Storage/StorageFactory.php | 53 ++- .../Concerns/UploadProcessorChunkConcern.php | 133 +++++--- .../UploadProcessorValidationConcern.php | 98 ++++-- src/StreamHandler/DownloadProcessor.php | 150 ++++----- src/StreamHandler/UploadProcessor.php | 248 ++++++++------ src/Utils/FlysystemHelper.php | 153 +++++++-- src/Utils/FlysystemPathResolver.php | 10 +- src/Utils/MetadataHelper.php | 13 +- .../Ownership/WindowsOwnershipResolver.php | 25 +- src/Utils/PathHelper.php | 67 +++- src/Utils/PermissionsHelper.php | 34 +- src/Utils/ReadablePathLocalizer.php | 85 +++++ src/Utils/SerializedValueValidator.php | 26 ++ tests/Feature/ArchiveSecurityTest.php | 28 ++ tests/Feature/AuditTrailTest.php | 3 +- tests/Feature/DirectoryOperationsTest.php | 32 ++ tests/Feature/DownloadProcessorTest.php | 28 ++ tests/Feature/FileCompressionTest.php | 12 + tests/Feature/FileJobQueueTest.php | 43 +++ tests/Feature/FlysystemHelperTest.php | 20 ++ tests/Feature/MetadataHelperTest.php | 1 - tests/Feature/PathHelperTest.php | 6 +- tests/Feature/SafeFileReaderTest.php | 22 +- tests/Feature/SafeFileWriterTest.php | 15 + tests/Feature/StorageFactoryTest.php | 46 +++ tests/Feature/UploadProcessorTest.php | 68 +++- 74 files changed, 2711 insertions(+), 990 deletions(-) create mode 100644 benchmarks/ReleaseWorkloadsBench.php create mode 100644 src/Exceptions/AuditException.php create mode 100644 src/Exceptions/QueueException.php create mode 100644 src/Exceptions/TransactionRollbackException.php create mode 100644 src/Utils/ReadablePathLocalizer.php create mode 100644 src/Utils/SerializedValueValidator.php diff --git a/.gitignore b/.gitignore index a90c2bf..87a3286 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ d2utmp* plan.md pathwise.md feature.md +.agent +.codex diff --git a/README.md b/README.md index a80fd5e..50a8eeb 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ $report = (new DirectoryOperations('/srv/source'))->syncTo( ## Secure archives -Every extraction path validates every ZIP member before writing. Absolute paths, Windows drive paths, null bytes, traversal segments, symbolic-link entries, extraction-root escapes, and existing destination-symlink breakouts are rejected with `UnsafeArchiveEntryException`. +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. + +`FileJobQueue` is direct-local-only and intended for bounded, lightweight single-host workloads—not as a remote or distributed broker. ## Auditing @@ -86,7 +88,7 @@ Every extraction path validates every ZIP member before writing. Absolute paths, ## Native execution -`ExecutionStrategy::PHP` always uses PHP, `AUTO` may use an available native executable and fall back, and `NATIVE` either completes natively or throws `NativeExecutionException`. Native execution accepts local paths only; command arguments are escaped and execution results retain command, output, and exit code. +`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. ## Security diff --git a/benchmarks/FlysystemHelperBench.php b/benchmarks/FlysystemHelperBench.php index 5ec425a..6400a49 100644 --- a/benchmarks/FlysystemHelperBench.php +++ b/benchmarks/FlysystemHelperBench.php @@ -39,6 +39,18 @@ public function benchChecksumSha256(): void FlysystemHelper::checksum($this->filePath, 'sha256'); } + public function benchCopyThroughCachedLocalResolver(): void + { + $destination = PathHelper::join($this->baseDir, 'copy.txt'); + FlysystemHelper::copy($this->filePath, $destination); + FlysystemHelper::delete($destination); + } + + public function benchFileExistsThroughCachedLocalResolver(): void + { + FlysystemHelper::fileExists($this->filePath); + } + public function benchGetMetadataSizeAndMtime(): void { FlysystemHelper::size($this->filePath); diff --git a/benchmarks/ReleaseWorkloadsBench.php b/benchmarks/ReleaseWorkloadsBench.php new file mode 100644 index 0000000..4e1fee0 --- /dev/null +++ b/benchmarks/ReleaseWorkloadsBench.php @@ -0,0 +1,191 @@ +baseDirectory = PathHelper::join(sys_get_temp_dir(), 'pathwise_release_bench_' . uniqid('', true)); + $this->sourceDirectory = PathHelper::join($this->baseDirectory, 'source'); + $this->largeFile = PathHelper::join($this->baseDirectory, 'large.bin'); + } + + public function tearDown(): void + { + if (FlysystemHelper::directoryExists($this->baseDirectory)) { + FlysystemHelper::deleteDirectory($this->baseDirectory); + } + } + + public function benchChunkAssembly100(): void + { + $this->benchmarkChunkAssembly(100); + } + + public function benchChunkAssembly1000ReverseArrival(): void + { + $this->benchmarkChunkAssembly(1_000, true); + } + + public function benchQueue100(): void + { + $this->benchmarkQueue(100); + } + + public function benchQueue1000(): void + { + $this->benchmarkQueue(1_000); + } + + public function benchQueue10000(): void + { + $this->benchmarkQueue(10_000); + } + + public function benchReader128KiB(): void + { + $this->consumeReader(131_072); + } + + public function benchReader64KiB(): void + { + $this->consumeReader(65_536); + } + + public function benchReader8KiB(): void + { + $this->consumeReader(8_192); + } + + public function benchSyncChecksum1000Files(): void + { + $this->benchmarkSync(SyncComparison::CHECKSUM); + } + + public function benchSyncSize1000Files(): void + { + $this->benchmarkSync(SyncComparison::SIZE); + } + + public function benchSyncSizeAndModifiedTime1000Files(): void + { + $this->benchmarkSync(SyncComparison::SIZE_AND_MODIFIED_TIME); + } + + public function benchTransactionHundredUpdates(): void + { + $this->benchmarkTransaction(100); + } + + public function benchTransactionOneUpdate(): void + { + $this->benchmarkTransaction(1); + } + + public function benchTransactionTenUpdates(): void + { + $this->benchmarkTransaction(10); + } + + private function benchmarkChunkAssembly(int $chunks, bool $reverse = false): void + { + $uploadDirectory = PathHelper::join($this->baseDirectory, 'uploads-' . $chunks); + $temporaryDirectory = PathHelper::join($this->baseDirectory, 'chunks-' . $chunks); + $uploader = new UploadProcessor(); + $uploader->setDirectorySettings($uploadDirectory, false, $temporaryDirectory); + $indexes = range(0, $chunks - 1); + if ($reverse) { + $indexes = array_reverse($indexes); + } + foreach ($indexes as $index) { + $part = PathHelper::join($this->baseDirectory, "part-{$chunks}-{$index}.tmp"); + FlysystemHelper::write($part, 'x'); + $uploader->processChunkUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => 1, + 'tmp_name' => $part, + 'name' => basename($part), + ], "bench_{$chunks}", $index, $chunks, 'assembled.txt'); + } + $uploader->finalizeChunkUpload("bench_{$chunks}"); + } + + private function benchmarkQueue(int $jobs): void + { + $queue = new FileJobQueue(PathHelper::join($this->baseDirectory, "queue-{$jobs}.json"), maxJobs: $jobs); + for ($index = 0; $index < $jobs; $index++) { + $queue->enqueue('benchmark', ['index' => $index]); + } + $queue->process(static function (): void {}); + } + + private function benchmarkSync(SyncComparison $comparison): void + { + FlysystemHelper::createDirectory($this->sourceDirectory); + for ($index = 0; $index < 1_000; $index++) { + FlysystemHelper::write( + PathHelper::join($this->sourceDirectory, sprintf('entry-%04d.txt', $index)), + str_repeat((string) ($index % 10), 128), + ); + } + $target = PathHelper::join($this->baseDirectory, 'sync-' . $comparison->name); + (new DirectoryOperations($this->sourceDirectory))->syncTo($target, true, null, $comparison); + } + + private function benchmarkTransaction(int $updates): void + { + $this->createLargeFile(); + $path = PathHelper::join($this->baseDirectory, "transaction-{$updates}.bin"); + FlysystemHelper::copy($this->largeFile, $path); + (new FileOperations($path))->transaction(static function (FileOperations $operations) use ($updates): void { + for ($index = 0; $index < $updates; $index++) { + $operations->update(str_repeat((string) ($index % 10), 8 * 1024 * 1024)); + } + }); + } + + private function consumeReader(int $chunkSize): void + { + $this->createLargeFile(); + foreach ((new SafeFileReader($this->largeFile))->chunks($chunkSize) as $chunk) { + strlen($chunk); + } + + $download = new DownloadProcessor(); + $download->setChunkSize($chunkSize); + $output = fopen('php://temp', 'w+b'); + if (is_resource($output)) { + $download->streamDownload($this->largeFile, $output); + fclose($output); + } + } + + private function createLargeFile(): void + { + FlysystemHelper::write($this->largeFile, str_repeat('0123456789abcdef', 512 * 1024)); + } +} diff --git a/benchmarks/StreamHandlerBench.php b/benchmarks/StreamHandlerBench.php index 246953c..5d4ab33 100644 --- a/benchmarks/StreamHandlerBench.php +++ b/benchmarks/StreamHandlerBench.php @@ -68,7 +68,7 @@ public function benchProcessUpload(): void file_put_contents($tmpUpload, str_repeat('upload-payload', 128)); try { - $destination = $this->uploadProcessor->processUpload([ + $destination = $this->uploadProcessor->ingestFile([ 'error' => UPLOAD_ERR_OK, 'size' => filesize($tmpUpload) ?: 0, 'tmp_name' => $tmpUpload, diff --git a/docs/_static/theme.css b/docs/_static/theme.css index ae81ea7..821e0b5 100644 --- a/docs/_static/theme.css +++ b/docs/_static/theme.css @@ -1,3 +1,3 @@ .highlight-php .k { - color: #0077aa; /* Example: make PHP keywords a different color */ + color: #0077aa; } diff --git a/docs/conf.py b/docs/conf.py index 810c108..6188155 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,6 @@ "myst_parser", "sphinx.ext.todo", "sphinx.ext.autosectionlabel", - "sphinx.ext.intersphinx", "sphinx_copybutton", "sphinx_design", "sphinxcontrib.phpdomain", @@ -33,11 +32,7 @@ myst_heading_anchors = 3 autosectionlabel_prefix_document = True -todo_include_todos = True - -intersphinx_mapping = { - "php": ("https://www.php.net/manual/en/", None), -} +todo_include_todos = False html_theme = "sphinx_book_theme" html_theme_options = { @@ -58,4 +53,3 @@ html_show_sourcelink = True html_show_sphinx = False html_last_updated_fmt = "%Y-%m-%d" - diff --git a/docs/download-processing.rst b/docs/download-processing.rst index 7c34b60..e2acbe9 100644 --- a/docs/download-processing.rst +++ b/docs/download-processing.rst @@ -53,7 +53,7 @@ Prepare secure metadata: rangeHeader: null, ); - // Use $manifest['status'] and $manifest['headers'] in your framework response. + // Use $manifest->status, $manifest->headers, and $manifest->range. Stream output with range support: @@ -61,14 +61,15 @@ Stream output with range support: $output = fopen('php://output', 'wb'); - $manifest = $downloads->streamDownload( + $result = $downloads->streamDownload( path: '/srv/app/downloads/video.mp4', outputStream: $output, downloadName: 'video.mp4', rangeHeader: $_SERVER['HTTP_RANGE'] ?? null, ); - // $manifest includes status, headers, rangeStart/rangeEnd and bytesSent. + // $result->preparation contains status, headers, and range metadata. + // $result->bytesSent is the number of bytes written to the output stream. Mounted storage example: diff --git a/docs/file-manager.rst b/docs/file-manager.rst index f438425..64db595 100644 --- a/docs/file-manager.rst +++ b/docs/file-manager.rst @@ -36,7 +36,9 @@ Example: Brief capabilities: -* Memory-safe reads: line, char, binary chunk, CSV, JSON, XML. +* 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``. diff --git a/docs/queue.rst b/docs/queue.rst index 2498087..2a8d322 100644 --- a/docs/queue.rst +++ b/docs/queue.rst @@ -15,9 +15,12 @@ Brief capabilities: 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. Flysystem-backed queues retain portable -read/write behavior, but their storage adapter must provide any cross-process -coordination required by the application. +all attempted jobs, including failures. Queue files must be direct-local paths; +mounted and default-Flysystem paths are rejected. + +``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. Good fit: diff --git a/docs/recipes.rst b/docs/recipes.rst index bfa58e5..9310bfc 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -28,7 +28,10 @@ Goal: $audit->log('upload.processed', ['path' => $finalPath]); $retention = RetentionManager::apply('/tmp/uploads', keepLast: 50, maxAgeDays: 30); - $audit->log('retention.applied', $retention); + $audit->log('retention.applied', [ + 'deleted' => $retention->deleted, + 'kept' => $retention->kept, + ]); Recipe 2: Mirror + Zip + Checksum --------------------------------- diff --git a/docs/retention.rst b/docs/retention.rst index 60896ad..bcb8f75 100644 --- a/docs/retention.rst +++ b/docs/retention.rst @@ -10,6 +10,9 @@ Brief 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. Use cases: @@ -29,3 +32,7 @@ Example maxAgeDays: 30, sortBy: 'mtime', ); + + foreach ($report->deleted as $deletedPath) { + // Record or report the deleted path. + } diff --git a/docs/security.rst b/docs/security.rst index 1236c30..9390df4 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -11,6 +11,11 @@ Brief capabilities: * Support conditional callbacks for context-aware checks. * Enforce policy with explicit violations via ``PolicyViolationException``. +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. + Typical use: * Restrict write/delete to approved roots. diff --git a/docs/storage-contracts.rst b/docs/storage-contracts.rst index 59c8c48..6a6a021 100644 --- a/docs/storage-contracts.rst +++ b/docs/storage-contracts.rst @@ -94,7 +94,11 @@ ZIP Extraction 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. +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. Synchronization --------------- diff --git a/docs/upload-processing.rst b/docs/upload-processing.rst index 7f1b71c..bcd96e0 100644 --- a/docs/upload-processing.rst +++ b/docs/upload-processing.rst @@ -10,7 +10,9 @@ Where it fits: ``UploadProcessor`` supports: -* Standard upload handling with configurable destination strategy. +* 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. @@ -80,10 +82,26 @@ Resumable chunk flow: originalFilename: 'video.mp4', ); - if ($state['isComplete']) { + if ($state->complete) { $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 + + $finalPath = $uploader->ingestFile([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize('/srv/import/report.pdf'), + 'tmp_name' => '/srv/import/report.pdf', + 'name' => 'report.pdf', + ]); + Hardened chunk upload: .. code-block:: php diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsEntryConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsEntryConcern.php index 08ddc4a..3913445 100644 --- a/src/DirectoryManager/Concerns/DirectoryOperationsEntryConcern.php +++ b/src/DirectoryManager/Concerns/DirectoryOperationsEntryConcern.php @@ -185,21 +185,21 @@ private function listStorageEntries(string $path, bool $deep): \Generator /** * @param FindCriteria $criteria */ - private function matchesFindCriteria(array $criteria, string $resolvedPath, int $size, bool $isWindows): bool + private function matchesFindCriteria(array $criteria, string $resolvedPath, int $size): bool { - return (empty($criteria['name']) || str_contains(basename($resolvedPath), $criteria['name'])) - && (empty($criteria['extension']) || pathinfo($resolvedPath, PATHINFO_EXTENSION) === $criteria['extension']) - && $this->matchesPermissionsCriteria($criteria, $resolvedPath, $isWindows) - && (empty($criteria['minSize']) || $size >= $criteria['minSize']) - && (empty($criteria['maxSize']) || $size <= $criteria['maxSize']); + return (!array_key_exists('name', $criteria) || str_contains(basename($resolvedPath), $criteria['name'])) + && (!array_key_exists('extension', $criteria) || pathinfo($resolvedPath, PATHINFO_EXTENSION) === $criteria['extension']) + && $this->matchesPermissionsCriteria($criteria, $resolvedPath) + && (!array_key_exists('minSize', $criteria) || $size >= $criteria['minSize']) + && (!array_key_exists('maxSize', $criteria) || $size <= $criteria['maxSize']); } /** * @param FindCriteria $criteria */ - private function matchesPermissionsCriteria(array $criteria, string $resolvedPath, bool $isWindows): bool + private function matchesPermissionsCriteria(array $criteria, string $resolvedPath): bool { - if (empty($criteria['permissions']) || $isWindows) { + if (!array_key_exists('permissions', $criteria)) { return true; } diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php index ebd5dac..68edeca 100644 --- a/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php +++ b/src/DirectoryManager/Concerns/DirectoryOperationsSyncConcern.php @@ -63,6 +63,7 @@ private function attemptNativeCopy(string $destination, ?callable $progress): bo if ($this->executionStrategy === ExecutionStrategy::NATIVE) { throw new NativeExecutionException( "Native directory copy failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + $native, ); } @@ -191,7 +192,10 @@ private function filesMatchForSync( SyncComparison::ALWAYS_COPY => false, SyncComparison::SIZE => FlysystemHelper::size($sourcePath) === FlysystemHelper::size($targetPath), SyncComparison::SIZE_AND_MODIFIED_TIME => FlysystemHelper::size($sourcePath) === FlysystemHelper::size($targetPath) - && FlysystemHelper::lastModified($sourcePath) === FlysystemHelper::lastModified($targetPath), + && ( + FlysystemHelper::lastModified($sourcePath) === FlysystemHelper::lastModified($targetPath) + || $this->checksumsMatch($sourcePath, $targetPath) + ), SyncComparison::CHECKSUM => $this->checksumsMatch($sourcePath, $targetPath), }; } diff --git a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php index a86ed00..718470d 100644 --- a/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php +++ b/src/DirectoryManager/Concerns/DirectoryOperationsZipConcern.php @@ -59,35 +59,65 @@ protected function deleteDirectoryContents(string $directory): bool return true; } - private function addContentsToZip(ZipArchive $zip, string $zipPath): void + private function addContentsToZip(ZipArchive $zip, string $zipPath): ?string { if ($this->isLocalPath($this->path) && is_dir($this->path)) { $this->addLocalContentsToZip($zip, $zipPath); - return; + return null; } - $this->addFlysystemContentsToZip($zip); + return $this->addFlysystemContentsToZip($zip); } - private function addFlysystemContentsToZip(ZipArchive $zip): void + private function addFlysystemContentsToZip(ZipArchive $zip): string { - $sourceLocation = $this->storageLocation($this->path); - foreach ($this->listStorageEntries($this->path, true) as $item) { - $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); - if ($relative === '') { - continue; - } + $stagingDirectory = PathHelper::createTempDirectory('pathwise_zip_stage_'); + if (!is_string($stagingDirectory)) { + throw new DirectoryOperationException('Unable to create ZIP staging directory.'); + } - $zipPathName = str_replace('\\', '/', $relative); - if ($this->entryType($item) === 'dir') { - $zip->addEmptyDir(rtrim($zipPathName, '/')); + $sourceLocation = $this->storageLocation($this->path); - continue; + try { + foreach ($this->listStorageEntries($this->path, true) as $item) { + $relative = $this->relativeStoragePath($sourceLocation, $this->entryPath($item)); + if ($relative === '') { + continue; + } + + if ($this->zipDestination === $this->buildPath($this->path, $relative)) { + continue; + } + + $zipPathName = str_replace('\\', '/', $relative); + if ($this->entryType($item) === 'dir') { + $this->assertDirectoryZipMutation( + $zip->addEmptyDir(rtrim($zipPathName, '/')), + "add ZIP directory: {$zipPathName}", + ); + + continue; + } + + $stagedPath = PathHelper::join($stagingDirectory, $relative); + $parent = dirname($stagedPath); + 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), + "add ZIP entry: {$zipPathName}", + ); } + } catch (\Throwable $exception) { + PathHelper::deleteDirectory($stagingDirectory); - $zip->addFromString($zipPathName, FlysystemHelper::read($this->buildPath($this->path, $relative))); + throw $exception; } + + return $stagingDirectory; } private function addLocalContentsToZip(ZipArchive $zip, string $zipPath): void @@ -109,12 +139,22 @@ private function addLocalContentsToZip(ZipArchive $zip, string $zipPath): void $subPathName = ltrim(str_replace('\\', '/', substr($currentPath, strlen($normalizedSourcePath))), '/'); if ($file->isDir()) { - $zip->addEmptyDir($subPathName); + $this->assertDirectoryZipMutation($zip->addEmptyDir($subPathName), "add ZIP directory: {$subPathName}"); continue; } - $zip->addFile($file->getPathname(), $subPathName); + $this->assertDirectoryZipMutation( + $zip->addFile($file->getPathname(), $subPathName), + "add ZIP entry: {$subPathName}", + ); + } + } + + private function assertDirectoryZipMutation(bool $succeeded, string $operation): void + { + if (!$succeeded) { + throw new DirectoryOperationException("Unable to {$operation}."); } } @@ -174,7 +214,7 @@ private function extractZipContents(string $localSource, string $source, array $ private function openZipArchive(string $zipPath, string $destination, bool $useLocalDestination): ZipArchive { $zip = new ZipArchive(); - if ($zip->open($zipPath, ZipArchive::CREATE) === true) { + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { return $zip; } @@ -258,14 +298,15 @@ private function tryNativeUnzip(string $localSource, string $source): bool if (!$this->isLocalPath($source) || !$this->isLocalPath($this->path)) { throw new UnsupportedStorageOperationException('Native unzip requires local source and destination paths.'); } - if (!NativeOperationsAdapter::canUseNativeCompression()) { + if (!NativeOperationsAdapter::canUseNativeZipDecompression()) { throw new NativeExecutionException('Native ZIP decompression executables are unavailable.'); } } if ( $this->executionStrategy === ExecutionStrategy::PHP - || !NativeOperationsAdapter::canUseNativeCompression() + || !NativeOperationsAdapter::canUseNativeZipDecompression() + || !$this->isLocalPath($source) || !$this->isLocalPath($this->path) ) { return false; @@ -279,6 +320,7 @@ 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, ); } @@ -288,19 +330,24 @@ private function tryNativeUnzip(string $localSource, string $source): bool private function tryNativeZip(string $destination, bool $useLocalDestination): bool { if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - if (!$this->isLocalPath($this->path) || !$useLocalDestination) { + if ( + !$this->isLocalPath($this->path) + || !$useLocalDestination + || FlysystemHelper::isSameOrDescendant($this->path, $destination) + ) { throw new UnsupportedStorageOperationException('Native zip requires local source and destination paths.'); } - if (!NativeOperationsAdapter::canUseNativeCompression()) { + if (!NativeOperationsAdapter::canUseNativeZipCompression()) { throw new NativeExecutionException('Native ZIP compression executables are unavailable.'); } } if ( $this->executionStrategy === ExecutionStrategy::PHP - || !NativeOperationsAdapter::canUseNativeCompression() + || !NativeOperationsAdapter::canUseNativeZipCompression() || !$this->isLocalPath($this->path) || !$useLocalDestination + || FlysystemHelper::isSameOrDescendant($this->path, $destination) ) { return false; } @@ -313,6 +360,7 @@ private function tryNativeZip(string $destination, bool $useLocalDestination): b if ($this->executionStrategy === ExecutionStrategy::NATIVE) { throw new NativeExecutionException( "Native zip failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + $native, ); } @@ -330,7 +378,14 @@ private function validateZipEntries(string $localSource, string $source): array } try { - return ZipEntryValidator::validateArchive($zip, $this->path); + return ZipEntryValidator::validateArchive( + $zip, + $this->path, + $this->maxEntries, + $this->maxEntryUncompressedBytes, + $this->maxTotalUncompressedBytes, + $this->maxCompressionRatio, + ); } finally { $zip->close(); } diff --git a/src/DirectoryManager/DirectoryOperations.php b/src/DirectoryManager/DirectoryOperations.php index 817221a..2c21c1a 100644 --- a/src/DirectoryManager/DirectoryOperations.php +++ b/src/DirectoryManager/DirectoryOperations.php @@ -13,6 +13,7 @@ use Infocyph\Pathwise\Exceptions\DirectoryOperationException; use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\Results\SyncReport; +use Infocyph\Pathwise\Security\ZipEntryValidator; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; use Infocyph\Pathwise\Utils\PermissionsHelper; @@ -47,6 +48,16 @@ class DirectoryOperations private ExecutionStrategy $executionStrategy = ExecutionStrategy::AUTO; + private float $maxCompressionRatio = ZipEntryValidator::DEFAULT_MAX_COMPRESSION_RATIO; + + private int $maxEntries = ZipEntryValidator::DEFAULT_MAX_ENTRIES; + + private int $maxEntryUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_ENTRY_UNCOMPRESSED_BYTES; + + private int $maxTotalUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_TOTAL_UNCOMPRESSED_BYTES; + + private ?string $zipDestination = null; + /** * Constructor to initialize the directory path. * @@ -71,6 +82,7 @@ public function copy(string $destination, ?callable $progress = null): self } $destination = PathHelper::normalize($destination); + $this->assertDestinationOutsideSource($destination, 'copy'); if (!FlysystemHelper::directoryExists($destination)) { FlysystemHelper::createDirectory($destination); } @@ -163,20 +175,21 @@ public function delete(bool $recursive = false): self * - minSize: minimum size of the file * - maxSize: maximum size of the file * - * @param FindCriteria $criteria The criteria to match against. + * @param array $criteria The criteria to match against. * @return list A list of file paths that match the criteria. */ public function find(array $criteria = []): array { $results = []; $isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + $criteria = $this->validateFindCriteria($criteria, $isWindows); foreach ($this->iterateResolvedEntries(true, true) as $entry) { $resolvedPath = $entry['path']; $item = $entry['item']; $size = $this->entrySize($item); - if (!$this->matchesFindCriteria($criteria, $resolvedPath, $size, $isWindows)) { + if (!$this->matchesFindCriteria($criteria, $resolvedPath, $size)) { continue; } @@ -324,6 +337,10 @@ public function listPermissions(): string */ public function listSortedContents(string $sortOrder = 'asc'): array { + if (!in_array($sortOrder, ['asc', 'desc'], true)) { + throw new InvalidArgumentException("Invalid sort order: {$sortOrder}."); + } + $contents = []; foreach ($this->iterateResolvedEntries(false, false) as $entry) { @@ -350,9 +367,11 @@ public function move(string $destination): self throw new DirectoryOperationException("Directory does not exist: {$this->path}"); } - FlysystemHelper::moveDirectory($this->path, PathHelper::normalize($destination)); + $destination = PathHelper::normalize($destination); + $this->assertDestinationOutsideSource($destination, 'move'); + FlysystemHelper::moveDirectory($this->path, $destination); - $this->path = PathHelper::normalize($destination); + $this->path = $destination; return $this; } @@ -370,6 +389,29 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self return $this; } + /** + * Configure unzip resource limits. A value of zero disables that limit. + */ + public function setExtractionLimits( + int $maxEntries = ZipEntryValidator::DEFAULT_MAX_ENTRIES, + int $maxEntryUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_ENTRY_UNCOMPRESSED_BYTES, + int $maxTotalUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_TOTAL_UNCOMPRESSED_BYTES, + float $maxCompressionRatio = ZipEntryValidator::DEFAULT_MAX_COMPRESSION_RATIO, + ): self { + ZipEntryValidator::validateArchiveLimits( + $maxEntries, + $maxEntryUncompressedBytes, + $maxTotalUncompressedBytes, + $maxCompressionRatio, + ); + $this->maxEntries = $maxEntries; + $this->maxEntryUncompressedBytes = $maxEntryUncompressedBytes; + $this->maxTotalUncompressedBytes = $maxTotalUncompressedBytes; + $this->maxCompressionRatio = $maxCompressionRatio; + + return $this; + } + /** * Set the permissions of the directory to the given value. * @@ -436,15 +478,15 @@ public function syncTo( ?SyncComparison $comparison = null, ): SyncReport { $this->assertSourceDirectoryExists(); + $destination = PathHelper::normalize($destination); + $this->assertDestinationOutsideSource($destination, 'synchronize'); $destination = $this->ensureDirectoryExists($destination); $report = $this->newSyncReport(); $sourceEntries = []; $sourceLocation = $this->storageLocation($this->path); $sourceItems = $this->listStorageEntries($this->path, true); - $comparison ??= $this->isLocalPath($this->path) && $this->isLocalPath($destination) - ? SyncComparison::SIZE_AND_MODIFIED_TIME - : SyncComparison::SIZE; + $comparison ??= SyncComparison::CHECKSUM; $current = 0; foreach ($sourceItems as $item) { @@ -474,11 +516,11 @@ public function unzip(string $source): self { $source = PathHelper::normalize($source); $this->assertZipSourceExists($source); - $this->ensureDirectoryExists($this->path); [$localSource, $cleanupSource] = $this->prepareLocalZipSource($source); try { $validatedEntries = $this->validateZipEntries($localSource, $source); + $this->ensureDirectoryExists($this->path); if ($this->tryNativeUnzip($localSource, $source)) { return $this; @@ -522,13 +564,25 @@ public function zip(string $destination): self return $this; } + $this->zipDestination = $destination; $zipPath = $this->prepareZipPath($destination, $useLocalDestination); $zip = $this->openZipArchive($zipPath, $destination, $useLocalDestination); + $stagingDirectory = null; try { - $this->addContentsToZip($zip, $zipPath); - } finally { + $stagingDirectory = $this->addContentsToZip($zip, $zipPath); + if (!$zip->close()) { + throw new DirectoryOperationException("Unable to save ZIP archive at '{$destination}'."); + } + } catch (\Throwable $exception) { $zip->close(); + + throw $exception; + } finally { + if (is_string($stagingDirectory)) { + PathHelper::deleteDirectory($stagingDirectory); + } + $this->zipDestination = null; } if (!$useLocalDestination) { @@ -538,6 +592,15 @@ public function zip(string $destination): self return $this; } + private function assertDestinationOutsideSource(string $destination, string $operation): void + { + if (FlysystemHelper::isSameOrDescendant($this->path, $destination)) { + throw new DirectoryOperationException( + "Cannot {$operation} directory '{$this->path}' into itself or one of its descendants.", + ); + } + } + /** * @return \Generator */ @@ -562,4 +625,72 @@ private function iterateResolvedEntries(bool $deep, bool $filesOnly): \Generator ]; } } + + /** @param array $criteria */ + private function optionalIntegerFindCriterion(array $criteria, string $name): ?int + { + if (!array_key_exists($name, $criteria)) { + return null; + } + $value = $criteria[$name]; + if (!is_int($value) || $value < 0) { + throw new InvalidArgumentException("Find criterion '{$name}' must be a non-negative integer."); + } + + return $value; + } + + /** @param array $criteria */ + private function optionalStringFindCriterion(array $criteria, string $name): ?string + { + if (!array_key_exists($name, $criteria)) { + return null; + } + $value = $criteria[$name]; + if (!is_string($value)) { + throw new InvalidArgumentException("Find criterion '{$name}' must be a string."); + } + + return $value; + } + + /** + * @param array $criteria + * @return FindCriteria + */ + private function validateFindCriteria(array $criteria, bool $isWindows): array + { + foreach (array_keys($criteria) as $name) { + if (!in_array($name, ['name', 'extension', 'permissions', 'minSize', 'maxSize'], true)) { + throw new InvalidArgumentException("Unknown find criterion: {$name}."); + } + } + if ($isWindows && array_key_exists('permissions', $criteria)) { + throw new UnsupportedStorageOperationException('Permission criteria are unsupported on Windows.'); + } + + $validated = []; + $name = $this->optionalStringFindCriterion($criteria, 'name'); + $extension = $this->optionalStringFindCriterion($criteria, 'extension'); + $permissions = $this->optionalIntegerFindCriterion($criteria, 'permissions'); + $minSize = $this->optionalIntegerFindCriterion($criteria, 'minSize'); + $maxSize = $this->optionalIntegerFindCriterion($criteria, 'maxSize'); + if ($name !== null) { + $validated['name'] = $name; + } + if ($extension !== null) { + $validated['extension'] = $extension; + } + if ($permissions !== null) { + $validated['permissions'] = $permissions; + } + if ($minSize !== null) { + $validated['minSize'] = $minSize; + } + if ($maxSize !== null) { + $validated['maxSize'] = $maxSize; + } + + return $validated; + } } diff --git a/src/Exceptions/AuditException.php b/src/Exceptions/AuditException.php new file mode 100644 index 0000000..629dc9d --- /dev/null +++ b/src/Exceptions/AuditException.php @@ -0,0 +1,7 @@ + $rollbackFailures + */ + public function __construct( + public readonly \Throwable $originalFailure, + public readonly array $rollbackFailures, + ) { + $details = array_map( + static fn(\Throwable $failure): string => $failure->getMessage(), + $rollbackFailures, + ); + parent::__construct( + 'Transaction failed and rollback was incomplete: ' . implode('; ', $details), + 0, + $originalFailure, + ); + } +} diff --git a/src/FileManager/Concerns/FileCompressionArchiveConcern.php b/src/FileManager/Concerns/FileCompressionArchiveConcern.php index 680b4ba..5949754 100644 --- a/src/FileManager/Concerns/FileCompressionArchiveConcern.php +++ b/src/FileManager/Concerns/FileCompressionArchiveConcern.php @@ -25,15 +25,22 @@ trait FileCompressionArchiveConcern { private function addArchiveEntry(ZipArchive $zip, string $sourcePath, string $relativePath): void { + $this->triggerHook('beforeAdd', $sourcePath, $relativePath); if ($this->password !== null) { - $zip->setPassword($this->password); - $zip->addFile($sourcePath, $relativePath); - $zip->setEncryptionName($relativePath, $this->encryptionAlgorithm); + $this->assertZipMutation($zip->setPassword($this->password), 'set the ZIP password'); + $this->assertZipMutation($zip->addFile($sourcePath, $relativePath), "add ZIP entry: {$relativePath}"); + $this->assertZipMutation( + $zip->setEncryptionName($relativePath, $this->encryptionAlgorithm), + "encrypt ZIP entry: {$relativePath}", + ); + + $this->triggerHook('afterAdd', $sourcePath, $relativePath); return; } - $zip->addFile($sourcePath, $relativePath); + $this->assertZipMutation($zip->addFile($sourcePath, $relativePath), "add ZIP entry: {$relativePath}"); + $this->triggerHook('afterAdd', $sourcePath, $relativePath); } private function addDirectoryEntriesToZip(string $path, ZipArchive $zip, string $baseDir): void @@ -44,7 +51,7 @@ private function addDirectoryEntriesToZip(string $path, ZipArchive $zip, string } if ($relativePath !== '') { - $zip->addEmptyDir($relativePath); + $this->assertZipMutation($zip->addEmptyDir($relativePath), "add ZIP directory: {$relativePath}"); } $entries = scandir($path); @@ -107,7 +114,7 @@ private function addFilesToZipWithFilter(string $path, ZipArchive $zip, ?string if ($relativePath !== '' && !$this->shouldTraverseDirectory($relativePath)) { return; } - $zip->addEmptyDir($relativePath); + $this->assertZipMutation($zip->addEmptyDir($relativePath), "add ZIP directory: {$relativePath}"); $entries = scandir($path); if ($entries === false) { throw new CompressionException("Failed to read directory: {$path}"); @@ -126,21 +133,29 @@ private function addFilesToZipWithFilter(string $path, ZipArchive $zip, ?string private function addFileToArchive(string $filePath, string $zipPath): void { + $this->triggerHook('beforeAdd', $filePath, $zipPath); if ($this->password !== null) { - $this->zip->setPassword($this->password); + $this->assertZipMutation($this->zip->setPassword($this->password), 'set the ZIP password'); } - $added = $this->isLocalFilesystemPath($filePath) - ? $this->zip->addFile($filePath, $zipPath) - : $this->zip->addFromString($zipPath, FlysystemHelper::read($filePath)); + $cleanupPath = null; + $localFilePath = $this->isLocalFilesystemPath($filePath) + ? $filePath + : $this->localizeCompressionSource($filePath, $cleanupPath); + $this->deferLocalizedCleanupPath($cleanupPath); + $added = $this->zip->addFile($localFilePath, $zipPath); if (!$added) { throw new CompressionException("Failed to add file to ZIP: $filePath"); } if ($this->password !== null) { - $this->zip->setEncryptionName($zipPath, $this->encryptionAlgorithm); + $this->assertZipMutation( + $this->zip->setEncryptionName($zipPath, $this->encryptionAlgorithm), + "encrypt ZIP entry: {$zipPath}", + ); } + $this->triggerHook('afterAdd', $filePath, $zipPath); } private function addSinglePathToZip(string $path, ZipArchive $zip, string $baseDir): void @@ -176,7 +191,14 @@ private function advanceProgress(string $operation, string $path): void private function applyArchivePassword(): void { if ($this->password !== null) { - $this->zip->setPassword($this->password); + $this->assertZipMutation($this->zip->setPassword($this->password), 'set the ZIP password'); + } + } + + private function assertZipMutation(bool $succeeded, string $operation): void + { + if (!$succeeded) { + throw new CompressionException("Unable to {$operation}."); } } @@ -191,7 +213,7 @@ private function attemptNativeDecompression(string $destination, bool $isRemoteD if ($this->password !== null) { throw new NativeExecutionException('Native decompression is unavailable for password-protected archives.'); } - if (!NativeOperationsAdapter::canUseNativeCompression()) { + if (!NativeOperationsAdapter::canUseNativeZipDecompression()) { throw new NativeExecutionException('Native ZIP decompression executables are unavailable.'); } } @@ -200,7 +222,9 @@ private function attemptNativeDecompression(string $destination, bool $isRemoteD $this->executionStrategy === ExecutionStrategy::PHP || $this->password !== null || $isRemoteDestination - || !NativeOperationsAdapter::canUseNativeCompression() + || !FlysystemHelper::isLocalPath($this->zipFilePath) + || !FlysystemHelper::isLocalPath($destination) + || !NativeOperationsAdapter::canUseNativeZipDecompression() ) { return false; } @@ -224,6 +248,7 @@ private function attemptNativeDecompression(string $destination, bool $isRemoteD if ($this->executionStrategy === ExecutionStrategy::NATIVE) { throw new NativeExecutionException( "Native decompression failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + $native, ); } @@ -359,9 +384,13 @@ private function ensureLocalExtractionDirectory(string $directory, string $entry } } - private function extractArchive(string $extractDestination, string $destination, bool $isRemoteDestination): void - { - $entries = ZipEntryValidator::validateArchive($this->zip, $extractDestination); + /** @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); } @@ -429,7 +458,7 @@ private function initializeProgress(string $source, array $extensions = []): voi private function isLocalFilesystemPath(string $path): bool { - return !PathHelper::hasScheme($path) && is_file($path); + return FlysystemHelper::isLocalPath($path) && is_file($path); } private function isRemotePath(string $path): bool @@ -511,4 +540,17 @@ private function shouldAttemptNativeCompression(): bool && $this->ignorePatterns === [] && $this->hooks === []; } + + /** @return array */ + private function validateArchiveForExtraction(string $destination): array + { + return ZipEntryValidator::validateArchive( + $this->zip, + $destination, + $this->maxEntries, + $this->maxEntryUncompressedBytes, + $this->maxTotalUncompressedBytes, + $this->maxCompressionRatio, + ); + } } diff --git a/src/FileManager/Concerns/FileCompressionRuntimeConcern.php b/src/FileManager/Concerns/FileCompressionRuntimeConcern.php index 0a98070..6a24aa1 100644 --- a/src/FileManager/Concerns/FileCompressionRuntimeConcern.php +++ b/src/FileManager/Concerns/FileCompressionRuntimeConcern.php @@ -68,9 +68,13 @@ private function cleanupLocalizedPath(?string $path): void private function closeZip(): void { if ($this->isOpen) { - $this->zip->close(); + $this->triggerHook('beforeSave', $this->zipFilePath); + if (!$this->zip->close()) { + throw new CompressionException("Failed to save ZIP archive at {$this->zipFilePath}."); + } $this->isOpen = false; $this->syncWorkingZipIfNeeded(); + $this->triggerHook('afterSave', $this->zipFilePath); } $this->cleanupDeferredLocalizedPaths(); diff --git a/src/FileManager/Concerns/FsConcern.php b/src/FileManager/Concerns/FsConcern.php index b1991de..bfc5067 100644 --- a/src/FileManager/Concerns/FsConcern.php +++ b/src/FileManager/Concerns/FsConcern.php @@ -165,7 +165,7 @@ private function doResolveWorkingZipPath(bool $create): string $this->syncWorkingZipOnClose = true; $normalizedTemp = PathHelper::normalize($tempFile); - if (FlysystemHelper::fileExists($this->zipFilePath)) { + if (!$create && FlysystemHelper::fileExists($this->zipFilePath)) { try { $this->doCopyFlysystemFileToLocal($this->zipFilePath, $normalizedTemp); } catch (\Throwable) { diff --git a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php index 350c306..a6b8104 100644 --- a/src/FileManager/Concerns/SafeFileWriterWriteConcern.php +++ b/src/FileManager/Concerns/SafeFileWriterWriteConcern.php @@ -10,6 +10,24 @@ trait SafeFileWriterWriteConcern { + private function isSafeSerializedValue(mixed $value, int $depth = 0): bool + { + if ($depth > 256) { + return false; + } + if (is_float($value)) { + return is_finite($value); + } + if ($value === null || is_bool($value) || is_int($value) || is_string($value)) { + return true; + } + if (!is_array($value)) { + return false; + } + + return array_all($value, fn(mixed $item): bool => $this->isSafeSerializedValue($item, $depth + 1)); + } + /** * @param list $params */ @@ -70,6 +88,9 @@ private function requireCsvRowParam(array $params, int $index, string $type): ar if (!is_string($column) && !is_int($column) && !is_float($column) && !is_bool($column) && $column !== null) { throw new FileAccessException("Write type '{$type}' expects scalar CSV values."); } + if (is_float($column) && !is_finite($column)) { + throw new FileAccessException("Write type '{$type}' expects finite CSV values."); + } $row[] = $column; } @@ -256,10 +277,12 @@ private function writeJsonArrayData(array $data, bool $prettyPrint = false): int private function writeJsonEncodedLine(mixed $data, bool $prettyPrint): int|false { - $jsonOptions = $prettyPrint ? JSON_PRETTY_PRINT : 0; - $jsonData = json_encode($data, $jsonOptions); - if ($jsonData === false) { - throw new FileAccessException('JSON encoding failed: ' . json_last_error_msg()); + $jsonOptions = JSON_THROW_ON_ERROR | ($prettyPrint ? JSON_PRETTY_PRINT : 0); + + try { + $jsonData = json_encode($data, $jsonOptions); + } catch (\JsonException $exception) { + throw new FileAccessException('JSON encoding failed: ' . $exception->getMessage(), 0, $exception); } $this->writeCount++; @@ -312,13 +335,23 @@ private function writeLineData(string $content): int|false */ private function writeMatchingLineData(string $content, string $pattern): int|false { - if (preg_match($pattern, $content)) { + set_error_handler(static fn(): bool => true); + + try { + $matched = preg_match($pattern, $content); + } finally { + restore_error_handler(); + } + if ($matched === false) { + throw new FileAccessException('Invalid regular-expression pattern.'); + } + if ($matched === 1) { $this->writeCount++; return $this->requireFileHandle()->fwrite($content . PHP_EOL); } - return false; + return 0; } /** @@ -333,6 +366,9 @@ private function writeMatchingLineData(string $content, string $pattern): int|fa */ private function writeSerializedData(mixed $data): int|false { + if (!$this->isSafeSerializedValue($data)) { + throw new FileAccessException('Serialized values must contain only safe scalar and array types.'); + } $serializedData = serialize($data); $this->writeCount++; @@ -350,8 +386,12 @@ private function writeSerializedData(mixed $data): int|false */ private function writeXmlData(SimpleXMLElement $element): int|false { + $xml = $element->asXML(); + if (!is_string($xml)) { + throw new FileAccessException('XML serialization failed.'); + } $this->writeCount++; - return $this->requireFileHandle()->fwrite($element->asXML() . PHP_EOL); + return $this->requireFileHandle()->fwrite($xml . PHP_EOL); } } diff --git a/src/FileManager/FileCompression.php b/src/FileManager/FileCompression.php index b8413d2..35f7c6d 100644 --- a/src/FileManager/FileCompression.php +++ b/src/FileManager/FileCompression.php @@ -64,6 +64,14 @@ class FileCompression private mixed $logger = null; + private float $maxCompressionRatio = ZipEntryValidator::DEFAULT_MAX_COMPRESSION_RATIO; + + private int $maxEntries = ZipEntryValidator::DEFAULT_MAX_ENTRIES; + + private int $maxEntryUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_ENTRY_UNCOMPRESSED_BYTES; + + private int $maxTotalUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_TOTAL_UNCOMPRESSED_BYTES; + private ?string $password = null; private mixed $progressCallback = null; @@ -92,11 +100,9 @@ public function __construct(private readonly string $zipFilePath, bool $create = $this->zip = new ZipArchive(); $this->workingZipPath = $this->resolveWorkingZipPath($create); - // Set encryption algorithm if supported - $this->encryptionAlgorithm = defined('ZipArchive::EM_AES_256') ? ZipArchive::EM_AES_256 : 0; + $this->encryptionAlgorithm = ZipArchive::EM_AES_256; - // Open the archive with CREATE flag if specified - $flags = $create ? ZipArchive::CREATE : 0; + $flags = $create ? ZipArchive::CREATE | ZipArchive::OVERWRITE : 0; $this->openZip($flags); } @@ -131,17 +137,14 @@ public function addFile(string $filePath, ?string $zipPath = null): self if (!FlysystemHelper::fileExists($filePath)) { throw new CompressionException("File does not exist: $filePath"); } - $this->triggerHook('beforeAdd', $filePath); - $this->log("Adding file: $filePath"); $zipPath ??= basename($filePath); $zipPath = $this->normalizeZipPath($zipPath); + $this->log("Adding file: $filePath"); $this->addFileToArchive($filePath, $zipPath); $this->progressTotal = max(1, $this->progressTotal); $this->advanceProgress('compress', $zipPath); - $this->triggerHook('afterAdd', $filePath); - return $this; } @@ -236,6 +239,13 @@ public function batchExtractFiles(array $files, string $destination): self */ public function compress(string $source): self { + if ( + FlysystemHelper::directoryExists($source) + && FlysystemHelper::isSameOrDescendant($source, $this->zipFilePath) + ) { + throw new CompressionException('The destination archive must be outside the source directory.'); + } + $this->reopenIfNeeded(); $resolvedSource = $this->prepareCompressionSource($source); @@ -243,7 +253,7 @@ public function compress(string $source): self $this->assertNativeCompressionSupported($source); } - if ($this->shouldAttemptNativeCompression() && NativeOperationsAdapter::canUseNativeCompression()) { + if ($this->shouldAttemptNativeCompression() && NativeOperationsAdapter::canUseNativeZipCompression()) { $this->closeZip(); $native = NativeOperationsAdapter::compressToZip($resolvedSource, $this->workingZipPath); if ($native->success) { @@ -263,6 +273,7 @@ public function compress(string $source): self if ($this->executionStrategy === ExecutionStrategy::NATIVE) { throw new NativeExecutionException( "Native compression failed with exit code {$native->exitCode}: " . implode("\n", $native->output), + $native, ); } @@ -314,6 +325,7 @@ public function decompress(?string $destination = null): self { $this->reopenIfNeeded(); $destination = $this->resolveDecompressionDestination($destination); + $validatedEntries = $this->validateArchiveForExtraction($destination); ['extractDestination' => $extractDestination, 'extractTempDir' => $extractTempDir, 'isRemote' => $isRemoteDestination] = $this->prepareExtractionDestination($destination); if ($this->attemptNativeDecompression($destination, $isRemoteDestination)) { @@ -323,7 +335,7 @@ public function decompress(?string $destination = null): self $this->applyArchivePassword(); try { - $this->extractArchive($extractDestination, $destination, $isRemoteDestination); + $this->extractArchive($validatedEntries, $extractDestination, $destination, $isRemoteDestination); $this->emitDecompressionProgress(); } finally { if ($extractTempDir !== null) { @@ -412,20 +424,16 @@ public function listFiles(): array * Supported events are: * * - `beforeAdd`: Called before a file or directory is added to the ZIP archive. - * The callback will receive the path to the file or directory as its first - * argument, and the ZipArchive object as its second argument. + * The callback receives the source path and archive entry path. * * - `afterAdd`: Called after a file or directory has been added to the ZIP archive. - * The callback will receive the path to the file or directory as its first - * argument, and the ZipArchive object as its second argument. + * The callback receives the source path and archive entry path. * * - `beforeSave`: Called before the ZIP archive is saved to disk. - * The callback will receive the path to the file to be saved as its first - * argument, and the ZipArchive object as its second argument. + * The callback receives the archive path. * * - `afterSave`: Called after the ZIP archive has been saved to disk. - * The callback will receive the path to the file that was saved as its first - * argument, and the ZipArchive object as its second argument. + * The callback receives the archive path. * * @param string $event The name of the event to register the callback for. * @param callable $callback The callback to register. @@ -498,6 +506,29 @@ public function setExecutionStrategy(ExecutionStrategy $executionStrategy): self return $this; } + /** + * Configure extraction resource limits. A value of zero disables that limit. + */ + public function setExtractionLimits( + int $maxEntries = ZipEntryValidator::DEFAULT_MAX_ENTRIES, + int $maxEntryUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_ENTRY_UNCOMPRESSED_BYTES, + int $maxTotalUncompressedBytes = ZipEntryValidator::DEFAULT_MAX_TOTAL_UNCOMPRESSED_BYTES, + float $maxCompressionRatio = ZipEntryValidator::DEFAULT_MAX_COMPRESSION_RATIO, + ): self { + ZipEntryValidator::validateArchiveLimits( + $maxEntries, + $maxEntryUncompressedBytes, + $maxTotalUncompressedBytes, + $maxCompressionRatio, + ); + $this->maxEntries = $maxEntries; + $this->maxEntryUncompressedBytes = $maxEntryUncompressedBytes; + $this->maxTotalUncompressedBytes = $maxTotalUncompressedBytes; + $this->maxCompressionRatio = $maxCompressionRatio; + + return $this; + } + /** * Configure include/exclude glob patterns used during compression. * @@ -552,6 +583,13 @@ public function setLogger(callable $logger): self */ public function setPassword(string $password): self { + if ($password === '') { + throw new CompressionException('ZIP passwords must not be empty.'); + } + if (!defined('ZipArchive::EM_AES_256')) { + throw new CompressionException('AES ZIP encryption is unavailable in the installed ZIP extension.'); + } + $this->password = $password; return $this; @@ -580,7 +618,7 @@ private function assertNativeCompressionSupported(string $source): void if (!$this->shouldAttemptNativeCompression()) { throw new NativeExecutionException('Native compression does not support the selected archive options.'); } - if (!NativeOperationsAdapter::canUseNativeCompression()) { + if (!NativeOperationsAdapter::canUseNativeZipCompression()) { throw new NativeExecutionException('Native ZIP compression executables are unavailable.'); } } diff --git a/src/FileManager/FileOperations.php b/src/FileManager/FileOperations.php index 71e0e42..039cd66 100644 --- a/src/FileManager/FileOperations.php +++ b/src/FileManager/FileOperations.php @@ -113,7 +113,9 @@ public function commitTransaction(): self */ public function copy(string $destination, ?callable $progress = null): self { + $destination = PathHelper::normalize($destination); $this->assertPolicy('copy', $this->filePath, ['destination' => $destination]); + $this->assertPolicy('write', $destination, ['source' => $this->filePath, 'operation' => 'copy']); $this->emitCopyProgress($progress, $destination, 0); $this->recordFileState($destination); @@ -129,12 +131,12 @@ public function copy(string $destination, ?callable $progress = null): self */ public function copyWithVerification(string $destination, string $algorithm = 'sha256'): self { - $this->copy($destination); - if (!in_array($algorithm, hash_algos(), true)) { throw new FileAccessException("Unsupported checksum algorithm: {$algorithm}"); } + $this->copy($destination); + $sourceHash = FlysystemHelper::checksum($this->filePath, $algorithm); $destinationHash = FlysystemHelper::checksum($destination, $algorithm); if (!is_string($sourceHash) || !is_string($destinationHash) || !hash_equals($sourceHash, $destinationHash)) { @@ -316,11 +318,13 @@ public function readStream(): mixed */ public function rename(string $newPath): self { - $this->assertPolicy('rename', $this->filePath, ['destination' => $newPath]); $newPath = PathHelper::normalize($newPath); + $this->assertPolicy('rename', $this->filePath, ['destination' => $newPath]); + $this->assertPolicy('write', $newPath, ['source' => $this->filePath, 'operation' => 'rename']); $oldPath = $this->filePath; $this->recordFileState($oldPath); $this->recordFileState($newPath); + $this->file = null; try { FlysystemHelper::move($this->filePath, $newPath); @@ -328,7 +332,6 @@ public function rename(string $newPath): self throw new FileAccessException("Unable to rename or move file to $newPath.", 0, $e); } $this->filePath = $newPath; - $this->initFile(); // Reinitialize file object with new path $this->audit('rename', ['from' => $oldPath, 'to' => $newPath]); return $this; @@ -339,47 +342,56 @@ public function rename(string $newPath): self * * @return self This instance for method chaining. */ - public function rollbackTransaction(): self + public function rollbackTransaction(?\Throwable $originalFailure = null): self { $this->assertTransactionActive('rollback'); $journal = $this->transactionJournal; if (!$journal instanceof FileTransactionJournal) { throw new TransactionStateException('Transaction journal is unavailable.'); } - $journal->rollback(); - $this->filePath = $journal->originalPath; - $this->file = null; - $this->transactionActive = false; - $this->transactionJournal = null; + + try { + $journal->rollback($originalFailure); + } finally { + $this->filePath = $journal->originalPath; + $this->file = null; + $this->transactionActive = false; + $this->transactionJournal = null; + } return $this; } - /** - * Search for a term in the file using OS-native commands and return matching lines. - * - * @return list - */ + /** @return list */ public function searchContent(string $searchTerm): array { - $this->assertLocalOperation('native content searching'); - $command = escapeshellarg($this->filePath); - $escapedTerm = escapeshellarg($searchTerm); + if ($this->executionStrategy === ExecutionStrategy::NATIVE) { + if (!FlysystemHelper::isLocalPath($this->filePath)) { + throw new UnsupportedStorageOperationException('Native content search requires a local file path.'); + } + if (!NativeOperationsAdapter::canUseNativeSearch()) { + throw new NativeExecutionException('Native content search executable is unavailable.'); + } - $output = []; - $returnVar = 0; + $result = $this->searchContentNatively($searchTerm, true); + if ($result === null) { + throw new NativeExecutionException('Native content search failed without a result.'); + } - if (PHP_OS_FAMILY === 'Windows') { - exec("findstr /I $escapedTerm $command", $output, $returnVar); - } else { - exec("grep -i $escapedTerm $command", $output, $returnVar); + return $result; } - - if ($returnVar !== 0 && empty($output)) { - return []; + if ( + $this->executionStrategy === ExecutionStrategy::AUTO + && FlysystemHelper::isLocalPath($this->filePath) + && NativeOperationsAdapter::canUseNativeSearch() + ) { + $native = $this->searchContentNatively($searchTerm, false); + if ($native !== null) { + return $native; + } } - return $output; + return $this->searchContentWithPhp($searchTerm); } /** @@ -474,6 +486,11 @@ public function setPolicyEngine(PolicyEngine $policyEngine): self */ public function setVisibility(string $visibility): self { + if ($this->transactionActive) { + throw new UnsupportedStorageOperationException( + 'Visibility changes are not supported inside file transactions.', + ); + } $this->assertPolicy('set-visibility', $this->filePath, ['visibility' => $visibility]); FlysystemHelper::setVisibility($this->filePath, $visibility); $this->audit('set-visibility', ['path' => $this->filePath, 'visibility' => $visibility]); @@ -515,15 +532,7 @@ public function transaction(callable $callback): mixed return $result; } catch (\Throwable $e) { - try { - $this->rollbackTransaction(); - } catch (\Throwable $rollbackFailure) { - throw new FileAccessException( - 'Transaction failed and rollback was incomplete: ' . $rollbackFailure->getMessage(), - 0, - $e, - ); - } + $this->rollbackTransaction($e); throw $e; } @@ -728,7 +737,11 @@ private function emitCopyProgress(?callable $progress, string $destination, int private function performCopy(string $destination): void { if ($this->executionStrategy === ExecutionStrategy::NATIVE) { - $this->assertLocalOperation('native copy'); + if (!FlysystemHelper::isLocalPath($this->filePath) || !FlysystemHelper::isLocalPath($destination)) { + throw new UnsupportedStorageOperationException( + 'Native copy requires local filesystem paths for both source and destination.', + ); + } if (!NativeOperationsAdapter::canUseNativeFileCopy()) { throw new NativeExecutionException('Native file copy executable is unavailable.'); } @@ -736,6 +749,7 @@ private function performCopy(string $destination): void if (!$native->success) { throw new NativeExecutionException( "Native file copy failed with exit code {$native->exitCode}: {$native->command}", + $native, ); } @@ -786,4 +800,47 @@ private function requireFile(string $mode = 'r'): SplFileObject return $this->file; } + + /** @return list|null */ + private function searchContentNatively(string $searchTerm, bool $throwOnFailure): ?array + { + $result = NativeOperationsAdapter::searchFile($this->filePath, $searchTerm); + if ($result->success) { + return $result->output; + } + if ($result->exitCode === 1) { + return []; + } + if ($throwOnFailure) { + throw new NativeExecutionException( + "Native content search failed with exit code {$result->exitCode}.", + $result, + ); + } + + return null; + } + + /** @return list */ + private function searchContentWithPhp(string $searchTerm): array + { + $stream = FlysystemHelper::readStream($this->filePath); + if (!is_resource($stream)) { + throw new FileAccessException("Unable to read file: {$this->filePath}."); + } + + $matches = []; + + try { + while (($line = fgets($stream)) !== false) { + if (stripos($line, $searchTerm) !== false) { + $matches[] = rtrim($line, "\r\n"); + } + } + } finally { + fclose($stream); + } + + return $matches; + } } diff --git a/src/FileManager/FileTransactionJournal.php b/src/FileManager/FileTransactionJournal.php index b45bcbc..d904e95 100644 --- a/src/FileManager/FileTransactionJournal.php +++ b/src/FileManager/FileTransactionJournal.php @@ -5,57 +5,84 @@ namespace Infocyph\Pathwise\FileManager; use Infocyph\Pathwise\Exceptions\FileAccessException; +use Infocyph\Pathwise\Exceptions\TransactionRollbackException; final class FileTransactionJournal { - /** @var list */ + /** @var list */ private array $entries = []; + /** @var array */ + private array $recordedPaths = []; + public function __construct(public readonly string $originalPath) {} public function commit(): void { $this->cleanup(); $this->entries = []; + $this->recordedPaths = []; } public function record(string $path): void { + $path = \Infocyph\Pathwise\Utils\PathHelper::normalize($path); + if (isset($this->recordedPaths[$path])) { + return; + } + $existed = is_file($path); $backup = null; - $permissions = null; + $mode = null; + $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}."); } - $mode = fileperms($path); - $permissions = is_int($mode) ? $mode & 0777 : null; + $fileMode = fileperms($path); + $fileOwner = fileowner($path); + $fileGroup = filegroup($path); + $mode = is_int($fileMode) ? $fileMode & 0777 : null; + $owner = is_int($fileOwner) ? $fileOwner : null; + $group = is_int($fileGroup) ? $fileGroup : null; } $this->entries[] = [ 'path' => $path, 'existed' => $existed, 'backup' => $backup, - 'permissions' => $permissions, + 'mode' => $mode, + 'owner' => $owner, + 'group' => $group, ]; + $this->recordedPaths[$path] = true; } - public function rollback(): void + public function rollback(?\Throwable $originalFailure = null): void { $failures = []; for ($index = count($this->entries) - 1; $index >= 0; $index--) { try { $this->restore($this->entries[$index]); } catch (\Throwable $exception) { - $failures[] = $exception->getMessage(); + $failures[] = $exception; } } $this->cleanup(); $this->entries = []; + $this->recordedPaths = []; if ($failures !== []) { - throw new FileAccessException('Transaction rollback failed: ' . implode('; ', $failures)); + throw new TransactionRollbackException( + $originalFailure ?? new FileAccessException('An explicit transaction rollback failed.'), + $failures, + ); } } @@ -63,13 +90,13 @@ private function cleanup(): void { foreach ($this->entries as $entry) { if (is_string($entry['backup']) && is_file($entry['backup'])) { - unlink($entry['backup']); + $this->unlinkSilently($entry['backup']); } } } /** - * @param array{path: string, existed: bool, backup: string|null, permissions: int|null} $entry + * @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 { @@ -91,8 +118,33 @@ private function restore(array $entry): void if (!copy($entry['backup'], $entry['path'])) { throw new FileAccessException("Unable to restore rollback backup for {$entry['path']}."); } - if (is_int($entry['permissions']) && !chmod($entry['path'], $entry['permissions'])) { + $this->restoreMetadata($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'])) { throw new FileAccessException("Unable to restore permissions for {$entry['path']}."); } + if (is_int($entry['owner']) && fileowner($entry['path']) !== $entry['owner'] && !chown($entry['path'], $entry['owner'])) { + throw new FileAccessException("Unable to restore owner for {$entry['path']}."); + } + if (is_int($entry['group']) && filegroup($entry['path']) !== $entry['group'] && !chgrp($entry['path'], $entry['group'])) { + throw new FileAccessException("Unable to restore group for {$entry['path']}."); + } + } + + private function unlinkSilently(string $path): void + { + set_error_handler(static fn(): bool => true); + + try { + unlink($path); + } finally { + restore_error_handler(); + } } } diff --git a/src/FileManager/SafeFileReader.php b/src/FileManager/SafeFileReader.php index 926872a..20af498 100644 --- a/src/FileManager/SafeFileReader.php +++ b/src/FileManager/SafeFileReader.php @@ -8,8 +8,8 @@ use Generator; use Infocyph\Pathwise\Exceptions\FileAccessException; use Infocyph\Pathwise\Exceptions\MissingExtensionException; -use Infocyph\Pathwise\Utils\FlysystemHelper; -use Infocyph\Pathwise\Utils\PathHelper; +use Infocyph\Pathwise\Utils\ReadablePathLocalizer; +use Infocyph\Pathwise\Utils\SerializedValueValidator; use SimpleXMLElement; use SplFileObject; use XMLReader; @@ -38,14 +38,18 @@ final class SafeFileReader implements Countable * * @param string $filename The path to the file to read. * @param string $mode The file mode to open the file with. Defaults to 'r'. - * @param bool $exclusiveLock When true, a lock is acquired on the file before - * reading. The type of lock is determined by the $mode parameter. + * @param int|null $lockType Optional LOCK_SH or LOCK_EX. Adapter-backed files are + * locked only on their localized working copy. */ public function __construct( private readonly string $filename, private readonly string $mode = 'r', - private readonly bool $exclusiveLock = false, - ) {} + private readonly ?int $lockType = null, + ) { + if ($lockType !== null && !in_array($lockType, [LOCK_SH, LOCK_EX], true)) { + throw new \InvalidArgumentException('Reader lock type must be LOCK_SH, LOCK_EX, or null.'); + } + } /** * Destructor for the SafeFileReader class. @@ -71,7 +75,7 @@ public function characters(): Generator } /** @return Generator */ - public function chunks(int $bytes = 1024): Generator + public function chunks(int $bytes = 65_536): Generator { if ($bytes < 1) { throw new \InvalidArgumentException('Chunk size must be positive.'); @@ -131,7 +135,7 @@ public function lines(): Generator return $this->lineIterator(); } - /** @return Generator> */ + /** @return Generator */ public function matchingLines(string $pattern): Generator { if ($pattern === '') { @@ -139,6 +143,20 @@ public function matchingLines(string $pattern): Generator } $this->prepareRead(); + $this->validatePattern($pattern); + + return $this->matchingLineIterator($pattern); + } + + /** @return Generator> */ + public function regexMatches(string $pattern): Generator + { + if ($pattern === '') { + throw new \InvalidArgumentException('A regular-expression pattern is required.'); + } + $this->validatePattern($pattern); + $this->prepareRead(); + return $this->regexIterator($pattern); } @@ -195,8 +213,11 @@ private function applyLock(): void if ($this->isLocked) { $this->releaseLock(); } - $lockType = $this->exclusiveLock ? LOCK_EX : LOCK_SH; - if (!$this->file->flock($lockType)) { + if ($this->lockType === null) { + return; + } + $operation = $this->lockType === LOCK_EX ? LOCK_EX : LOCK_SH; + if (!$this->file->flock($operation)) { throw new FileAccessException("Unable to lock file at path: {$this->filename}"); } $this->isLocked = true; @@ -212,10 +233,14 @@ private function applyLock(): void * @param int $bytes The number of bytes to read in each chunk. Defaults to 1024. * @return Generator Yields binary data chunks from the file. */ - private function binaryIterator(int $bytes = 1024): Generator + private function binaryIterator(int $bytes = 65_536): Generator { - while (!$this->file->eof()) { - yield $this->file->fread($bytes); + while (true) { + $chunk = $this->file->fread($bytes); + if ($chunk === '') { + break; + } + yield $chunk; $this->position++; $this->count++; } @@ -232,30 +257,13 @@ private function binaryIterator(int $bytes = 1024): Generator */ private function characterIterator(): Generator { - while (!$this->file->eof()) { - yield $this->file->fgetc(); + while (($character = $this->file->fgetc()) !== false) { + yield $character; $this->position++; $this->count++; } } - private function containsObjectValue(mixed $value, int $depth = 0): bool - { - if ($depth > 256) { - return true; - } - - if (is_object($value)) { - return true; - } - - if (!is_array($value)) { - return false; - } - - return array_any($value, fn($item) => $this->containsObjectValue($item, $depth + 1)); - } - /** * Iterates over the file line by line, splitting each line into an array using the given CSV settings. * @@ -287,8 +295,10 @@ private function deserializeValue(string $serializedLine): mixed throw new FileAccessException('Failed to unserialize data.'); } - if ($this->containsObjectValue($result)) { - throw new FileAccessException('Serialized objects are not allowed.'); + if (SerializedValueValidator::containsUnsupportedValue($result)) { + throw new FileAccessException( + 'Serialized objects are not allowed; serialized values must use safe scalar/array types.', + ); } return $result; @@ -305,21 +315,28 @@ private function deserializeValue(string $serializedLine): mixed */ private function fixedWidthIterator(array $widths): Generator { - while (!$this->file->eof()) { - $line = $this->file->fgets(); + while (true) { + try { + $line = $this->file->fgets(); + } catch (\RuntimeException $exception) { + if ($this->file->eof()) { + break; + } + + throw new FileAccessException('Unable to read fixed-width input.', 0, $exception); + } $fields = []; $offset = 0; foreach ($widths as $width) { - if ($width < 1) { - continue; - } - $fields[] = substr($line, $offset, $width); $offset += $width; } yield $fields; $this->position++; $this->count++; + if ($this->file->eof()) { + break; + } } } @@ -366,9 +383,13 @@ private function jsonArrayIteratorWithHandling(): Generator throw new FileAccessException('JSON array decoding error: failed to read file content.'); } - $jsonArray = json_decode($jsonContent, true); - if (json_last_error() !== JSON_ERROR_NONE || !is_array($jsonArray)) { - throw new FileAccessException('JSON array decoding error: ' . json_last_error_msg()); + try { + $jsonArray = json_decode($jsonContent, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new FileAccessException('JSON array decoding error: ' . $exception->getMessage(), 0, $exception); + } + if (!is_array($jsonArray)) { + throw new FileAccessException('JSON array decoding error: top-level value must be an array.'); } foreach ($jsonArray as $element) { yield $element; @@ -393,9 +414,10 @@ private function jsonIteratorWithHandling(): Generator while (!$this->file->eof()) { $line = trim($this->file->fgets()); if ($line) { - $decoded = json_decode($line, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new FileAccessException('JSON decoding error: ' . json_last_error_msg()); + try { + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new FileAccessException('JSON decoding error: ' . $exception->getMessage(), 0, $exception); } yield $decoded; $this->position++; @@ -427,6 +449,22 @@ private function lineIterator(): Generator } } + /** @return Generator */ + private function matchingLineIterator(string $pattern): Generator + { + while (!$this->file->eof()) { + $line = $this->file->fgets(); + if ($this->file->eof() && $line === '') { + break; + } + if (preg_match($pattern, $line) === 1) { + yield $line; + $this->position++; + $this->count++; + } + } + } + private function prepareRead(): void { $this->initiate(); @@ -472,46 +510,9 @@ private function resetPosition(): void private function resolveReadablePath(): string { - $normalized = PathHelper::normalize($this->filename); - $preferFlysystem = FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($normalized); - if (!$preferFlysystem && !PathHelper::hasScheme($normalized) && is_file($normalized)) { - return $normalized; - } - - if (!FlysystemHelper::fileExists($normalized)) { - throw new FileAccessException("Cannot access file at path: {$this->filename}"); - } - - if ($this->localWorkingPath !== null && is_file($this->localWorkingPath)) { - return $this->localWorkingPath; - } - - $stream = FlysystemHelper::readStream($normalized); - if (!is_resource($stream)) { - throw new FileAccessException("Cannot access file at path: {$this->filename}"); - } - - $tempFile = tempnam(sys_get_temp_dir(), 'pathwise_reader_'); - if ($tempFile === false) { - fclose($stream); - - throw new FileAccessException("Cannot access file at path: {$this->filename}"); - } - - $target = fopen($tempFile, 'wb'); - if (!is_resource($target)) { - fclose($stream); - $this->unlinkPathSilently($tempFile); - - throw new FileAccessException("Cannot access file at path: {$this->filename}"); - } - - stream_copy_to_stream($stream, $target); - fclose($stream); - fclose($target); - - $this->localWorkingPath = PathHelper::normalize($tempFile); - $this->cleanupLocalWorkingPath = true; + $resolved = ReadablePathLocalizer::resolve($this->filename, $this->localWorkingPath); + $this->localWorkingPath = $resolved['path']; + $this->cleanupLocalWorkingPath = $resolved['cleanup']; return $this->localWorkingPath; } @@ -553,6 +554,20 @@ private function unlinkPathSilently(string $path): void } } + private function validatePattern(string $pattern): void + { + set_error_handler(static fn(): bool => true); + + try { + $valid = preg_match($pattern, '') !== false; + } finally { + restore_error_handler(); + } + if (!$valid) { + throw new \InvalidArgumentException('Invalid regular-expression pattern.'); + } + } + /** * @param list $widths * @return list diff --git a/src/FileManager/SafeFileWriter.php b/src/FileManager/SafeFileWriter.php index 6197a89..be0794a 100644 --- a/src/FileManager/SafeFileWriter.php +++ b/src/FileManager/SafeFileWriter.php @@ -62,6 +62,9 @@ public function __destruct() } catch (\Throwable) { // Never throw from destructors. } finally { + if (is_string($this->atomicTempFilePath) && is_file($this->atomicTempFilePath)) { + $this->unlinkPathSilently($this->atomicTempFilePath); + } if ($this->cleanupLocalWorkingPath && is_string($this->localWorkingPath) && is_file($this->localWorkingPath)) { $this->unlinkPathSilently($this->localWorkingPath); } @@ -150,10 +153,11 @@ public function flush(): void * * @return DateTime The creation date of the file. */ - public function getCreationDate(): DateTime + public function getCreationDate(): ?DateTime { return $this->resolveFileDate( static fn(string $path): int => (int) filectime($path), + false, ); } @@ -162,10 +166,11 @@ public function getCreationDate(): DateTime * * @return DateTime The last modification date of the file. */ - public function getModificationDate(): DateTime + public function getModificationDate(): ?DateTime { return $this->resolveFileDate( static fn(string $path): int => (int) filemtime($path), + true, ); } @@ -210,8 +215,8 @@ public function jsonSerialize(): array 'size' => $this->getSize(), 'writes' => $this->writeCount, 'writeTypesCount' => $this->writeTypesCount, - 'modificationDate' => $this->getModificationDate()->format(DateTimeInterface::ATOM), - 'creationDate' => $this->getCreationDate()->format(DateTimeInterface::ATOM), + 'modificationDate' => $this->getModificationDate()?->format(DateTimeInterface::ATOM), + 'creationDate' => $this->getCreationDate()?->format(DateTimeInterface::ATOM), ]; } @@ -341,12 +346,7 @@ public function writeBinary(string $data): int public function writeCharacters(string $characters): int { - $written = 0; - foreach (str_split($characters) as $character) { - $written += $this->performWrite('characters', fn(): int|false => $this->writeCharacterData($character)); - } - - return $written; + return $this->performWrite('characters', fn(): int|false => $this->writeCharacterData($characters)); } /** @param list $row */ @@ -361,6 +361,10 @@ public function writeCsv(array $row, string $separator = ',', string $enclosure */ public function writeFixedWidth(array $data, array $widths): int { + if ($widths === [] || array_any($widths, static fn(int $width): bool => $width < 1)) { + throw new FileAccessException('Fixed-width definitions must be positive integers.'); + } + return $this->performWrite('fixed-width', fn(): int|false => $this->writeFixedWidthData($data, $widths)); } @@ -438,7 +442,10 @@ private function finalizeAtomicWrite(): void } if ($this->isRemoteTarget()) { - $this->localWorkingPath ??= $this->createLocalTempFile('pathwise_writer_sync_'); + 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}"); @@ -536,7 +543,7 @@ private function preloadRemoteAppendSourceIfNeeded(): void /** * @param callable(string): int $localDateResolver */ - private function resolveFileDate(callable $localDateResolver): DateTime + private function resolveFileDate(callable $localDateResolver, bool $useAdapterLastModified): ?DateTime { $target = $this->getActiveOrFinalPath(); if (is_file($target)) { @@ -545,11 +552,11 @@ private function resolveFileDate(callable $localDateResolver): DateTime return new DateTime('@' . $timestamp); } - if (FlysystemHelper::fileExists($this->filename)) { + if ($useAdapterLastModified && FlysystemHelper::fileExists($this->filename)) { return new DateTime('@' . FlysystemHelper::lastModified($this->filename)); } - return new DateTime(); + return null; } private function resolveNonAtomicTargetFilePath(): string @@ -593,12 +600,21 @@ private function runSilently(callable $operation): mixed private function syncWorkingCopyBack(): void { - StreamTransferHelper::syncLocalFileToPathOrThrow( - $this->syncBackOnClose, - $this->localWorkingPath, - $this->filename, - fn(): \Throwable => new FileAccessException("Cannot write to file: {$this->filename}"), - ); + try { + StreamTransferHelper::syncLocalFileToPathOrThrow( + $this->syncBackOnClose, + $this->localWorkingPath, + $this->filename, + fn(): \Throwable => new FileAccessException("Cannot write to file: {$this->filename}"), + ); + } finally { + if ($this->cleanupLocalWorkingPath && is_string($this->localWorkingPath)) { + $this->unlinkPathSilently($this->localWorkingPath); + } + $this->localWorkingPath = null; + $this->cleanupLocalWorkingPath = false; + $this->syncBackOnClose = false; + } } private function unlinkPathSilently(string $path): void diff --git a/src/Indexing/ChecksumIndexer.php b/src/Indexing/ChecksumIndexer.php index b740c5a..ffc8bfc 100644 --- a/src/Indexing/ChecksumIndexer.php +++ b/src/Indexing/ChecksumIndexer.php @@ -4,6 +4,7 @@ namespace Infocyph\Pathwise\Indexing; +use Infocyph\Pathwise\Exceptions\FileAccessException; use Infocyph\Pathwise\Results\DeduplicationResult; use Infocyph\Pathwise\Utils\FlysystemHelper; @@ -18,20 +19,23 @@ final class ChecksumIndexer * * @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> Array mapping checksum to array of file paths. */ public static function buildIndex(string $directory, string $algorithm = 'sha256'): array { $directory = PathHelper::normalize($directory); - if (!FlysystemHelper::directoryExists($directory) || !in_array($algorithm, hash_algos(), true)) { - return []; + 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)) { - continue; + throw new FileAccessException("Unable to calculate checksum for: {$path}."); } $index[$hash][] = $path; @@ -57,40 +61,7 @@ public static function deduplicateWithHardLinks( $skipped = []; foreach ($duplicates as $paths) { - $canonical = array_shift($paths); - if (!is_string($canonical) || !self::isLocalFile($canonical)) { - array_push($skipped, ...$paths); - - continue; - } - - foreach ($paths as $path) { - if (!self::isLocalFile($path)) { - $skipped[] = $path; - - continue; - } - - $tmp = self::temporarySiblingPath($path); - if ($tmp === null) { - $skipped[] = $path; - - continue; - } - if (!self::runSilently(static fn(): bool => rename($path, $tmp))) { - $skipped[] = $path; - - continue; - } - - if (self::filesAreIdentical($canonical, $tmp) && self::runSilently(static fn(): bool => link($canonical, $path))) { - self::unlinkSilently($tmp); - $linked[] = $path; - } else { - self::runSilently(static fn(): bool => rename($tmp, $path)); - $skipped[] = $path; - } - } + self::deduplicateGroup($paths, $linked, $skipped); } return new DeduplicationResult($linked, $skipped); @@ -101,7 +72,7 @@ public static function deduplicateWithHardLinks( * * @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> Array mapping checksum to array of duplicate file paths. */ public static function findDuplicates(string $directory, string $algorithm = 'sha256'): array { @@ -110,6 +81,58 @@ public static function findDuplicates(string $directory, string $algorithm = 'sh return array_filter($index, static fn(array $paths): bool => count($paths) > 1); } + /** + * @param list $paths + * @param list $linked + * @param list $skipped + */ + private static function deduplicateGroup(array $paths, array &$linked, array &$skipped): void + { + $canonical = array_shift($paths); + if (!is_string($canonical) || !self::isLocalFile($canonical)) { + array_push($skipped, ...$paths); + + return; + } + + foreach ($paths as $path) { + self::deduplicatePath($canonical, $path, $linked, $skipped); + } + } + + /** + * @param list $linked + * @param list $skipped + */ + private static function deduplicatePath(string $canonical, string $path, array &$linked, array &$skipped): void + { + if (!self::isLocalFile($path)) { + $skipped[] = $path; + + return; + } + + $temporary = self::temporarySiblingPath($path); + if ($temporary === null || !self::runSilently(static fn(): bool => rename($path, $temporary))) { + $skipped[] = $path; + + return; + } + + if (self::filesAreIdentical($canonical, $temporary) && self::runSilently(static fn(): bool => link($canonical, $path))) { + self::unlinkSilently($temporary); + $linked[] = $path; + + return; + } + if (!self::runSilently(static fn(): bool => rename($temporary, $path))) { + throw new FileAccessException( + "Unable to restore deduplication target '{$path}'; recovery copy remains at '{$temporary}'.", + ); + } + $skipped[] = $path; + } + private static function filesAreIdentical(string $firstPath, string $secondPath): bool { $firstSize = filesize($firstPath); diff --git a/src/Native/NativeCommandRunner.php b/src/Native/NativeCommandRunner.php index 989702f..1ed830c 100644 --- a/src/Native/NativeCommandRunner.php +++ b/src/Native/NativeCommandRunner.php @@ -6,37 +6,107 @@ final class NativeCommandRunner { - /** - * Check if a command exists on the system. - * - * @param string $command The command to check. - * @return bool True if the command exists, false otherwise. - */ + /** @var array */ + private static array $executableCache = []; + public static function commandExists(string $command): bool { - $lookup = PHP_OS_FAMILY === 'Windows' - ? 'where ' . escapeshellcmd($command) . ' >NUL 2>&1' - : 'command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1'; + $cacheKey = PHP_OS_FAMILY . ':' . strtolower($command); + if (array_key_exists($cacheKey, self::$executableCache)) { + return self::$executableCache[$cacheKey]; + } - $result = self::run($lookup); - - return $result['success']; + return self::$executableCache[$cacheKey] = self::locateExecutable($command); } /** - * @return array{success: bool, output: array, code: int} + * @param list $command + * @return array{success: bool, output: list, code: int} */ - public static function run(string $command): array + public static function run(array $command, ?string $workingDirectory = null): array { - $output = []; - $exitCode = 1; + if ($command === []) { + return ['success' => false, 'output' => ['No command was provided.'], 'code' => 127]; + } + + $pipes = []; + $process = proc_open( + $command, + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $workingDirectory, + null, + ['bypass_shell' => true], + ); + if (!is_resource($process)) { + return ['success' => false, 'output' => ['Unable to start native command.'], 'code' => 127]; + } - exec($command . ' 2>&1', $output, $exitCode); + $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); + } + } + $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' => $output, + 'output' => is_array($output) ? $output : [], 'code' => $exitCode, ]; } + + /** @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; + } + } + } + + 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)); + } + + $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))) { + return true; + } + } + + return false; + } + + /** @return list */ + private static function windowsExecutableExtensions(): array + { + $pathExtensions = getenv('PATHEXT'); + if (!is_string($pathExtensions) || $pathExtensions === '') { + return ['.exe', '.com', '.bat', '.cmd']; + } + + return array_values(array_filter(array_map(strtolower(...), explode(PATH_SEPARATOR, $pathExtensions)))); + } } diff --git a/src/Native/NativeOperationsAdapter.php b/src/Native/NativeOperationsAdapter.php index c14548a..3b0460b 100644 --- a/src/Native/NativeOperationsAdapter.php +++ b/src/Native/NativeOperationsAdapter.php @@ -5,50 +5,43 @@ namespace Infocyph\Pathwise\Native; use Infocyph\Pathwise\Results\NativeExecutionResult; +use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; final class NativeOperationsAdapter { - /** - * Check if native compression commands are available. - * - * @return bool True if native compression is available. - */ public static function canUseNativeCompression(): bool { - if (PHP_OS_FAMILY === 'Windows') { - return NativeCommandRunner::commandExists('powershell') || NativeCommandRunner::commandExists('tar'); - } - - return NativeCommandRunner::commandExists('zip') && NativeCommandRunner::commandExists('unzip'); + return self::canUseNativeZipCompression() && self::canUseNativeZipDecompression(); } - /** - * Check if native directory copy commands are available. - * - * @return bool True if native directory copy is available. - */ public static function canUseNativeDirectoryCopy(): bool { - if (PHP_OS_FAMILY === 'Windows') { - return NativeCommandRunner::commandExists('robocopy'); - } - - return NativeCommandRunner::commandExists('rsync'); + return NativeCommandRunner::commandExists(PHP_OS_FAMILY === 'Windows' ? 'robocopy' : 'rsync'); } - /** - * Check if native file copy commands are available. - * - * @return bool True if native file copy is available. - */ public static function canUseNativeFileCopy(): bool { - if (PHP_OS_FAMILY === 'Windows') { - return NativeCommandRunner::commandExists('cmd'); - } + return NativeCommandRunner::commandExists(PHP_OS_FAMILY === 'Windows' ? 'powershell' : 'cp'); + } + + public static function canUseNativeSearch(): bool + { + return NativeCommandRunner::commandExists(PHP_OS_FAMILY === 'Windows' ? 'findstr' : 'grep'); + } + + public static function canUseNativeZipCompression(): bool + { + return PHP_OS_FAMILY === 'Windows' + ? NativeCommandRunner::commandExists('powershell') + : NativeCommandRunner::commandExists('zip'); + } - return NativeCommandRunner::commandExists('cp'); + public static function canUseNativeZipDecompression(): bool + { + return PHP_OS_FAMILY === 'Windows' + ? NativeCommandRunner::commandExists('powershell') + : NativeCommandRunner::commandExists('unzip'); } public static function compressToZip(string $source, string $zipPath): NativeExecutionResult @@ -57,43 +50,35 @@ public static function compressToZip(string $source, string $zipPath): NativeExe $zipPath = PathHelper::normalize($zipPath); if (PHP_OS_FAMILY === 'Windows' && NativeCommandRunner::commandExists('powershell')) { - $command = sprintf( - 'powershell -NoProfile -Command "Compress-Archive -Path %s -DestinationPath %s -Force"', - escapeshellarg($source . DIRECTORY_SEPARATOR . '*'), - escapeshellarg($zipPath), - ); - $result = NativeCommandRunner::run($command); + $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", + ]); + } - return new NativeExecutionResult($result['success'], $command, $result['code'], array_values($result['output'])); + if (!NativeCommandRunner::commandExists('zip')) { + return self::unsupportedResult(); } - if (NativeCommandRunner::commandExists('zip')) { - $zipArg = escapeshellarg($zipPath); - $cwd = dirname($source); - - if (is_dir($source)) { - $cwd = $source; - $command = sprintf('zip -r %s .', $zipArg); - - $zipParent = PathHelper::normalize(dirname($zipPath)); - if ($zipParent === PathHelper::normalize($source)) { - $command .= ' -x ' . escapeshellarg(basename($zipPath)); - } - } else { - $command = sprintf( - 'zip -r %s %s', - $zipArg, - escapeshellarg(basename($source)), - ); + if (is_dir($source)) { + $command = ['zip', '-r', $zipPath, '.']; + if (FlysystemHelper::isSameOrDescendant($source, $zipPath)) { + $command[] = '-x'; + $command[] = basename($zipPath); } - $wrapped = sprintf('cd %s && %s', escapeshellarg($cwd), $command); - $result = NativeCommandRunner::run($wrapped); - - return new NativeExecutionResult($result['success'], $wrapped, $result['code'], array_values($result['output'])); + return self::run($command, $source); } - return self::unsupportedResult(); + return self::run(['zip', '-r', $zipPath, basename($source)], dirname($source)); } public static function copyDirectory( @@ -105,148 +90,129 @@ public static function copyDirectory( $destination = PathHelper::normalize($destination); if (PHP_OS_FAMILY === 'Windows') { - $flags = $mirror ? '/MIR' : '/E'; - $result = self::runCommandIfAvailable( + if (!NativeCommandRunner::commandExists('robocopy')) { + return self::unsupportedResult(); + } + $result = self::run([ 'robocopy', - static fn(): string => sprintf( - 'robocopy %s %s %s /R:1 /W:1 /NFL /NDL /NJH /NJS /NP', - escapeshellarg($source), - escapeshellarg($destination), - $flags, - ), - static fn(array $result): bool => $result['code'] <= 7, + $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 ($result !== null) { - return $result; - } } - $deleteFlag = $mirror ? ' --delete' : ''; - $result = self::runCommandIfAvailable( - 'rsync', - static fn(): string => sprintf( - 'rsync -a%s %s/ %s/', - $deleteFlag, - escapeshellarg($source), - escapeshellarg($destination), - ), - ); - if ($result !== null) { - return $result; + if (!NativeCommandRunner::commandExists('rsync')) { + return self::unsupportedResult(); + } + $command = ['rsync', '-a']; + if ($mirror) { + $command[] = '--delete'; } + $command[] = rtrim($source, '/\\') . DIRECTORY_SEPARATOR; + $command[] = rtrim($destination, '/\\') . DIRECTORY_SEPARATOR; - return self::unsupportedResult(); + return self::run($command); } public static function copyFile(string $source, string $destination): NativeExecutionResult { - return self::runDualPathOperation( - $source, - $destination, - 'cmd', - static fn(string $normalizedSource, string $normalizedDestination): string => sprintf( - 'cmd /C copy /Y %s %s >NUL', - escapeshellarg($normalizedSource), - escapeshellarg($normalizedDestination), - ), - 'cp', - static fn(string $normalizedSource, string $normalizedDestination): string => sprintf( - 'cp -f %s %s', - escapeshellarg($normalizedSource), - escapeshellarg($normalizedDestination), - ), - ); + $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::unsupportedResult(); } public static function decompressZip(string $zipPath, string $destination): NativeExecutionResult { - return self::runDualPathOperation( - $zipPath, - $destination, - 'powershell', - static fn(string $normalizedZipPath, string $normalizedDestination): string => sprintf( - 'powershell -NoProfile -Command "Expand-Archive -Path %s -DestinationPath %s -Force"', - escapeshellarg($normalizedZipPath), - escapeshellarg($normalizedDestination), - ), - 'unzip', - static fn(string $normalizedZipPath, string $normalizedDestination): string => sprintf( - 'unzip -o %s -d %s', - escapeshellarg($normalizedZipPath), - escapeshellarg($normalizedDestination), - ), - ); + $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::unsupportedResult(); } - /** - * @param callable(): string $commandBuilder - * @param callable(array{success: bool, output: array, code: int}): bool|null $successResolver - */ - private static function runCommandIfAvailable( - string $command, - callable $commandBuilder, - ?callable $successResolver = null, - ): ?NativeExecutionResult { - if (!NativeCommandRunner::commandExists($command)) { - return null; + 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(); } - $builtCommand = $commandBuilder(); - $result = NativeCommandRunner::run($builtCommand); - - return new NativeExecutionResult( - success: $successResolver !== null ? (bool) $successResolver($result) : $result['success'], - command: $builtCommand, - exitCode: $result['code'], - output: array_values($result['output']), - ); + return NativeCommandRunner::commandExists('grep') + ? self::run(['grep', '-i', '-F', '--', $term, PathHelper::normalize($path)]) + : self::unsupportedResult(); } - /** - * @param callable(string, string): string $windowsCommandBuilder - * @param callable(string, string): string $unixCommandBuilder - */ - private static function runDualPathOperation( - string $sourcePath, - string $destinationPath, - string $windowsCommand, - callable $windowsCommandBuilder, - string $unixCommand, - callable $unixCommandBuilder, - ): NativeExecutionResult { - $normalizedSourcePath = PathHelper::normalize($sourcePath); - $normalizedDestinationPath = PathHelper::normalize($destinationPath); - - return self::runWindowsThenUnix( - $windowsCommand, - static fn(): string => $windowsCommandBuilder($normalizedSourcePath, $normalizedDestinationPath), - $unixCommand, - static fn(): string => $unixCommandBuilder($normalizedSourcePath, $normalizedDestinationPath), - ); + /** @param 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, + )); } - /** - * @param callable(): string $windowsCommandBuilder - * @param callable(): string $unixCommandBuilder - */ - private static function runWindowsThenUnix( - string $windowsCommand, - callable $windowsCommandBuilder, - string $unixCommand, - callable $unixCommandBuilder, - ): NativeExecutionResult { - if (PHP_OS_FAMILY === 'Windows') { - $windowsResult = self::runCommandIfAvailable($windowsCommand, $windowsCommandBuilder); - if ($windowsResult !== null) { - return $windowsResult; - } - } + /** @param list $command */ + private static function run(array $command, ?string $workingDirectory = null): NativeExecutionResult + { + $result = NativeCommandRunner::run($command, $workingDirectory); - return self::runCommandIfAvailable($unixCommand, $unixCommandBuilder) ?? self::unsupportedResult(); + return new NativeExecutionResult( + $result['success'], + self::displayCommand($command), + $result['code'], + $result['output'], + ); } private static function unsupportedResult(): NativeExecutionResult { - return new NativeExecutionResult(false, '', 127); + return new NativeExecutionResult(false, '', 127, ['Required native executable is unavailable.']); } } diff --git a/src/Observability/AuditTrail.php b/src/Observability/AuditTrail.php index 977ca3e..82411fa 100644 --- a/src/Observability/AuditTrail.php +++ b/src/Observability/AuditTrail.php @@ -4,7 +4,8 @@ namespace Infocyph\Pathwise\Observability; -use DateTimeInterface; +use DateTimeImmutable; +use DateTimeZone; final readonly class AuditTrail { @@ -34,7 +35,8 @@ public function getLogFilePath(): ?string public function log(string $operation, array $context = []): void { $record = [ - 'timestamp' => date(DateTimeInterface::ATOM), + 'timestamp' => new DateTimeImmutable('now', new DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s.u\Z'), 'operation' => $operation, 'context' => $context, ]; diff --git a/src/Observability/LocalJsonlAuditSink.php b/src/Observability/LocalJsonlAuditSink.php index 4e24f22..19adffa 100644 --- a/src/Observability/LocalJsonlAuditSink.php +++ b/src/Observability/LocalJsonlAuditSink.php @@ -4,10 +4,10 @@ namespace Infocyph\Pathwise\Observability; +use Infocyph\Pathwise\Exceptions\AuditException; use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; -use RuntimeException; final readonly class LocalJsonlAuditSink implements AuditSink { @@ -24,16 +24,20 @@ 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 RuntimeException("Unable to create audit directory: {$directory}"); + throw new AuditException("Unable to create audit directory: {$directory}"); } } public function write(array $record): void { - $line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL; + try { + $line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL; + } 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 RuntimeException("Unable to append audit record to {$this->path}."); + throw new AuditException("Unable to append audit record to {$this->path}."); } } } diff --git a/src/Observability/PartitionedAuditSink.php b/src/Observability/PartitionedAuditSink.php index df65bf9..6128807 100644 --- a/src/Observability/PartitionedAuditSink.php +++ b/src/Observability/PartitionedAuditSink.php @@ -4,6 +4,9 @@ namespace Infocyph\Pathwise\Observability; +use DateTimeImmutable; +use DateTimeZone; +use Infocyph\Pathwise\Exceptions\AuditException; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; @@ -13,9 +16,15 @@ public function __construct(private string $directory) {} public function write(array $record): void { - $partition = gmdate('Y/m/d/H'); - $name = sprintf('%s-%s.json', gmdate('Ymd\THis.u\Z'), bin2hex(random_bytes(12))); + $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); - FlysystemHelper::write($path, json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + + try { + FlysystemHelper::write($path, json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } 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 150798b..f5fc2e2 100644 --- a/src/PathwiseFacade.php +++ b/src/PathwiseFacade.php @@ -122,17 +122,6 @@ public static function duplicates(string $directory, string $algorithm = 'sha256 return ChecksumIndexer::findDuplicates($directory, $algorithm); } - /** - * Alias for at() - 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 from(string $path): self - { - return self::at($path); - } - /** * Build a checksum index for all files in a directory. * @@ -322,12 +311,11 @@ public function path(): string * Get a safe file reader for this path. * * @param string $mode The file mode to open with. Defaults to 'r'. - * @param bool $exclusiveLock If true, acquire an exclusive lock. * @return SafeFileReader The file reader instance. */ - public function reader(string $mode = 'r', bool $exclusiveLock = false): SafeFileReader + public function reader(string $mode = 'r', ?int $lockType = null): SafeFileReader { - return new SafeFileReader($this->path, $mode, $exclusiveLock); + return new SafeFileReader($this->path, $mode, $lockType); } /** diff --git a/src/Queue/FileJobQueue.php b/src/Queue/FileJobQueue.php index 3c37b5d..eaf44a9 100644 --- a/src/Queue/FileJobQueue.php +++ b/src/Queue/FileJobQueue.php @@ -4,13 +4,13 @@ namespace Infocyph\Pathwise\Queue; +use Infocyph\Pathwise\Exceptions\QueueException; use Infocyph\Pathwise\Results\QueueProcessResult; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; use InvalidArgumentException; use JsonException; -use RuntimeException; /** * @phpstan-type QueueJob array{ @@ -19,6 +19,7 @@ * payload: array, * priority: int, * createdAt: int, + * reservedAt?: int, * error?: string, * failedAt?: int * } @@ -30,24 +31,36 @@ */ final readonly class FileJobQueue { - public function __construct(private string $queueFilePath) - { + public function __construct( + private string $queueFilePath, + private int $reservationTimeout = 300, + private int $maxJobs = 10_000, + private int $maxQueueBytes = 16_777_216, + private int $maxPayloadBytes = 1_048_576, + ) { + if (!$this->isLocalQueuePath()) { + throw new QueueException('FileJobQueue requires a direct-local filesystem path.'); + } + if ( + $reservationTimeout < 1 + || $maxJobs < 1 + || $maxQueueBytes < 1 + || $maxPayloadBytes < 1 + ) { + throw new InvalidArgumentException('Queue limits and reservation timeout must be positive integers.'); + } $directory = dirname($this->queueFilePath); if (!FlysystemHelper::directoryExists($directory)) { FlysystemHelper::createDirectory($directory); } - if ($this->isLocalQueuePath()) { - $this->initializeLocalQueue(); - } elseif (!FlysystemHelper::fileExists($this->queueFilePath)) { - FlysystemHelper::write($this->queueFilePath, $this->encodeQueueData($this->emptyQueueData())); - } + $this->initializeLocalQueue(); } /** * Add a job to the queue. * * @param string $type The job type. - * @param array $payload The job payload data. + * @param array $payload The job payload data. * @param int $priority The job priority (higher is more important). * @return string The job ID. */ @@ -56,10 +69,26 @@ public function enqueue(string $type, array $payload = [], int $priority = 0): s if (trim($type) === '') { throw new InvalidArgumentException('Queue job type must not be empty.'); } + if (array_any(array_keys($payload), static fn(int|string $key): bool => !is_string($key))) { + 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)); - return $this->mutateQueueData(static function (array $data) use ($jobId, $type, $payload, $priority): array { + 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, @@ -130,20 +159,29 @@ public function stats(): array } /** - * @return QueueJob|null + * @param QueueState $data + * @return array{0: QueueState, 1: QueueJob|null} */ - private function claimNextJob(): ?array + private function claimFromQueueState(array $data): array { - return $this->mutateQueueData(static function (array $data): array { - if ($data['pending'] === []) { - return [$data, null]; - } + $data = $this->reclaimStaleReservations($data); + if ($data['pending'] === []) { + return [$data, null]; + } - $job = array_shift($data['pending']); - $data['processing'][] = $job; + $job = array_shift($data['pending']); + $job['reservedAt'] = time(); + $data['processing'][] = $job; - return [$data, $job]; - }); + return [$data, $job]; + } + + /** + * @return QueueJob|null + */ + private function claimNextJob(): ?array + { + return $this->mutateQueueData($this->claimFromQueueState(...)); } /** @@ -162,6 +200,7 @@ private function completeJob(array $job): void break; } + unset($job['reservedAt']); if (isset($job['error'])) { $data['failed'][] = $job; } @@ -182,18 +221,23 @@ private function decodeQueueData(string $content): array try { $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { - throw new RuntimeException("Queue file contains invalid JSON: {$this->queueFilePath}", 0, $exception); + throw new QueueException("Queue file contains invalid JSON: {$this->queueFilePath}", 0, $exception); } if (!is_array($decoded)) { - throw new RuntimeException("Queue file does not contain an object: {$this->queueFilePath}"); + throw new QueueException("Queue file does not contain an object: {$this->queueFilePath}"); } - return [ + $state = [ 'pending' => $this->normalizeJobList($decoded['pending'] ?? []), 'processing' => $this->normalizeJobList($decoded['processing'] ?? []), 'failed' => $this->normalizeJobList($decoded['failed'] ?? []), ]; + if ($this->jobCount($state) > $this->maxJobs) { + throw new QueueException('Queue exceeds the configured job-count limit.'); + } + + return $state; } /** @@ -209,19 +253,28 @@ private function emptyQueueData(): array */ private function encodeQueueData(array $data): string { - return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + try { + $encoded = 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 RuntimeException("Unable to initialize queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to initialize queue file: {$this->queueFilePath}"); } try { if (!flock($stream, LOCK_EX)) { - throw new RuntimeException("Unable to lock queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to lock queue file: {$this->queueFilePath}"); } $metadata = fstat($stream); @@ -243,6 +296,12 @@ private function isLocalQueuePath(): bool && (PathHelper::isAbsolute($this->queueFilePath) || !FlysystemHelper::hasDefaultFilesystem()); } + /** @param QueueState $data */ + private function jobCount(array $data): int + { + return count($data['pending']) + count($data['processing']) + count($data['failed']); + } + /** * @template T * @param callable(QueueState): array{0: QueueState, 1: T} $mutation @@ -250,21 +309,14 @@ private function isLocalQueuePath(): bool */ private function mutateQueueData(callable $mutation): mixed { - if (!$this->isLocalQueuePath()) { - [$data, $result] = $mutation($this->readQueueData()); - $this->writeQueueData($data); - - return $result; - } - $stream = fopen($this->queueFilePath, 'c+b'); if (!is_resource($stream)) { - throw new RuntimeException("Unable to open queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to open queue file: {$this->queueFilePath}"); } try { if (!flock($stream, LOCK_EX)) { - throw new RuntimeException("Unable to lock queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to lock queue file: {$this->queueFilePath}"); } rewind($stream); @@ -274,11 +326,11 @@ private function mutateQueueData(callable $mutation): mixed rewind($stream); if (!ftruncate($stream, 0)) { - throw new RuntimeException("Unable to truncate queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to truncate queue file: {$this->queueFilePath}"); } $this->writeFully($stream, $encoded); if (!fflush($stream)) { - throw new RuntimeException("Unable to flush queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to flush queue file: {$this->queueFilePath}"); } return $result; @@ -288,34 +340,32 @@ private function mutateQueueData(callable $mutation): mixed } } - /** - * @return QueueJob|null - */ - private function normalizeJob(mixed $value): ?array + /** @return QueueJob */ + private function normalizeJob(mixed $value): array { if (!is_array($value)) { - return null; + throw new QueueException('Queue contains a malformed job.'); } $id = $value['id'] ?? null; $type = $value['type'] ?? null; - $payload = $this->normalizePayload($value['payload'] ?? []); - $priority = $value['priority'] ?? 0; - $createdAt = $value['createdAt'] ?? time(); - if (!is_string($id) || !is_string($type)) { - return 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) === '') { + throw new QueueException('Queue contains a malformed job.'); } - if ((!is_int($priority) && !is_numeric($priority)) || (!is_int($createdAt) && !is_numeric($createdAt))) { - return null; + if (!is_int($priority) || !is_int($createdAt) || $createdAt < 0) { + throw new QueueException('Queue contains a malformed job.'); } $job = [ 'id' => $id, 'type' => $type, 'payload' => $payload, - 'priority' => (int) $priority, - 'createdAt' => (int) $createdAt, + 'priority' => $priority, + 'createdAt' => $createdAt, ]; $error = $value['error'] ?? null; @@ -324,8 +374,18 @@ private function normalizeJob(mixed $value): ?array } $failedAt = $value['failedAt'] ?? null; - if (is_int($failedAt) || is_numeric($failedAt)) { - $job['failedAt'] = (int) $failedAt; + if ($failedAt !== null) { + if (!is_int($failedAt) || $failedAt < 0) { + throw new QueueException('Queue contains a malformed failure timestamp.'); + } + $job['failedAt'] = $failedAt; + } + $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; } return $job; @@ -337,16 +397,12 @@ private function normalizeJob(mixed $value): ?array private function normalizeJobList(mixed $value): array { if (!is_array($value)) { - return []; + throw new QueueException('Queue job list must be an array.'); } $jobs = []; foreach ($value as $rawJob) { $job = $this->normalizeJob($rawJob); - if ($job === null) { - continue; - } - $jobs[] = $job; } @@ -359,18 +415,27 @@ private function normalizeJobList(mixed $value): array private function normalizePayload(mixed $value): array { if (!is_array($value)) { - return []; + throw new QueueException('Queue payload must be an object.'); } $payload = []; foreach ($value as $key => $item) { if (!is_string($key)) { - continue; + throw new QueueException('Queue payload keys must be strings.'); } $payload[$key] = $item; } + 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.'); + } + return $payload; } @@ -383,21 +448,20 @@ private function readQueueData(): array return $this->emptyQueueData(); } - if (!$this->isLocalQueuePath()) { - return $this->decodeQueueData(FlysystemHelper::read($this->queueFilePath)); - } - $stream = fopen($this->queueFilePath, 'rb'); if (!is_resource($stream)) { - throw new RuntimeException("Unable to open queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to open queue file: {$this->queueFilePath}"); } try { if (!flock($stream, LOCK_SH)) { - throw new RuntimeException("Unable to lock queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to lock queue file: {$this->queueFilePath}"); } $content = stream_get_contents($stream); + if (is_string($content) && strlen($content) > $this->maxQueueBytes) { + throw new QueueException('Queue exceeds the configured byte-size limit.'); + } return $this->decodeQueueData(is_string($content) ? $content : ''); } finally { @@ -406,10 +470,35 @@ private function readQueueData(): array } } + /** + * @param QueueState $data + * @return QueueState + */ + private function reclaimStaleReservations(array $data): array + { + $cutoff = time() - $this->reservationTimeout; + $active = []; + foreach ($data['processing'] as $job) { + $reservedAt = $job['reservedAt'] ?? 0; + if ($reservedAt > $cutoff) { + $active[] = $job; + + continue; + } + + unset($job['reservedAt']); + $data['pending'][] = $job; + } + $data['processing'] = $active; + usort($data['pending'], static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']); + + return $data; + } + private function writeFully(mixed $stream, string $contents): void { if (!is_resource($stream)) { - throw new RuntimeException('Invalid queue stream.'); + throw new QueueException('Invalid queue stream.'); } $offset = 0; @@ -417,17 +506,9 @@ private function writeFully(mixed $stream, string $contents): void while ($offset < $length) { $written = fwrite($stream, substr($contents, $offset)); if (!is_int($written) || $written < 1) { - throw new RuntimeException("Unable to write queue file: {$this->queueFilePath}"); + throw new QueueException("Unable to write queue file: {$this->queueFilePath}"); } $offset += $written; } } - - /** - * @param QueueState $data - */ - private function writeQueueData(array $data): void - { - FlysystemHelper::write($this->queueFilePath, $this->encodeQueueData($data)); - } } diff --git a/src/Results/ChunkUploadState.php b/src/Results/ChunkUploadState.php index 2b7d95a..d135a1f 100644 --- a/src/Results/ChunkUploadState.php +++ b/src/Results/ChunkUploadState.php @@ -4,6 +4,8 @@ namespace Infocyph\Pathwise\Results; +use InvalidArgumentException; + final readonly class ChunkUploadState { public function __construct( @@ -11,5 +13,12 @@ public function __construct( public int $receivedChunks, public int $totalChunks, public bool $complete, - ) {} + ) { + if ($uploadId === '' || $receivedChunks < 0 || $totalChunks < 1 || $receivedChunks > $totalChunks) { + throw new InvalidArgumentException('Invalid chunk upload state.'); + } + if ($complete !== ($receivedChunks === $totalChunks)) { + throw new InvalidArgumentException('Chunk completion flag does not match the chunk counts.'); + } + } } diff --git a/src/Results/DownloadPreparation.php b/src/Results/DownloadPreparation.php index 9066b13..be34941 100644 --- a/src/Results/DownloadPreparation.php +++ b/src/Results/DownloadPreparation.php @@ -4,6 +4,8 @@ namespace Infocyph\Pathwise\Results; +use InvalidArgumentException; + final readonly class DownloadPreparation { /** @param array $headers */ @@ -17,5 +19,15 @@ public function __construct( public int $status, public RangeDownloadMetadata $range, public array $headers, - ) {} + ) { + if ($size < 0 || $lastModified < 0) { + throw new InvalidArgumentException('Download sizes and timestamps must be non-negative.'); + } + if (!in_array($status, [200, 206], true)) { + throw new InvalidArgumentException('Download status must be 200 or 206.'); + } + if (($status === 206) !== $range->partial) { + throw new InvalidArgumentException('Download status does not match range metadata.'); + } + } } diff --git a/src/Results/DownloadStreamResult.php b/src/Results/DownloadStreamResult.php index db14aa4..ad25ecf 100644 --- a/src/Results/DownloadStreamResult.php +++ b/src/Results/DownloadStreamResult.php @@ -4,7 +4,14 @@ namespace Infocyph\Pathwise\Results; +use InvalidArgumentException; + final readonly class DownloadStreamResult { - public function __construct(public DownloadPreparation $preparation, public int $bytesSent) {} + public function __construct(public DownloadPreparation $preparation, public int $bytesSent) + { + if ($bytesSent < 0 || $bytesSent > $preparation->range->contentLength) { + throw new InvalidArgumentException('Invalid streamed byte count.'); + } + } } diff --git a/src/Results/QueueProcessResult.php b/src/Results/QueueProcessResult.php index 9b5538f..3abc581 100644 --- a/src/Results/QueueProcessResult.php +++ b/src/Results/QueueProcessResult.php @@ -4,7 +4,14 @@ namespace Infocyph\Pathwise\Results; +use InvalidArgumentException; + final readonly class QueueProcessResult { - public function __construct(public int $processed, public int $failed) {} + public function __construct(public int $processed, public int $failed) + { + if ($processed < 0 || $failed < 0) { + throw new InvalidArgumentException('Queue process counts must be non-negative.'); + } + } } diff --git a/src/Results/RangeDownloadMetadata.php b/src/Results/RangeDownloadMetadata.php index 682c258..191f9bb 100644 --- a/src/Results/RangeDownloadMetadata.php +++ b/src/Results/RangeDownloadMetadata.php @@ -4,12 +4,28 @@ namespace Infocyph\Pathwise\Results; +use InvalidArgumentException; + final readonly class RangeDownloadMetadata { public function __construct( - public int $start, - public int $end, + public ?int $start, + public ?int $end, public int $contentLength, public bool $partial, - ) {} + ) { + if ($contentLength < 0) { + throw new InvalidArgumentException('Range content length must be non-negative.'); + } + if ($start === null || $end === null) { + if ($start !== null || $end !== null || $contentLength !== 0 || $partial) { + throw new InvalidArgumentException('Empty range metadata is inconsistent.'); + } + + return; + } + if ($start < 0 || $end < $start || $contentLength !== ($end - $start) + 1) { + throw new InvalidArgumentException('Range ordering or content length is invalid.'); + } + } } diff --git a/src/Retention/RetentionManager.php b/src/Retention/RetentionManager.php index 029d212..6088c66 100644 --- a/src/Retention/RetentionManager.php +++ b/src/Retention/RetentionManager.php @@ -31,6 +31,9 @@ public static function apply( 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([], []); } @@ -59,7 +62,7 @@ public static function apply( } /** - * @return array + * @return array */ private static function collectFiles(string $directory): array { @@ -71,7 +74,7 @@ private static function collectFiles(string $directory): array } /** - * @return array + * @return array */ private static function collectFilesLocal(string $directory): array { @@ -88,7 +91,7 @@ private static function collectFilesLocal(string $directory): array } /** - * @return array + * @return array */ private static function collectFilesViaFlysystem(string $directory): array { @@ -108,7 +111,7 @@ private static function collectFilesViaFlysystem(string $directory): array } /** - * @return array{path: string, mtime: int, ctime: int}|null + * @return array{path: string, mtime: int, ctime: int|null}|null */ private static function normalizeFlysystemEntry(string $directory, string $base, \League\Flysystem\StorageAttributes $item): ?array { @@ -122,7 +125,7 @@ private static function normalizeFlysystemEntry(string $directory, string $base, return [ 'path' => PathHelper::join($directory, $relative), 'mtime' => $mtime, - 'ctime' => $mtime, + 'ctime' => null, ]; } diff --git a/src/Security/PolicyEngine.php b/src/Security/PolicyEngine.php index 965e8bc..072909b 100644 --- a/src/Security/PolicyEngine.php +++ b/src/Security/PolicyEngine.php @@ -5,6 +5,7 @@ namespace Infocyph\Pathwise\Security; use Infocyph\Pathwise\Exceptions\PolicyViolationException; +use Infocyph\Pathwise\Utils\PathHelper; final class PolicyEngine { @@ -79,17 +80,26 @@ 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. - * @return bool True if allowed, false otherwise. + * Rules use last-match-wins precedence. Returns true when allowed. */ public function isAllowed(string $operation, string $path, array $context = []): bool { $decision = true; + $normalizedPath = str_replace('\\', '/', $path); + $caseInsensitive = PHP_OS_FAMILY === 'Windows' && !PathHelper::hasScheme($path); + if ($caseInsensitive) { + $normalizedPath = strtolower($normalizedPath); + } foreach ($this->rules as $rule) { if ($rule['operation'] !== '*' && $rule['operation'] !== $operation) { continue; } - if (!fnmatch($rule['pattern'], str_replace('\\', '/', $path))) { + $pattern = str_replace('\\', '/', $rule['pattern']); + if ($caseInsensitive) { + $pattern = strtolower($pattern); + } + if (!fnmatch($pattern, $normalizedPath)) { continue; } if ($rule['condition'] !== null && !($rule['condition'])($operation, $path, $context)) { diff --git a/src/Security/ZipEntryValidator.php b/src/Security/ZipEntryValidator.php index 92f414c..16ff46e 100644 --- a/src/Security/ZipEntryValidator.php +++ b/src/Security/ZipEntryValidator.php @@ -9,6 +9,14 @@ final class ZipEntryValidator { + public const float DEFAULT_MAX_COMPRESSION_RATIO = 1_000.0; + + public const int DEFAULT_MAX_ENTRIES = 10_000; + + public const int DEFAULT_MAX_ENTRY_UNCOMPRESSED_BYTES = 1_073_741_824; + + public const int DEFAULT_MAX_TOTAL_UNCOMPRESSED_BYTES = 4_294_967_296; + private const int UNIX_FILE_TYPE_MASK = 0170000; private const int UNIX_SYMBOLIC_LINK = 0120000; @@ -59,9 +67,28 @@ public static function validate(string $entry, string $extractionRoot): string /** * @return array */ - public static function validateArchive(ZipArchive $archive, string $extractionRoot): array - { + public static function validateArchive( + ZipArchive $archive, + string $extractionRoot, + int $maxEntries = self::DEFAULT_MAX_ENTRIES, + int $maxEntryUncompressedBytes = self::DEFAULT_MAX_ENTRY_UNCOMPRESSED_BYTES, + int $maxTotalUncompressedBytes = self::DEFAULT_MAX_TOTAL_UNCOMPRESSED_BYTES, + float $maxCompressionRatio = self::DEFAULT_MAX_COMPRESSION_RATIO, + ): array { + self::validateArchiveLimits( + $maxEntries, + $maxEntryUncompressedBytes, + $maxTotalUncompressedBytes, + $maxCompressionRatio, + ); + if ($maxEntries > 0 && $archive->numFiles > $maxEntries) { + throw new UnsafeArchiveEntryException( + "ZIP archive contains {$archive->numFiles} entries; the configured maximum is {$maxEntries}.", + ); + } + $entries = []; + $totalUncompressedBytes = 0; for ($index = 0; $index < $archive->numFiles; $index++) { $entry = $archive->getNameIndex($index); @@ -71,11 +98,37 @@ public static function validateArchive(ZipArchive $archive, string $extractionRo $entries[$index] = self::validate($entry, $extractionRoot); self::assertNotSymbolicLink($archive, $index, $entry); + $totalUncompressedBytes = self::validateEntryResources( + $archive, + $index, + $entry, + $totalUncompressedBytes, + $maxEntryUncompressedBytes, + $maxTotalUncompressedBytes, + $maxCompressionRatio, + ); } return $entries; } + public static function validateArchiveLimits( + int $maxEntries, + int $maxEntryUncompressedBytes, + int $maxTotalUncompressedBytes, + float $maxCompressionRatio, + ): void { + if ( + $maxEntries < 0 + || $maxEntryUncompressedBytes < 0 + || $maxTotalUncompressedBytes < 0 + || $maxCompressionRatio < 0 + || !is_finite($maxCompressionRatio) + ) { + throw new \InvalidArgumentException('ZIP extraction limits must be finite, non-negative values.'); + } + } + /** @param list $segments */ private static function assertNoSymbolicLinkInDestination(string $root, array $segments, string $entry): void { @@ -112,4 +165,49 @@ private static function assertNotSymbolicLink(ZipArchive $archive, int $index, s throw new UnsafeArchiveEntryException("Symbolic-link ZIP entry detected: {$entry}"); } } + + private static function validateEntryResources( + ZipArchive $archive, + int $index, + string $entry, + int $currentTotal, + int $maxEntryUncompressedBytes, + int $maxTotalUncompressedBytes, + float $maxCompressionRatio, + ): int { + $stat = $archive->statIndex($index); + if (!is_array($stat)) { + throw new UnsafeArchiveEntryException("Unable to read ZIP resource metadata for entry: {$entry}"); + } + + $size = $stat['size']; + $compressedSize = $stat['comp_size']; + if ($size < 0 || $compressedSize < 0) { + throw new UnsafeArchiveEntryException("Invalid ZIP resource metadata for entry: {$entry}"); + } + if ($maxEntryUncompressedBytes > 0 && $size > $maxEntryUncompressedBytes) { + throw new UnsafeArchiveEntryException( + "ZIP entry exceeds the configured uncompressed-size limit: {$entry}", + ); + } + if ($size > PHP_INT_MAX - $currentTotal) { + throw new UnsafeArchiveEntryException('ZIP archive uncompressed size exceeds the platform integer range.'); + } + + $total = $currentTotal + $size; + if ($maxTotalUncompressedBytes > 0 && $total > $maxTotalUncompressedBytes) { + throw new UnsafeArchiveEntryException('ZIP archive exceeds the configured total uncompressed-size limit.'); + } + if ( + $maxCompressionRatio > 0 + && $size > 0 + && ($compressedSize === 0 || ($size / $compressedSize) > $maxCompressionRatio) + ) { + throw new UnsafeArchiveEntryException( + "ZIP entry exceeds the configured compression-ratio limit: {$entry}", + ); + } + + return $total; + } } diff --git a/src/Storage/StorageFactory.php b/src/Storage/StorageFactory.php index ead1398..fc5bee4 100644 --- a/src/Storage/StorageFactory.php +++ b/src/Storage/StorageFactory.php @@ -113,6 +113,7 @@ public static function clearDrivers(): void */ public static function createFilesystem(array $config): FilesystemOperator { + self::assertUnambiguousConfig($config); $provided = self::resolveProvidedFilesystem($config); if ($provided !== null) { return $provided; @@ -198,8 +199,30 @@ public static function mount(string $name, array $config): FilesystemOperator */ public static function mountMany(array $mounts): void { + $prepared = []; foreach ($mounts as $name => $config) { - self::mount((string) $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; } } @@ -226,6 +249,9 @@ public static function registerDriver(string $name, callable $factory): void 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; } @@ -253,6 +279,24 @@ public static function unregisterDriver(string $name): void unset(self::$drivers[self::canonicalDriverName($name)]); } + /** @param array $config */ + private static function assertUnambiguousConfig(array $config): void + { + $hasFilesystem = array_key_exists('filesystem', $config); + $hasAdapter = array_key_exists('adapter', $config); + $hasDriver = array_key_exists('driver', $config) + || array_key_exists('root', $config) + || array_key_exists('constructor', $config); + if (array_sum([(int) $hasFilesystem, (int) $hasAdapter, (int) $hasDriver]) > 1) { + throw new \InvalidArgumentException( + 'Storage configuration must use exactly one of filesystem, adapter, or driver mode.', + ); + } + if (array_key_exists('constructor', $config) && !array_key_exists('driver', $config)) { + throw new \InvalidArgumentException('Storage "constructor" requires an explicit driver.'); + } + } + private static function canonicalDriverName(string $name): string { $normalized = self::normalizeDriverName($name); @@ -345,7 +389,10 @@ private static function createOfficialFilesystem(string $driver, array $config): ); } - $arguments = array_is_list($constructor) ? $constructor : array_values($constructor); + 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)); @@ -399,7 +446,7 @@ private static function resolveOptions(array $config): array $normalized = []; foreach ($options as $key => $value) { if (!is_string($key)) { - continue; + throw new \InvalidArgumentException('Storage "options" keys must be strings.'); } $normalized[$key] = $value; diff --git a/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php b/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php index b6ca8f7..3ba1e48 100644 --- a/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php +++ b/src/StreamHandler/Concerns/UploadProcessorChunkConcern.php @@ -20,7 +20,6 @@ * uploadId: string, * originalFilename: string, * totalChunks: int, - * received: array, * createdAt: int * } */ @@ -44,18 +43,26 @@ private function appendChunkToStream(string $chunkPath, mixed $output, int $inde } } + /** @param ChunkManifest $manifest */ + private function assertChunkManifestIdentity( + array $manifest, + string $uploadId, + string $originalFilename, + int $totalChunks, + ): void { + if ( + $manifest['uploadId'] !== $uploadId + || $manifest['originalFilename'] !== $originalFilename + || $manifest['totalChunks'] !== $totalChunks + ) { + throw new UploadException('Chunk upload metadata does not match the existing session.'); + } + } + /** - * @param array $received */ - private function cleanupChunkUploadArtifacts(string $uploadId, string $chunkDirectory, array $received): void + private function cleanupChunkUploadArtifacts(string $uploadId, string $chunkDirectory): void { - foreach ($received as $chunkName) { - $chunkPath = PathHelper::join($chunkDirectory, $chunkName); - if (FlysystemHelper::fileExists($chunkPath)) { - FlysystemHelper::delete($chunkPath); - } - } - $manifestPath = $this->getChunkManifestPath($uploadId); if (FlysystemHelper::fileExists($manifestPath)) { FlysystemHelper::delete($manifestPath); @@ -95,50 +102,34 @@ private function loadChunkManifest(string $uploadId): ?array throw new UploadException('Invalid chunk manifest.'); } - $receivedRaw = $manifest['received'] ?? null; - if (!is_array($receivedRaw)) { - throw new UploadException('Invalid chunk manifest.'); - } - - $received = []; - foreach ($receivedRaw as $chunkIndex => $chunkName) { - if (!is_string($chunkName)) { - throw new UploadException('Invalid chunk manifest.'); - } - - $received[$chunkIndex] = $chunkName; - } - $originalFilename = $manifest['originalFilename'] ?? null; - $storedUploadId = $manifest['uploadId'] ?? $uploadId; - $createdAt = $manifest['createdAt'] ?? time(); + $storedUploadId = $manifest['uploadId'] ?? null; + $createdAt = $manifest['createdAt'] ?? null; $totalChunks = $manifest['totalChunks'] ?? null; - if (!is_string($originalFilename) || !is_string($storedUploadId)) { + if (!is_string($originalFilename) || !is_string($storedUploadId) || $storedUploadId !== $uploadId) { throw new UploadException('Invalid chunk manifest.'); } - if (!is_int($createdAt) && !is_numeric($createdAt)) { + if (!is_int($createdAt) || $createdAt < 0) { throw new UploadException('Invalid chunk manifest.'); } - if (!is_int($totalChunks) && !is_numeric($totalChunks)) { + if (!is_int($totalChunks) || $totalChunks < 1) { throw new UploadException('Invalid chunk manifest.'); } return [ 'uploadId' => $storedUploadId, 'originalFilename' => $originalFilename, - 'totalChunks' => (int) $totalChunks, - 'received' => $received, - 'createdAt' => (int) $createdAt, + 'totalChunks' => $totalChunks, + 'createdAt' => $createdAt, ]; } /** - * @param array $received */ - private function mergeChunksToDestination(string $chunkDirectory, array $received, int $totalChunks, string $destination): void + private function mergeChunksToDestination(string $chunkDirectory, int $totalChunks, string $destination): void { $output = fopen('php://temp', 'rb+'); if ($output === false) { @@ -149,7 +140,7 @@ private function mergeChunksToDestination(string $chunkDirectory, array $receive try { for ($i = 0; $i < $totalChunks; $i++) { - $chunkPath = $this->resolveChunkPath($chunkDirectory, $received, $i); + $chunkPath = $this->resolveChunkPath($chunkDirectory, $i); $this->appendChunkToStream($chunkPath, $output, $i); } @@ -160,17 +151,25 @@ private function mergeChunksToDestination(string $chunkDirectory, array $receive } } - /** - * @param array $received - */ - private function resolveChunkPath(string $chunkDirectory, array $received, int $index): string + /** @return array */ + private function receivedChunkMap(string $chunkDirectory, int $totalChunks): array { - $chunkName = $received[(string) $index] ?? null; - if (!is_string($chunkName)) { - throw new UploadException("Missing chunk index {$index}."); + $received = []; + for ($index = 0; $index < $totalChunks; $index++) { + $name = sprintf('chunk_%06d.part', $index); + if (FlysystemHelper::fileExists(PathHelper::join($chunkDirectory, $name))) { + $received[(string) $index] = $name; + } } - $chunkPath = PathHelper::join($chunkDirectory, $chunkName); + return $received; + } + + /** + */ + private function resolveChunkPath(string $chunkDirectory, int $index): string + { + $chunkPath = PathHelper::join($chunkDirectory, sprintf('chunk_%06d.part', $index)); if (!FlysystemHelper::fileExists($chunkPath)) { throw new UploadException("Missing chunk file for index {$index}."); } @@ -179,7 +178,7 @@ private function resolveChunkPath(string $chunkDirectory, array $received, int $ } /** - * @return array{0: ChunkManifest, 1: int, 2: array} + * @return array{0: ChunkManifest, 1: int} */ private function resolveCompleteChunkState(string $uploadId): array { @@ -189,7 +188,8 @@ private function resolveCompleteChunkState(string $uploadId): array } $totalChunks = $manifest['totalChunks']; - $received = $manifest['received']; + $chunkDirectory = $this->getChunkDirectory($uploadId); + $received = $this->receivedChunkMap($chunkDirectory, $totalChunks); if ($totalChunks < 1 || count($received) !== $totalChunks) { throw new UploadException('Upload is not complete.'); } @@ -197,9 +197,7 @@ private function resolveCompleteChunkState(string $uploadId): array throw new UploadException('Total chunks exceed configured limit.'); } - ksort($received); - - return [$manifest, $totalChunks, $received]; + return [$manifest, $totalChunks]; } /** @@ -208,8 +206,10 @@ private function resolveCompleteChunkState(string $uploadId): array private function saveChunkManifest(string $uploadId, array $manifest): void { $path = $this->getChunkManifestPath($uploadId); - $json = json_encode($manifest, JSON_PRETTY_PRINT); - if ($json === false) { + + try { + $json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + } catch (\JsonException) { throw new UploadException('Failed to persist chunk manifest.'); } @@ -252,4 +252,37 @@ private function validateUploadId(string $uploadId): void throw new UploadException('Invalid upload session id.'); } } + + /** + * @template T + * @param callable(): T $operation + * @return T + */ + private function withChunkSessionLock(string $uploadId, callable $operation): mixed + { + $chunkDirectory = $this->getChunkDirectory($uploadId); + if (!FlysystemHelper::isLocalPath($chunkDirectory)) { + return $operation(); + } + + $lockDirectory = dirname($chunkDirectory); + if (!is_dir($lockDirectory) && !mkdir($lockDirectory, 0700, true) && !is_dir($lockDirectory)) { + throw new UploadException('Unable to create chunk lock directory.'); + } + $lock = fopen(PathHelper::join($lockDirectory, ".{$uploadId}.lock"), 'c+b'); + if (!is_resource($lock) || !flock($lock, LOCK_EX)) { + if (is_resource($lock)) { + fclose($lock); + } + + throw new UploadException('Unable to lock chunk upload session.'); + } + + try { + return $operation(); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } } diff --git a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php index d168365..69a584d 100644 --- a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php +++ b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php @@ -21,6 +21,23 @@ */ trait UploadProcessorValidationConcern { + /** + * Get a unique destination for the uploaded file. + */ + private function buildDestination(string $fileName): string + { + $subDir = $this->useDateDirectories ? date('Y/m/d') : ''; + $destinationDir = $subDir !== '' + ? PathHelper::join($this->uploadDir, $subDir) + : $this->uploadDir; + + if (!FlysystemHelper::directoryExists($destinationDir)) { + FlysystemHelper::createDirectory($destinationDir); + } + + return PathHelper::join($destinationDir, $fileName); + } + private function copyImageToInspectionFile(string $filePath, string $tempFile): void { $stream = FlysystemHelper::readStream($filePath); @@ -42,6 +59,13 @@ private function copyImageToInspectionFile(string $filePath, string $tempFile): fclose($target); } + private function deleteIncomingFile(string $path): void + { + if (FlysystemHelper::fileExists($path)) { + FlysystemHelper::delete($path); + } + } + /** * Ensure the upload directory exists. */ @@ -52,6 +76,44 @@ private function ensureUploadDirectoryExists(): void } } + 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)) { + $this->moveIncomingFile($source, $destination); + + return $destination; + } + + $destinationChecksum = FlysystemHelper::checksum($destination, 'sha256'); + $sourceChecksum = FlysystemHelper::checksum($source, 'sha256'); + if ( + is_string($destinationChecksum) + && is_string($sourceChecksum) + && hash_equals($destinationChecksum, $sourceChecksum) + ) { + $this->deleteIncomingFile($source); + + return $destination; + } + + throw new UploadException('A file-name collision was detected for different content.'); + } + + for ($attempt = 0; $attempt < 5; $attempt++) { + $destination = $this->buildDestination($this->generateFileName(null, $extension)); + if (!FlysystemHelper::fileExists($destination)) { + $this->moveIncomingFile($source, $destination); + + return $destination; + } + } + + throw new UploadException('Unable to allocate a unique upload destination.'); + } + /** * Get the MIME type of a file. */ @@ -65,29 +127,6 @@ private function getFileMimeType(string $filePath): string return $mimeType; } - /** - * Get a unique destination for the uploaded file. - */ - private function getUniqueDestination(string $fileName): string - { - $subDir = $this->useDateDirectories ? date('Y/m/d') : ''; - $destinationDir = $subDir !== '' - ? PathHelper::join($this->uploadDir, $subDir) - : $this->uploadDir; - - if (!FlysystemHelper::directoryExists($destinationDir)) { - FlysystemHelper::createDirectory($destinationDir); - } - - $destination = PathHelper::join($destinationDir, $fileName); - - if (FlysystemHelper::fileExists($destination)) { - throw new UploadException('File with the same name already exists.'); - } - - return $destination; - } - /** * Check if a file is an image. */ @@ -167,14 +206,23 @@ private function normalizeExtensions(array $extensions): array private function normalizeUploadSize(int|string $size): int { if (is_int($size)) { + if ($size < 0) { + throw new UploadException('Invalid upload size metadata.'); + } + return $size; } - if (!is_numeric($size)) { + if (preg_match('/^(?:0|[1-9][0-9]*)$/', $size) !== 1) { + throw new UploadException('Invalid upload size metadata.'); + } + + $normalized = filter_var($size, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if (!is_int($normalized)) { throw new UploadException('Invalid upload size metadata.'); } - return (int) $size; + return $normalized; } /** diff --git a/src/StreamHandler/DownloadProcessor.php b/src/StreamHandler/DownloadProcessor.php index 0e97a38..7343ae7 100644 --- a/src/StreamHandler/DownloadProcessor.php +++ b/src/StreamHandler/DownloadProcessor.php @@ -28,7 +28,7 @@ class DownloadProcessor private bool $blockHiddenFiles = true; - private int $chunkSize = 8192; + private int $chunkSize = 65_536; private string $defaultDownloadName = 'download.bin'; @@ -67,7 +67,9 @@ public function prepareDownload( $mimeType = MetadataHelper::getMimeType($normalizedPath) ?? 'application/octet-stream'; $lastModified = FlysystemHelper::lastModified($normalizedPath); [$rangeStart, $rangeEnd, $isPartial] = $this->resolveRange($rangeHeader, $size); - $contentLength = ($rangeEnd - $rangeStart) + 1; + $contentLength = $rangeStart === null || $rangeEnd === null + ? 0 + : ($rangeEnd - $rangeStart) + 1; $resolvedFileName = $this->resolveDownloadName($downloadName, $normalizedPath); $disposition = $this->forceAttachment ? 'attachment' : 'inline'; @@ -84,7 +86,7 @@ public function prepareDownload( 'X-Content-Type-Options' => 'nosniff', ]; - if ($isPartial) { + if ($isPartial && is_int($rangeStart) && is_int($rangeEnd)) { $headers['Content-Range'] = sprintf('bytes %d-%d/%d', $rangeStart, $rangeEnd, $size); } @@ -134,7 +136,10 @@ public function setBlockHiddenFiles(bool $block = true): void */ public function setChunkSize(int $chunkSize): void { - $this->chunkSize = max(1024, $chunkSize); + if ($chunkSize < 1) { + throw new DownloadException('Download chunk size must be positive.'); + } + $this->chunkSize = $chunkSize; } /** @@ -181,7 +186,10 @@ public function setForceAttachment(bool $enabled = true): void */ public function setMaxDownloadSize(int $maxDownloadSize = 0): void { - $this->maxDownloadSize = max(0, $maxDownloadSize); + if ($maxDownloadSize < 0) { + throw new DownloadException('Maximum download size must be non-negative.'); + } + $this->maxDownloadSize = $maxDownloadSize; } /** @@ -221,7 +229,7 @@ public function streamDownload( } try { - $this->seekStreamToOffset($inputStream, $manifest->range->start); + $this->seekStreamToOffset($inputStream, $manifest->range->start ?? 0); $remaining = $manifest->range->contentLength; $bytesSent = 0; @@ -293,9 +301,15 @@ private function discardBytes(mixed $stream, int $bytes): void private function isHiddenFile(string $path): bool { - $basename = basename($path); - - return $basename !== '' && str_starts_with($basename, '.'); + $location = preg_replace('/^[a-zA-Z0-9._-]+:\/\//', '', str_replace('\\', '/', $path)) ?? $path; + $segments = preg_split('/\/+/', trim($location, '/')) ?: []; + + return array_any( + $segments, + static fn(string $segment): bool => $segment !== '.' + && $segment !== '..' + && str_starts_with($segment, '.'), + ); } private function normalizeExtension(string $extension): string @@ -322,69 +336,13 @@ private function normalizeExtensions(array $extensions): array return array_values(array_unique($normalized)); } - private function pathStartsWith(string $path, string $prefix): bool - { - $pathNormalized = rtrim($path, '/\\'); - $prefixNormalized = rtrim($prefix, '/\\'); - if (PHP_OS_FAMILY === 'Windows') { - $pathNormalized = strtolower($pathNormalized); - $prefixNormalized = strtolower($prefixNormalized); - } - - return $pathNormalized === $prefixNormalized - || str_starts_with($pathNormalized, $prefixNormalized . DIRECTORY_SEPARATOR); - } - private function pathWithinAllowedRoot(string $path): bool { if ($this->allowedRoots === []) { return true; } - $pathIsScheme = PathHelper::hasScheme($path); - foreach ($this->allowedRoots as $root) { - $rootIsScheme = PathHelper::hasScheme($root); - if ($pathIsScheme || $rootIsScheme) { - if ($this->pathWithinSchemeRoot($path, $root, $pathIsScheme, $rootIsScheme)) { - return true; - } - - continue; - } - - if ($this->pathWithinLocalRoot($path, $root)) { - return true; - } - } - - return false; - } - - private function pathWithinLocalRoot(string $path, string $root): bool - { - $pathAbsolute = PathHelper::isAbsolute($path) - ? $path - : PathHelper::toAbsolutePath($path); - $rootAbsolute = PathHelper::isAbsolute($root) - ? $root - : PathHelper::toAbsolutePath($root); - - $resolvedPath = realpath($pathAbsolute) ?: PathHelper::normalize($pathAbsolute); - $resolvedRoot = realpath($rootAbsolute) ?: PathHelper::normalize($rootAbsolute); - - return $this->pathStartsWith($resolvedPath, $resolvedRoot); - } - - private function pathWithinSchemeRoot(string $path, string $root, bool $pathIsScheme, bool $rootIsScheme): bool - { - if (!$pathIsScheme || !$rootIsScheme) { - return false; - } - - $normalizedPath = rtrim(str_replace('\\', '/', $path), '/'); - $normalizedRoot = rtrim(str_replace('\\', '/', $root), '/'); - - return $normalizedPath === $normalizedRoot || str_starts_with($normalizedPath, $normalizedRoot . '/'); + return array_any($this->allowedRoots, fn($root) => FlysystemHelper::isSameOrDescendant($root, $path)); } /** @@ -407,13 +365,38 @@ private function resolveDownloadName(?string $downloadName, string $path): strin return $safe !== '' ? $safe : $this->defaultDownloadName; } + /** @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.'); + } + + if ($endRaw === '') { + return [$start, $size - 1, true]; + } + + $end = (int) $endRaw; + if ($end < $start) { + throw new DownloadException('Invalid range header.'); + } + + return [$start, min($end, $size - 1), true]; + } + /** - * @return array{int, int, bool} + * @return array{int|null, int|null, bool} */ private function resolveRange(?string $rangeHeader, int $size): array { - if ($size < 1) { - throw new DownloadException('Cannot prepare download for empty file.'); + if ($size === 0) { + if ($rangeHeader !== null && trim($rangeHeader) !== '' && $this->rangeRequestsEnabled) { + throw new DownloadException('Byte range is unsatisfiable for an empty file.'); + } + + return [null, null, false]; } if (!$this->rangeRequestsEnabled || $rangeHeader === null || trim($rangeHeader) === '') { @@ -432,32 +415,21 @@ private function resolveRange(?string $rangeHeader, int $size): array } if ($startRaw === '') { - $suffixLength = (int) $endRaw; - if ($suffixLength <= 0) { - throw new DownloadException('Invalid range header.'); - } - - $start = max(0, $size - $suffixLength); - $end = $size - 1; - - return [$start, $end, true]; - } - - $start = (int) $startRaw; - if ($start < 0 || $start >= $size) { - throw new DownloadException('Invalid range header.'); + return $this->resolveSuffixRange($endRaw, $size); } - if ($endRaw === '') { - return [$start, $size - 1, true]; - } + return $this->resolveExplicitRange($startRaw, $endRaw, $size); + } - $end = (int) $endRaw; - if ($end < $start) { + /** @return array{int, int, true} */ + private function resolveSuffixRange(string $endRaw, int $size): array + { + $suffixLength = (int) $endRaw; + if ($suffixLength <= 0) { throw new DownloadException('Invalid range header.'); } - return [$start, min($end, $size - 1), true]; + return [max(0, $size - $suffixLength), $size - 1, true]; } private function sanitizeFilename(string $name): string diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 680faf6..14941b9 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -24,7 +24,6 @@ * uploadId: string, * originalFilename: string, * totalChunks: int, - * received: array, * createdAt: int * } * @phpstan-type UploadInfo array{ @@ -93,7 +92,7 @@ class UploadProcessor private int $maxChunkSize = 0; - private int $maxFileSize = 30720; + private int $maxFileSize = 25 * 1024 * 1024; private int $maxImageHeight = 0; @@ -103,7 +102,7 @@ class UploadProcessor private bool $requireMalwareScan = false; - private bool $strictContentTypeValidation = false; + private bool $strictContentTypeValidation = true; private ?string $tempDir = null; @@ -127,19 +126,35 @@ public function finalizeChunkUpload(string $uploadId): string } $this->validateUploadId($uploadId); - [$manifest, $totalChunks, $received] = $this->resolveCompleteChunkState($uploadId); - $originalFilename = $manifest['originalFilename']; - $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); - $this->validateFileExtension($extension); - $fileName = $this->generateFileName(null, $extension); - $destination = $this->getUniqueDestination($fileName); - $chunkDirectory = $this->getChunkDirectory($uploadId); - - $this->mergeChunksToDestination($chunkDirectory, $received, $totalChunks, $destination); - $this->validateFinalizedUpload($destination); - $this->cleanupChunkUploadArtifacts($uploadId, $chunkDirectory, $received); - - return $destination; + + return $this->withChunkSessionLock($uploadId, function () use ($uploadId): string { + [$manifest, $totalChunks] = $this->resolveCompleteChunkState($uploadId); + $originalFilename = $manifest['originalFilename']; + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $this->validateFileExtension($extension); + $chunkDirectory = $this->getChunkDirectory($uploadId); + $stagingName = '.assembled_' . bin2hex(random_bytes(16)); + if ($extension !== '') { + $stagingName .= '.' . ltrim($extension, '.'); + } + $stagingPath = PathHelper::join($chunkDirectory, $stagingName); + + try { + $this->mergeChunksToDestination($chunkDirectory, $totalChunks, $stagingPath); + $this->validateFinalizedUpload($stagingPath); + $destination = $this->finalizeIncomingFile($stagingPath, $extension); + } catch (\Throwable $exception) { + if (FlysystemHelper::fileExists($stagingPath)) { + FlysystemHelper::delete($stagingPath); + } + + throw $exception; + } + + $this->cleanupChunkUploadArtifacts($uploadId, $chunkDirectory); + + return $destination; + }); } /** @@ -177,6 +192,17 @@ public function getValidationProfiles(): array return array_keys(self::VALIDATION_PROFILES); } + /** + * Ingest a trusted CLI/application file that is not an HTTP upload. + * + * @param array $file File metadata using the same keys as $_FILES. + * @param array $metadata Explicit audit metadata for the log entry. + */ + public function ingestFile(array $file, array $metadata = []): string + { + return $this->processIncomingFile($file, false, $metadata); + } + /** * Process an upload chunk and persist resumable state. * @@ -200,104 +226,52 @@ public function processChunkUpload( $chunkFile = $this->validateFile($chunkFile); $this->validateChunkUploadRequest($chunkFile, $uploadId, $chunkIndex, $totalChunks, $originalFilename); - $chunkDirectory = $this->getChunkDirectory($uploadId); - if (!FlysystemHelper::directoryExists($chunkDirectory)) { - FlysystemHelper::createDirectory($chunkDirectory); - } - - $chunkPath = PathHelper::join($chunkDirectory, sprintf('chunk_%06d.part', $chunkIndex)); - $this->moveIncomingFile($chunkFile['tmp_name'], $chunkPath); - - /** @var ChunkManifest $manifest */ - $manifest = $this->loadChunkManifest($uploadId) ?? [ - 'uploadId' => $uploadId, - 'originalFilename' => $originalFilename, - 'totalChunks' => $totalChunks, - 'received' => [], - 'createdAt' => time(), - ]; + return $this->withChunkSessionLock($uploadId, function () use ( + $chunkFile, + $uploadId, + $chunkIndex, + $totalChunks, + $originalFilename, + ): ChunkUploadState { + $chunkDirectory = $this->getChunkDirectory($uploadId); + if (!FlysystemHelper::directoryExists($chunkDirectory)) { + FlysystemHelper::createDirectory($chunkDirectory); + } - $manifest['originalFilename'] = $originalFilename; - $manifest['totalChunks'] = $totalChunks; - $manifest['received'][(string) $chunkIndex] = basename($chunkPath); - ksort($manifest['received']); - $this->saveChunkManifest($uploadId, $manifest); - - return new ChunkUploadState( - uploadId: $uploadId, - receivedChunks: count($manifest['received']), - totalChunks: $totalChunks, - complete: count($manifest['received']) === $totalChunks, - ); + /** @var ChunkManifest $manifest */ + $manifest = $this->loadChunkManifest($uploadId) ?? [ + 'uploadId' => $uploadId, + 'originalFilename' => $originalFilename, + 'totalChunks' => $totalChunks, + 'createdAt' => time(), + ]; + $this->assertChunkManifestIdentity($manifest, $uploadId, $originalFilename, $totalChunks); + $this->saveChunkManifest($uploadId, $manifest); + + $chunkPath = PathHelper::join($chunkDirectory, sprintf('chunk_%06d.part', $chunkIndex)); + $this->moveIncomingFile($chunkFile['tmp_name'], $chunkPath); + $received = $this->receivedChunkMap($chunkDirectory, $totalChunks); + + return new ChunkUploadState( + uploadId: $uploadId, + receivedChunks: count($received), + totalChunks: $totalChunks, + complete: count($received) === $totalChunks, + ); + }); } /** * Process the upload and save the file. * * @param array $file The file data from $_FILES. + * @param array $metadata Explicit audit metadata for the log entry. * @return string The path to the saved file. * @throws UploadException If validation fails or upload directory is not set. */ - public function processUpload(array $file): string + public function processUpload(array $file, array $metadata = []): string { - $logFileName = is_string($file['name'] ?? null) ? $file['name'] : null; - - try { - if (!isset($this->uploadDir) || $this->uploadDir === '') { - throw new UploadException('Upload directory is not set.'); - } - - $file = $this->validateFile($file); - $tmpName = $file['tmp_name']; - $extension = pathinfo($file['name'], PATHINFO_EXTENSION); - $fileType = $this->validateUploadedPayload($tmpName, $extension, false); - - $fileName = $this->generateFileName($tmpName, $extension); - $destination = $this->getUniqueDestination($fileName); - - if (!move_uploaded_file($tmpName, $destination)) { - $stream = fopen($tmpName, 'rb'); - if (!is_resource($stream)) { - throw new UploadException('Failed to move uploaded file.'); - } - - try { - FlysystemHelper::writeStream($destination, $stream); - } finally { - fclose($stream); - } - - $this->unlinkFileSilently($tmpName); - } - - // Log upload metadata - if (isset($this->logger)) { - $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); - $callingClass = $backtrace[1]['class'] ?? 'Unknown Class'; - $callingMethod = $backtrace[1]['function'] ?? 'Unknown Method'; - - $this->logger->info('File uploaded successfully.', [ - 'fileName' => $fileName, - 'destination' => $destination, - 'fileType' => $fileType, - 'uploader' => [ - 'class' => $callingClass, - 'method' => $callingMethod, - ], - ]); - } - - return $destination; - } catch (\Throwable $e) { - if (isset($this->logger)) { - $this->logger->error('File upload failed.', [ - 'error' => $e->getMessage(), - 'file' => $logFileName, - ]); - } - - throw $e; - } + return $this->processIncomingFile($file, true, $metadata); } /** @@ -308,8 +282,11 @@ public function processUpload(array $file): string */ public function setChunkLimits(int $maxChunkCount = 0, int $maxChunkSize = 0): void { - $this->maxChunkCount = max(0, $maxChunkCount); - $this->maxChunkSize = max(0, $maxChunkSize); + if ($maxChunkCount < 0 || $maxChunkSize < 0) { + throw new UploadException('Chunk limits must be non-negative.'); + } + $this->maxChunkCount = $maxChunkCount; + $this->maxChunkSize = $maxChunkSize; } /** @@ -353,8 +330,11 @@ public function setExtensionPolicy(array $allowedExtensions = [], array $blocked */ public function setImageValidationSettings(int $maxImageWidth = 0, int $maxImageHeight = 0): void { - $this->maxImageWidth = max(0, $maxImageWidth); - $this->maxImageHeight = max(0, $maxImageHeight); + if ($maxImageWidth < 0 || $maxImageHeight < 0) { + throw new UploadException('Image limits must be non-negative.'); + } + $this->maxImageWidth = $maxImageWidth; + $this->maxImageHeight = $maxImageHeight; } /** @@ -441,6 +421,9 @@ public function setValidationProfile(string $profile): void */ public function setValidationSettings(array $allowedFileTypes, int $maxFileSize): void { + if ($maxFileSize < 0) { + throw new UploadException('Maximum file size must be non-negative.'); + } $this->allowedFileTypes = $allowedFileTypes; $this->maxFileSize = $maxFileSize; $this->validationProfile = null; @@ -453,7 +436,9 @@ private function generateFileName(?string $dataSource, string $extension): strin { $identifier = match ($this->namingStrategy) { 'timestamp' => sprintf('%d_%s', time(), bin2hex(random_bytes(8))), - default => $dataSource !== null ? hash_file('sha256', $dataSource) : bin2hex(random_bytes(32)), + default => $dataSource !== null + ? FlysystemHelper::checksum($dataSource, 'sha256') + : bin2hex(random_bytes(32)), }; if (!is_string($identifier)) { throw new UploadException('Unable to generate upload file name.'); @@ -465,4 +450,51 @@ private function generateFileName(?string $dataSource, string $extension): strin ? sprintf('upload_%s.%s', $identifier, $extension) : sprintf('upload_%s', $identifier); } + + /** + * @param array $file + * @param array $metadata + */ + private function processIncomingFile(array $file, bool $requireHttpUpload, array $metadata): string + { + $logFileName = is_string($file['name'] ?? null) ? $file['name'] : null; + + try { + if (!isset($this->uploadDir) || $this->uploadDir === '') { + throw new UploadException('Upload directory is not set.'); + } + + $file = $this->validateFile($file); + $tmpName = $file['tmp_name']; + if ($requireHttpUpload && !is_uploaded_file($tmpName)) { + throw new UploadException('File is not a valid HTTP upload.'); + } + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); + $fileType = $this->validateUploadedPayload($tmpName, $extension, false); + + $destination = $this->finalizeIncomingFile($tmpName, $extension); + $fileName = basename($destination); + + if (isset($this->logger)) { + $this->logger->info('File uploaded successfully.', [ + 'fileName' => $fileName, + 'destination' => $destination, + 'fileType' => $fileType, + 'metadata' => $metadata, + ]); + } + + return $destination; + } catch (\Throwable $e) { + if (isset($this->logger)) { + $this->logger->error('File upload failed.', [ + 'error' => $e->getMessage(), + 'file' => $logFileName, + 'metadata' => $metadata, + ]); + } + + throw $e; + } + } } diff --git a/src/Utils/FlysystemHelper.php b/src/Utils/FlysystemHelper.php index d2746e4..684bc14 100644 --- a/src/Utils/FlysystemHelper.php +++ b/src/Utils/FlysystemHelper.php @@ -20,6 +20,9 @@ final class FlysystemHelper { private static ?FilesystemOperator $defaultFilesystem = null; + /** @var array */ + private static array $localFilesystems = []; + /** @var array */ private static array $mounts = []; @@ -100,6 +103,10 @@ public static function copy(string $source, string $destination, array $config = */ public static function copyDirectory(string $source, string $destination, array $config = []): void { + if (self::isSameOrDescendant($source, $destination)) { + throw new \InvalidArgumentException('A directory cannot be copied into itself or one of its descendants.'); + } + [$sourceFilesystem, $sourceLocation] = self::filesystemForDirectory($source); [$destinationFilesystem, $destinationLocation] = self::filesystemForDirectory($destination); $destinationFilesystem->createDirectory($destinationLocation, $config); @@ -178,13 +185,9 @@ public static function deleteDirectory(string $path): void */ public static function directoryExists(string $path): bool { - try { - [$filesystem, $location] = self::filesystemForDirectory($path); + [$filesystem, $location] = self::filesystemForDirectory($path); - return $filesystem->directoryExists($location); - } catch (\Throwable) { - return false; - } + return $filesystem->directoryExists($location); } /** @@ -195,13 +198,9 @@ public static function directoryExists(string $path): bool */ public static function fileExists(string $path): bool { - try { - [$filesystem, $location] = self::filesystemForFile($path); + [$filesystem, $location] = self::filesystemForFile($path); - return $filesystem->fileExists($location); - } catch (\Throwable) { - return false; - } + return $filesystem->fileExists($location); } /** @@ -212,13 +211,9 @@ public static function fileExists(string $path): bool */ public static function has(string $path): bool { - try { - [$filesystem, $location] = self::filesystemForPath($path); + [$filesystem, $location] = self::filesystemForPath($path); - return $filesystem->has($location); - } catch (\Throwable) { - return false; - } + return $filesystem->has($location); } /** @@ -231,6 +226,11 @@ public static function hasDefaultFilesystem(): bool return self::$defaultFilesystem !== null; } + public static function hasMount(string $name): bool + { + return isset(self::$mounts[self::normalizeMountName($name)]); + } + /** * Determine whether a path is handled directly by the local filesystem. * @@ -242,6 +242,33 @@ public static function isLocalPath(string $path): bool && (PathHelper::isAbsolute($path) || self::$defaultFilesystem === null); } + /** + * Determine whether two paths resolve to the same filesystem and the target + * is the source directory itself or one of its descendants. + */ + public static function isSameOrDescendant(string $sourceDirectory, string $target): bool + { + if (self::isLocalPath($sourceDirectory) && self::isLocalPath($target)) { + $source = rtrim(self::canonicalLocalPath($sourceDirectory), '/'); + $destination = rtrim(self::canonicalLocalPath($target), '/'); + + return self::pathsMatch($source, $destination) + || self::pathStartsWith($destination, $source . '/'); + } + + [$sourceFilesystem, $sourceLocation] = self::filesystemForDirectory($sourceDirectory); + [$targetFilesystem, $targetLocation] = self::filesystemForPath($target); + if ($sourceFilesystem !== $targetFilesystem) { + return false; + } + + $sourceLocation = trim(str_replace('\\', '/', $sourceLocation), '/'); + $targetLocation = trim(str_replace('\\', '/', $targetLocation), '/'); + + return $sourceLocation === $targetLocation + || ($sourceLocation === '' ? $targetLocation !== '' : str_starts_with($targetLocation, $sourceLocation . '/')); + } + /** * Get the last modified timestamp of a file. * @@ -312,6 +339,9 @@ public static function mimeType(string $path): ?string public static function mount(string $name, FilesystemOperator $filesystem): void { $normalized = self::normalizeMountName($name); + if (isset(self::$mounts[$normalized])) { + throw new \InvalidArgumentException("Flysystem mount '{$normalized}' is already registered."); + } self::$mounts[$normalized] = $filesystem; } @@ -346,6 +376,10 @@ public static function move(string $source, string $destination, array $config = */ public static function moveDirectory(string $source, string $destination, array $config = []): void { + if (self::isSameOrDescendant($source, $destination)) { + throw new \InvalidArgumentException('A directory cannot be moved into itself or one of its descendants.'); + } + self::copyDirectory($source, $destination, $config); self::deleteDirectory($source); } @@ -395,6 +429,12 @@ public static function readStream(string $path): mixed return $filesystem->readStream($location); } + /** Replace an existing mount explicitly. */ + public static function replaceMount(string $name, FilesystemOperator $filesystem): void + { + self::$mounts[self::normalizeMountName($name)] = $filesystem; + } + /** * Reset the helper by clearing default filesystem and mounts. */ @@ -402,6 +442,7 @@ public static function reset(): void { self::clearDefaultFilesystem(); self::clearMounts(); + self::$localFilesystems = []; } /** @@ -529,6 +570,23 @@ public static function writeStream(string $path, mixed $stream, array $config = $filesystem->writeStream($location, $stream, $config); } + private static function canonicalLocalPath(string $path): string + { + $absolute = PathHelper::toAbsolutePath($path); + $suffix = []; + $candidate = $absolute; + while (!file_exists($candidate) && dirname($candidate) !== $candidate) { + array_unshift($suffix, basename($candidate)); + $candidate = dirname($candidate); + } + + $resolved = realpath($candidate); + $base = is_string($resolved) ? $resolved : $candidate; + $canonical = PathHelper::normalize(PathHelper::join($base, ...$suffix)); + + return str_replace('\\', '/', $canonical); + } + /** * @return array{FilesystemOperator, string} */ @@ -574,18 +632,9 @@ private static function filesystemForFile(string $path): array */ private static function filesystemForLocalDirectory(string $path): array { - $path = PathHelper::normalize(rtrim($path, '/\\')); - if ($path === '' || $path === DIRECTORY_SEPARATOR) { - return [new Filesystem(new LocalFilesystemAdapter(DIRECTORY_SEPARATOR)), '']; - } + [$root, $location] = self::localRootAndLocation($path); - $parent = dirname($path); - $location = basename($path); - - return [ - new Filesystem(new LocalFilesystemAdapter($parent)), - str_replace('\\', '/', $location), - ]; + return [self::localFilesystem($root), rtrim($location, '/')]; } /** @@ -593,14 +642,9 @@ private static function filesystemForLocalDirectory(string $path): array */ private static function filesystemForLocalFile(string $path): array { - $path = PathHelper::normalize($path); - $directory = dirname($path); - $location = basename($path); + [$root, $location] = self::localRootAndLocation($path); - return [ - new Filesystem(new LocalFilesystemAdapter($directory)), - str_replace('\\', '/', $location), - ]; + return [self::localFilesystem($root), ltrim($location, '/')]; } /** @@ -611,9 +655,30 @@ private static function filesystemForPath(string $path): array return self::filesystemFor($path, false); } + private static function localFilesystem(string $root): FilesystemOperator + { + return self::$localFilesystems[$root] ??= new Filesystem(new LocalFilesystemAdapter($root)); + } + + /** @return array{string, string} */ + private static function localRootAndLocation(string $path): array + { + $absolute = str_replace('\\', '/', PathHelper::toAbsolutePath($path)); + if (preg_match('/^([A-Za-z]:)\/(.*)$/', $absolute, $matches) === 1) { + return [$matches[1] . DIRECTORY_SEPARATOR, $matches[2]]; + } + + return [DIRECTORY_SEPARATOR, ltrim($absolute, '/')]; + } + private static function normalizeMountName(string $name): string { - return strtolower(trim($name, " \t\n\r\0\x0B:/")); + $normalized = strtolower(trim($name)); + if (preg_match('/^[a-z][a-z0-9._-]*$/', $normalized) !== 1) { + throw new \InvalidArgumentException("Invalid Flysystem mount name: '{$name}'."); + } + + return $normalized; } /** @@ -658,6 +723,20 @@ private static function normalizeStorageAttributes(StorageAttributes $item): arr return $normalized; } + private static function pathsMatch(string $first, string $second): bool + { + return PHP_OS_FAMILY === 'Windows' + ? strcasecmp($first, $second) === 0 + : $first === $second; + } + + private static function pathStartsWith(string $path, string $prefix): bool + { + return PHP_OS_FAMILY === 'Windows' + ? str_starts_with(strtolower($path), strtolower($prefix)) + : str_starts_with($path, $prefix); + } + /** * @return array{?FilesystemOperator, string} */ diff --git a/src/Utils/FlysystemPathResolver.php b/src/Utils/FlysystemPathResolver.php index 12f4300..b45929b 100644 --- a/src/Utils/FlysystemPathResolver.php +++ b/src/Utils/FlysystemPathResolver.php @@ -53,9 +53,13 @@ public static function relativePathFromRawPath(string $itemPathRaw, string $base return null; } - $relative = $base !== '' && str_starts_with($itemPath, $base . '/') - ? substr($itemPath, strlen($base) + 1) - : ($itemPath === $base ? '' : $itemPath); + if ($base !== '' && $itemPath !== $base && !str_starts_with($itemPath, $base . '/')) { + return null; + } + + $relative = $base !== '' + ? ($itemPath === $base ? '' : substr($itemPath, strlen($base) + 1)) + : $itemPath; return $relative === '' ? null : $relative; } diff --git a/src/Utils/MetadataHelper.php b/src/Utils/MetadataHelper.php index e8bf9c3..6dd203b 100644 --- a/src/Utils/MetadataHelper.php +++ b/src/Utils/MetadataHelper.php @@ -41,7 +41,6 @@ public static function getAllMetadata(string $path, bool $humanReadableSize = fa 'mime_type' => self::getMimeType($path), 'type' => $type, 'ownership' => self::getOwnershipDetails($path), - 'last_modified_by' => self::getLastModifiedBy($path), 'extension' => self::getFileExtension($path), 'is_hidden' => self::isHidden($path), 'symlink_target' => self::getSymlinkTarget($path), @@ -175,7 +174,7 @@ public static function getFileSize(string $path, bool $humanReadable = false): s * 'Y-m-d H:i:s'. If the file does not exist, returns null. * * @param string $path The path to the file to retrieve timestamps for. - * @return array{created: string, modified: string, accessed: string}|null The human-readable timestamps, or null if the file does not exist. + * @return array{created: string|null, modified: string, accessed: string|null}|null */ public static function getHumanReadableTimestamps(string $path): ?array { @@ -185,9 +184,9 @@ public static function getHumanReadableTimestamps(string $path): ?array } return [ - 'created' => date('Y-m-d H:i:s', $timestamps['created']), + 'created' => is_int($timestamps['created']) ? date('Y-m-d H:i:s', $timestamps['created']) : null, 'modified' => date('Y-m-d H:i:s', $timestamps['modified']), - 'accessed' => date('Y-m-d H:i:s', $timestamps['accessed']), + 'accessed' => is_int($timestamps['accessed']) ? date('Y-m-d H:i:s', $timestamps['accessed']) : null, ]; } @@ -314,8 +313,8 @@ public static function getSymlinkTarget(string $path): ?string * * @param string $path The path to the file or directory to retrieve * timestamps for. - * @return array{created: int, modified: int, accessed: int}|null The timestamps, or null if the file or directory does - * not exist. + * @return array{created: int|null, modified: int, accessed: int|null}|null The timestamps, or null if the file or directory does + * not exist. */ public static function getTimestamps(string $path): ?array { @@ -344,7 +343,7 @@ public static function getTimestamps(string $path): ?array return null; } - return ['created' => $modified, 'modified' => $modified, 'accessed' => $modified]; + return ['created' => null, 'modified' => $modified, 'accessed' => null]; } /** diff --git a/src/Utils/Ownership/WindowsOwnershipResolver.php b/src/Utils/Ownership/WindowsOwnershipResolver.php index 8671116..c42bdd6 100644 --- a/src/Utils/Ownership/WindowsOwnershipResolver.php +++ b/src/Utils/Ownership/WindowsOwnershipResolver.php @@ -14,18 +14,7 @@ final class WindowsOwnershipResolver implements OwnershipResolverInterface */ public function getLastModifiedBy(string $path): ?string { - if (!file_exists($path)) { - return null; - } - - $owner = getenv('USERNAME'); - if (is_string($owner) && $owner !== '') { - return $owner; - } - - $currentUser = get_current_user(); - - return $currentUser !== '' ? $currentUser : null; + return null; } /** @@ -40,16 +29,6 @@ public function getOwnershipDetails(string $path): ?array return null; } - $owner = getenv('USERNAME'); - if (!is_string($owner) || $owner === '') { - $owner = get_current_user() ?: null; - } - - $group = getenv('USERDOMAIN'); - if (!is_string($group) || $group === '') { - $group = null; - } - - return compact('owner', 'group'); + return ['owner' => null, 'group' => null]; } } diff --git a/src/Utils/PathHelper.php b/src/Utils/PathHelper.php index 9e330c2..583c7b7 100644 --- a/src/Utils/PathHelper.php +++ b/src/Utils/PathHelper.php @@ -225,7 +225,20 @@ public static function isAbsolute(string $path): bool */ public static function isValidPath(string $path): bool { - return !preg_match('/[<>:"|?*]/', $path); + if ($path === '' || preg_match('/[\x00-\x1F\x7F]/', $path) === 1) { + return false; + } + if (str_contains($path, '://') && preg_match('/^[A-Za-z][A-Za-z0-9._-]*:\/\/.+$/', $path) !== 1) { + return false; + } + if (preg_match('/[<>"|?*]/', $path) === 1) { + return false; + } + if (preg_match('/^[A-Za-z]:/', $path) === 1 && preg_match('~^[A-Za-z]:[\\\\/]~', $path) !== 1) { + return false; + } + + return true; } /** @@ -299,11 +312,7 @@ public static function pathExists(string $path): bool return true; } - try { - return FlysystemHelper::has($path); - } catch (\Throwable) { - return false; - } + return FlysystemHelper::has($path); } /** @@ -323,6 +332,7 @@ public static function pathExists(string $path): bool */ public static function relativePath(string $from, string $to): string { + self::assertCompatibleRelativePathRoots($from, $to); $from = explode(DIRECTORY_SEPARATOR, self::normalize($from)); $to = explode(DIRECTORY_SEPARATOR, self::normalize($to)); @@ -334,15 +344,21 @@ public static function relativePath(string $from, string $to): string return str_repeat('..' . DIRECTORY_SEPARATOR, count($from)) . implode(DIRECTORY_SEPARATOR, $to); } - /** - * Sanitizes a given path by removing all non-alphanumeric characters except for dash, underscore, slash, and dot. - * - * @param string $path The path to sanitize. - * @return string The sanitized path. - */ - public static function sanitize(string $path): string + public static function sanitizeFilename(string $filename): string { - return preg_replace('/[^A-Za-z0-9\-_\/\.]/', '', $path) ?? ''; + return self::sanitizeSegment($filename); + } + + /** Sanitize one filename or path segment without rewriting a full path. */ + public static function sanitizeSegment(string $segment): string + { + if (str_contains($segment, '/') || str_contains($segment, '\\')) { + throw new \InvalidArgumentException('A path segment cannot contain directory separators.'); + } + + $sanitized = preg_replace('/[\x00-\x1F\x7F<>:"|?*]/', '', trim($segment)) ?? ''; + + return trim($sanitized, " .\t\n\r\0\x0B"); } /** @@ -365,6 +381,29 @@ public static function toAbsolutePath(string $path, ?string $base = null): strin return self::normalize(self::join($base, $path)); } + private static function assertCompatibleRelativePathRoots(string $from, string $to): void + { + $fromScheme = preg_match('/^([A-Za-z][A-Za-z0-9._-]*):\/\//', $from, $fromMatch) === 1 + ? strtolower($fromMatch[1]) + : null; + $toScheme = preg_match('/^([A-Za-z][A-Za-z0-9._-]*):\/\//', $to, $toMatch) === 1 + ? strtolower($toMatch[1]) + : null; + if ($fromScheme !== $toScheme) { + throw new \InvalidArgumentException('Cannot calculate a relative path across different filesystems.'); + } + + $fromDrive = preg_match('~^([A-Za-z]):[\\\\/]~', $from, $fromDriveMatch) === 1 + ? strtolower($fromDriveMatch[1]) + : null; + $toDrive = preg_match('~^([A-Za-z]):[\\\\/]~', $to, $toDriveMatch) === 1 + ? strtolower($toDriveMatch[1]) + : null; + if ($fromDrive !== $toDrive) { + throw new \InvalidArgumentException('Cannot calculate a relative path across different drive roots.'); + } + } + /** * @param list $stack */ diff --git a/src/Utils/PermissionsHelper.php b/src/Utils/PermissionsHelper.php index 6a155de..ab27274 100644 --- a/src/Utils/PermissionsHelper.php +++ b/src/Utils/PermissionsHelper.php @@ -4,8 +4,9 @@ namespace Infocyph\Pathwise\Utils; +use Infocyph\Pathwise\Exceptions\FileAccessException; use Infocyph\Pathwise\Exceptions\MissingExtensionException; -use RuntimeException; +use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; class PermissionsHelper { @@ -22,6 +23,8 @@ class PermissionsHelper */ public static function canExecute(string $path): bool { + self::assertDirectLocalPath($path); + return is_executable($path); } @@ -37,6 +40,8 @@ public static function canExecute(string $path): bool */ public static function canRead(string $path): bool { + self::assertDirectLocalPath($path); + return is_readable($path); } @@ -52,6 +57,8 @@ public static function canRead(string $path): bool */ public static function canWrite(string $path): bool { + self::assertDirectLocalPath($path); + return is_writable($path); } @@ -101,6 +108,7 @@ public static function formatPermissions(int $permissions): string */ public static function getHumanReadablePermissions(string $path): ?string { + self::assertDirectLocalPath($path); if (!file_exists($path)) { return null; } @@ -127,6 +135,7 @@ public static function getHumanReadablePermissions(string $path): ?string */ public static function getOwnership(string $path): ?array { + self::assertDirectLocalPath($path); if (!self::isPosixSupported()) { throw new MissingExtensionException('Ownership operations require ext-posix.'); } @@ -170,6 +179,7 @@ public static function getOwnership(string $path): ?array */ public static function getPermissions(string $path): ?string { + self::assertDirectLocalPath($path); if (!file_exists($path)) { return null; } @@ -190,6 +200,8 @@ public static function getPermissions(string $path): ?string */ public static function isOwnedByCurrentUser(string $path): bool { + self::assertDirectLocalPath($path); + return self::isPosixSupported() && fileowner($path) === posix_geteuid(); } @@ -199,10 +211,12 @@ public static function isOwnedByCurrentUser(string $path): bool * @param string $path The path to the file or directory to set ownership on. * @param string $owner The username of the new owner. * @param string|null $group The groupname of the new group, or null to leave the group unchanged. - * @throws RuntimeException If the operation fails or if ownership functions are not supported on the current system. + * @throws FileAccessException If the ownership operation fails. + * @throws MissingExtensionException If ownership functions are unavailable. */ public static function setOwnership(string $path, string $owner, ?string $group = null): self { + self::assertDirectLocalPath($path); if (!self::isPosixSupported()) { throw new MissingExtensionException('Ownership operations require ext-posix.'); } @@ -213,7 +227,7 @@ public static function setOwnership(string $path, string $owner, ?string $group } if (!$result) { - throw new RuntimeException("Failed to set ownership on {$path}"); + throw new FileAccessException("Failed to set ownership on {$path}"); } return new self(); @@ -228,17 +242,27 @@ public static function setOwnership(string $path, string $owner, ?string $group * * @param string $path The path to the file or directory to set permissions on. * @param int $permissions The new permissions for the file or directory. - * @throws RuntimeException If the operation fails. + * @throws FileAccessException If the operation fails. */ public static function setPermissions(string $path, int $permissions): self { + self::assertDirectLocalPath($path); if (!chmod($path, $permissions)) { - throw new RuntimeException("Failed to set permissions on {$path}"); + throw new FileAccessException("Failed to set permissions on {$path}"); } return new self(); } + private static function assertDirectLocalPath(string $path): void + { + if (!FlysystemHelper::isLocalPath($path)) { + throw new UnsupportedStorageOperationException( + "Permission operations require a direct-local path: {$path}", + ); + } + } + private static function isPosixSupported(): bool { return function_exists('posix_getpwuid') && function_exists('posix_getgrgid'); diff --git a/src/Utils/ReadablePathLocalizer.php b/src/Utils/ReadablePathLocalizer.php new file mode 100644 index 0000000..687312c --- /dev/null +++ b/src/Utils/ReadablePathLocalizer.php @@ -0,0 +1,85 @@ + $normalized, 'cleanup' => false]; + } + if (!FlysystemHelper::fileExists($normalized)) { + throw self::inaccessible($filename); + } + if (is_string($existingLocalPath) && is_file($existingLocalPath)) { + return ['path' => $existingLocalPath, 'cleanup' => true]; + } + + return ['path' => self::copyToTemporaryFile($normalized, $filename), 'cleanup' => true]; + } + + private static function copyToTemporaryFile(string $source, string $original): string + { + $input = FlysystemHelper::readStream($source); + if (!is_resource($input)) { + throw self::inaccessible($original); + } + $temporary = tempnam(sys_get_temp_dir(), 'pathwise_reader_'); + if ($temporary === false) { + fclose($input); + + throw self::inaccessible($original); + } + $output = fopen($temporary, 'wb'); + if (!is_resource($output)) { + fclose($input); + self::unlinkSilently($temporary); + + throw self::inaccessible($original); + } + + $failure = null; + + try { + if (stream_copy_to_stream($input, $output) === false) { + $failure = self::inaccessible($original); + } + } catch (\Throwable $exception) { + $failure = $exception; + } finally { + fclose($input); + fclose($output); + } + if ($failure instanceof \Throwable) { + self::unlinkSilently($temporary); + + throw $failure; + } + + return PathHelper::normalize($temporary); + } + + private static function inaccessible(string $path): FileAccessException + { + return new FileAccessException("Cannot access file at path: {$path}"); + } + + private static function unlinkSilently(string $path): void + { + set_error_handler(static fn(): bool => true); + + try { + unlink($path); + } finally { + restore_error_handler(); + } + } +} diff --git a/src/Utils/SerializedValueValidator.php b/src/Utils/SerializedValueValidator.php new file mode 100644 index 0000000..f3b6e0f --- /dev/null +++ b/src/Utils/SerializedValueValidator.php @@ -0,0 +1,26 @@ + 256) { + return true; + } + if (is_float($value)) { + return !is_finite($value); + } + if ($value === null || is_bool($value) || is_int($value) || is_string($value)) { + return false; + } + if (!is_array($value)) { + return true; + } + + return array_any($value, static fn(mixed $item): bool => self::containsUnsupportedValue($item, $depth + 1)); + } +} diff --git a/tests/Feature/ArchiveSecurityTest.php b/tests/Feature/ArchiveSecurityTest.php index bc1b293..e9239de 100644 --- a/tests/Feature/ArchiveSecurityTest.php +++ b/tests/Feature/ArchiveSecurityTest.php @@ -115,3 +115,31 @@ ))->toThrow(UnsafeArchiveEntryException::class) ->and(file_exists($this->extractPath . DIRECTORY_SEPARATOR . 'safe.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(); + $zip->addFromString('one.txt', 'one'); + $zip->addFromString('two.txt', 'two'); + $zip->close(); + rmdir($this->extractPath); + + expect(fn () => (new FileCompression($this->archivePath)) + ->setExtractionLimits(maxEntries: 1) + ->decompress($this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class) + ->and(is_dir($this->extractPath))->toBeFalse(); +}); + +test('archive per-entry and compression-ratio limits reject oversized entries', function () { + ($this->writeArchive)('large.txt', str_repeat('A', 4096)); + + expect(fn () => (new FileCompression($this->archivePath)) + ->setExtractionLimits(maxEntryUncompressedBytes: 100) + ->decompress($this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class) + ->and(fn () => (new FileCompression($this->archivePath)) + ->setExtractionLimits(maxCompressionRatio: 1.1) + ->decompress($this->extractPath)) + ->toThrow(UnsafeArchiveEntryException::class); +}); diff --git a/tests/Feature/AuditTrailTest.php b/tests/Feature/AuditTrailTest.php index f2c8b1e..57a718d 100644 --- a/tests/Feature/AuditTrailTest.php +++ b/tests/Feature/AuditTrailTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\Pathwise\Observability\AuditTrail; +use Infocyph\Pathwise\Exceptions\AuditException; use Infocyph\Pathwise\Exceptions\UnsupportedStorageOperationException; use Infocyph\Pathwise\Observability\CallbackAuditSink; use Infocyph\Pathwise\Observability\PartitionedAuditSink; @@ -38,7 +39,7 @@ $stream = fopen('php://temp', 'rb'); try { - expect(fn() => $audit->log('invalid', ['stream' => $stream]))->toThrow(JsonException::class) + expect(fn() => $audit->log('invalid', ['stream' => $stream]))->toThrow(AuditException::class) ->and(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))->toHaveCount(1); } finally { if (is_resource($stream)) { diff --git a/tests/Feature/DirectoryOperationsTest.php b/tests/Feature/DirectoryOperationsTest.php index 56d86b7..3948837 100644 --- a/tests/Feature/DirectoryOperationsTest.php +++ b/tests/Feature/DirectoryOperationsTest.php @@ -264,3 +264,35 @@ function createTempDirectory(): string } } }); + +test('zip destination inside source is excluded from the archive', function () { + $destination = $this->tempDir . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'archive.zip'; + mkdir(dirname($destination), 0755, true); + + (new DirectoryOperations($this->tempDir))->zip($destination); + $zip = new ZipArchive(); + expect($zip->open($destination))->toBeTrue(); + $entries = []; + for ($index = 0; $index < $zip->numFiles; $index++) { + $entries[] = $zip->getNameIndex($index); + } + $zip->close(); + + expect($entries)->not->toContain('nested/archive.zip'); +}); + +test('size and modified-time sync is idempotent when copy does not preserve mtime', function () { + $source = $this->tempDir . DIRECTORY_SEPARATOR . 'mtime-source'; + $target = $this->tempDir . DIRECTORY_SEPARATOR . 'mtime-target'; + mkdir($source); + file_put_contents($source . DIRECTORY_SEPARATOR . 'old.txt', 'same-content'); + touch($source . DIRECTORY_SEPARATOR . 'old.txt', 946684800); + + $operations = new DirectoryOperations($source); + $first = $operations->syncTo($target, false, null, \Infocyph\Pathwise\Core\SyncComparison::SIZE_AND_MODIFIED_TIME); + $second = $operations->syncTo($target, false, null, \Infocyph\Pathwise\Core\SyncComparison::SIZE_AND_MODIFIED_TIME); + + expect($first->created)->toContain('old.txt') + ->and($second->unchanged)->toContain('old.txt') + ->and($second->updated)->toBe([]); +}); diff --git a/tests/Feature/DownloadProcessorTest.php b/tests/Feature/DownloadProcessorTest.php index c4609ec..4ff6f0f 100644 --- a/tests/Feature/DownloadProcessorTest.php +++ b/tests/Feature/DownloadProcessorTest.php @@ -254,3 +254,31 @@ FlysystemHelper::deleteDirectory($defaultRoot); } }); + +test('it models and streams an empty file without a fake byte range', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'empty.txt'; + touch($path); + + $manifest = $this->downloadProcessor->prepareDownload($path); + $output = fopen('php://temp', 'rb+'); + $result = $this->downloadProcessor->streamDownload($path, $output); + fclose($output); + + expect($manifest->status)->toBe(200) + ->and($manifest->range->start)->toBeNull() + ->and($manifest->range->end)->toBeNull() + ->and($manifest->range->contentLength)->toBe(0) + ->and($result->bytesSent)->toBe(0) + ->and(fn () => $this->downloadProcessor->prepareDownload($path, null, 'bytes=0-0')) + ->toThrow(DownloadException::class, 'unsatisfiable'); +}); + +test('it blocks files below a hidden parent directory', function () { + $hiddenDirectory = $this->workingDir . DIRECTORY_SEPARATOR . '.private'; + mkdir($hiddenDirectory); + $path = $hiddenDirectory . DIRECTORY_SEPARATOR . 'report.txt'; + file_put_contents($path, 'hidden-parent'); + + expect(fn () => $this->downloadProcessor->prepareDownload($path)) + ->toThrow(DownloadException::class, 'Hidden file downloads are blocked'); +}); diff --git a/tests/Feature/FileCompressionTest.php b/tests/Feature/FileCompressionTest.php index 9a2ebd0..8f02d77 100644 --- a/tests/Feature/FileCompressionTest.php +++ b/tests/Feature/FileCompressionTest.php @@ -236,3 +236,15 @@ expect(FlysystemHelper::read('zipmnt://dst/a.txt'))->toBe('A') ->and(FlysystemHelper::read('zipmnt://dst/nested/b.txt'))->toBe('B'); }); + +test('it rejects an empty archive password', function () { + expect(fn () => (new FileCompression($this->zipFilePath, true))->setPassword('')) + ->toThrow(CompressionException::class, 'must not be empty'); +}); + +test('create mode replaces stale archive entries', function () { + (new FileCompression($this->zipFilePath, true))->addFile($this->file1, 'stale.txt')->save(); + $fresh = (new FileCompression($this->zipFilePath, true))->addFile($this->file2, 'fresh.txt')->save(); + + expect($fresh->listFiles())->toBe(['fresh.txt']); +}); diff --git a/tests/Feature/FileJobQueueTest.php b/tests/Feature/FileJobQueueTest.php index 00c51ba..e7965ac 100644 --- a/tests/Feature/FileJobQueueTest.php +++ b/tests/Feature/FileJobQueueTest.php @@ -3,6 +3,9 @@ declare(strict_types=1); use Infocyph\Pathwise\Queue\FileJobQueue; +use Infocyph\Pathwise\Utils\FlysystemHelper; +use League\Flysystem\Filesystem; +use League\Flysystem\Local\LocalFilesystemAdapter; beforeEach(function () { $this->queueFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('queue_', true) . '.json'; @@ -12,6 +15,46 @@ if (is_file($this->queueFile)) { unlink($this->queueFile); } + FlysystemHelper::reset(); +}); + +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()]], + 'processing' => [], + 'failed' => [], + ], JSON_THROW_ON_ERROR)); + + expect(fn () => (new FileJobQueue($this->queueFile))->stats()) + ->toThrow(RuntimeException::class, 'malformed job'); +}); + +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') + ->and($queue->enqueue('first'))->toStartWith('job_') + ->and(fn () => $queue->enqueue('second'))->toThrow(RuntimeException::class, 'job-count'); +}); + +test('it rejects mounted and default-filesystem queue paths', function () { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('queue_mount_', true); + mkdir($root); + FlysystemHelper::mount('queue', new Filesystem(new LocalFilesystemAdapter($root))); + + try { + expect(fn () => new FileJobQueue('queue://jobs.json')) + ->toThrow(RuntimeException::class, 'direct-local'); + + FlysystemHelper::setDefaultFilesystem(new Filesystem(new LocalFilesystemAdapter($root))); + expect(fn () => new FileJobQueue('jobs.json')) + ->toThrow(RuntimeException::class, 'direct-local'); + } finally { + FlysystemHelper::reset(); + rmdir($root); + } }); test('it processes queued jobs by priority', function () { diff --git a/tests/Feature/FlysystemHelperTest.php b/tests/Feature/FlysystemHelperTest.php index 2789aa6..570c7f5 100644 --- a/tests/Feature/FlysystemHelperTest.php +++ b/tests/Feature/FlysystemHelperTest.php @@ -116,3 +116,23 @@ expect(fn () => FlysystemHelper::publicUrl($filePath))->toThrow(RuntimeException::class) ->and(fn () => FlysystemHelper::temporaryUrl($filePath, new DateTimeImmutable('+1 hour')))->toThrow(RuntimeException::class); }); + +test('it rejects duplicate and invalid mount names', function () { + $filesystem = new Filesystem(new LocalFilesystemAdapter($this->helperDir)); + FlysystemHelper::mount('valid-name', $filesystem); + + expect(fn () => FlysystemHelper::mount('valid-name', $filesystem))->toThrow(InvalidArgumentException::class) + ->and(fn () => FlysystemHelper::mount('../invalid', $filesystem))->toThrow(InvalidArgumentException::class) + ->and(fn () => FlysystemHelper::mount('', $filesystem))->toThrow(InvalidArgumentException::class); +}); + +test('it rejects directory copies and moves into their own subtree', function () { + $source = $this->helperDir . DIRECTORY_SEPARATOR . 'source'; + FlysystemHelper::createDirectory($source); + FlysystemHelper::write($source . DIRECTORY_SEPARATOR . 'a.txt', 'A'); + + expect(fn () => FlysystemHelper::copyDirectory($source, $source . DIRECTORY_SEPARATOR . 'copy')) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => FlysystemHelper::moveDirectory($source, $source . DIRECTORY_SEPARATOR . 'move')) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/MetadataHelperTest.php b/tests/Feature/MetadataHelperTest.php index dc9ab95..8a89309 100644 --- a/tests/Feature/MetadataHelperTest.php +++ b/tests/Feature/MetadataHelperTest.php @@ -208,7 +208,6 @@ 'mime_type', 'type', 'ownership', - 'last_modified_by', 'extension', 'is_hidden', 'symlink_target', diff --git a/tests/Feature/PathHelperTest.php b/tests/Feature/PathHelperTest.php index ba5b0d1..76dcfb2 100644 --- a/tests/Feature/PathHelperTest.php +++ b/tests/Feature/PathHelperTest.php @@ -76,9 +76,9 @@ expect(PathHelper::relativePath('/var/www/html', '/var/www/assets/css/style.css'))->toBe('..' . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'css' . DIRECTORY_SEPARATOR . 'style.css'); }); -// Test PathHelper::sanitize -test('it sanitizes a path', function () { - expect(PathHelper::sanitize('invalid/path*with|characters'))->toBe('invalid/pathwithcharacters'); +test('it sanitizes a single path segment', function () { + expect(PathHelper::sanitizeSegment('path*with|characters'))->toBe('pathwithcharacters') + ->and(fn() => PathHelper::sanitizeSegment('invalid/path'))->toThrow(InvalidArgumentException::class); }); // Test PathHelper::createDirectory and PathHelper::deleteDirectory diff --git a/tests/Feature/SafeFileReaderTest.php b/tests/Feature/SafeFileReaderTest.php index ed1d17e..8a2ed9f 100644 --- a/tests/Feature/SafeFileReaderTest.php +++ b/tests/Feature/SafeFileReaderTest.php @@ -73,7 +73,7 @@ }); test('it applies and releases lock on file', function () { - $reader = new SafeFileReader($this->tempFilePath, 'r', true); + $reader = new SafeFileReader($this->tempFilePath, 'r', LOCK_EX); expect($reader)->toBeInstanceOf(SafeFileReader::class); $reader->releaseLock(); expect(true)->toBeTrue(); // Just verifies the lock was applied and released without issue @@ -142,3 +142,23 @@ expect($lines)->toBe(['A', 'B']); }); + +test('character and chunk readers have exact EOF semantics', function (string $contents, int $chunkSize, array $expectedChunks) { + file_put_contents($this->tempFilePath, $contents); + $reader = new SafeFileReader($this->tempFilePath); + + expect(iterator_to_array($reader->characters(), false))->toBe(str_split($contents)) + ->and(iterator_to_array($reader->chunks($chunkSize), false))->toBe($expectedChunks); +})->with([ + 'empty' => ['', 4, []], + 'one byte' => ['A', 4, ['A']], + 'exact boundary' => ['ABCD', 4, ['ABCD']], + 'boundary plus one' => ['ABCDE', 4, ['ABCD', 'E']], +]); + +test('it rejects invalid matching-line regex and non-positive fixed widths', function () { + $reader = new SafeFileReader($this->tempFilePath); + + expect(fn () => $reader->matchingLines('['))->toThrow(InvalidArgumentException::class) + ->and(fn () => $reader->fixedWidth([0]))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/SafeFileWriterTest.php b/tests/Feature/SafeFileWriterTest.php index 64e358e..aa87d9e 100644 --- a/tests/Feature/SafeFileWriterTest.php +++ b/tests/Feature/SafeFileWriterTest.php @@ -202,3 +202,18 @@ expect($normalizedContent)->toBe("hello\n"); }); + +test('matching-line writer returns zero for no match and rejects invalid regex', function () { + $writer = new SafeFileWriter($this->tempFilePath); + + expect($writer->writeMatchingLine('hello', '/world/'))->toBe(0) + ->and($writer->count())->toBe(0) + ->and(fn () => $writer->writeMatchingLine('hello', '['))->toThrow(FileAccessException::class); +}); + +test('fixed-width and serialized writers reject unsafe values', function () { + $writer = new SafeFileWriter($this->tempFilePath); + + expect(fn () => $writer->writeFixedWidth(['x'], [0]))->toThrow(FileAccessException::class) + ->and(fn () => $writer->writeSerialized((object) ['x' => 1]))->toThrow(FileAccessException::class); +}); diff --git a/tests/Feature/StorageFactoryTest.php b/tests/Feature/StorageFactoryTest.php index 7473822..ba12f3a 100644 --- a/tests/Feature/StorageFactoryTest.php +++ b/tests/Feature/StorageFactoryTest.php @@ -201,3 +201,49 @@ expect($filesystem->read('memory.txt'))->toBe('memory-data'); }); + +test('it rejects conflicting configuration modes and malformed options', function () { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_conflict_', true); + mkdir($root); + $adapter = new LocalFilesystemAdapter($root); + + try { + expect(fn () => StorageFactory::createFilesystem(['adapter' => $adapter, 'driver' => 'local', 'root' => $root])) + ->toThrow(InvalidArgumentException::class, 'exactly one') + ->and(fn () => StorageFactory::createFilesystem(['adapter' => $adapter, 'options' => ['bad']])) + ->toThrow(InvalidArgumentException::class, 'keys must be strings'); + } finally { + 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/UploadProcessorTest.php b/tests/Feature/UploadProcessorTest.php index 7b72012..8d1ac08 100644 --- a/tests/Feature/UploadProcessorTest.php +++ b/tests/Feature/UploadProcessorTest.php @@ -91,7 +91,7 @@ 'name' => 'plain.txt', ]; - expect(fn() => $this->uploadProcessor->processUpload($file)) + expect(fn() => $this->uploadProcessor->ingestFile($file)) ->toThrow(UploadException::class, 'Invalid file format'); } finally { if (file_exists($tmpFile)) { @@ -185,7 +185,7 @@ 'name' => 'payload.php', ]; - expect(fn() => $this->uploadProcessor->processUpload($file)) + expect(fn() => $this->uploadProcessor->ingestFile($file)) ->toThrow(UploadException::class, 'Blocked file extension'); } finally { if (file_exists($tmpFile)) { @@ -209,7 +209,7 @@ 'name' => 'sample.txt', ]; - expect(fn() => $this->uploadProcessor->processUpload($file)) + expect(fn() => $this->uploadProcessor->ingestFile($file)) ->toThrow(UploadException::class, 'Malware scanner is required but not configured'); } finally { if (file_exists($tmpFile)) { @@ -233,7 +233,7 @@ 'name' => 'avatar.png', ]; - expect(fn() => $this->uploadProcessor->processUpload($file)) + expect(fn() => $this->uploadProcessor->ingestFile($file)) ->toThrow(UploadException::class, 'File content type does not match extension'); } finally { if (file_exists($tmpFile)) { @@ -319,7 +319,7 @@ file_put_contents($tmpFile, 'mounted-upload-content'); try { - $destination = $this->uploadProcessor->processUpload([ + $destination = $this->uploadProcessor->ingestFile([ 'error' => UPLOAD_ERR_OK, 'size' => filesize($tmpFile), 'tmp_name' => $tmpFile, @@ -395,7 +395,7 @@ file_put_contents($tmpFile, 'default-filesystem-content'); try { - $destination = $this->uploadProcessor->processUpload([ + $destination = $this->uploadProcessor->ingestFile([ 'error' => UPLOAD_ERR_OK, 'size' => filesize($tmpFile), 'tmp_name' => $tmpFile, @@ -414,3 +414,59 @@ FlysystemHelper::deleteDirectory($defaultRoot); } }); + +test('HTTP upload API rejects an ordinary local temporary file', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir); + $tmpFile = tempnam(sys_get_temp_dir(), 'pathwise_not_http_'); + file_put_contents($tmpFile, 'ordinary-file'); + + try { + expect(fn () => $this->uploadProcessor->processUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpFile), + 'tmp_name' => $tmpFile, + 'name' => 'ordinary.txt', + ]))->toThrow(UploadException::class, 'not a valid HTTP upload'); + } finally { + if (is_file($tmpFile)) { + unlink($tmpFile); + } + } +}); + +test('chunk finalization uses the assembled content hash deterministically', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir, false, $this->uploadDir); + $this->uploadProcessor->setValidationSettings(['text/plain'], 1024); + $uploadId = 'deterministic_hash'; + foreach (['assembled-', 'content'] as $index => $contents) { + $part = $this->uploadDir . DIRECTORY_SEPARATOR . "hash_{$index}.part"; + file_put_contents($part, $contents); + $this->uploadProcessor->processChunkUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($part), + 'tmp_name' => $part, + 'name' => basename($part), + ], $uploadId, $index, 2, 'payload.txt'); + } + + $destination = $this->uploadProcessor->finalizeChunkUpload($uploadId); + expect(basename($destination))->toBe('upload_' . hash('sha256', 'assembled-content') . '.txt'); +}); + +test('failed finalization preserves chunks for a retry and removes assembly output', function () { + $this->uploadProcessor->setDirectorySettings($this->uploadDir, false, $this->uploadDir); + $part = $this->uploadDir . DIRECTORY_SEPARATOR . 'retry.part'; + file_put_contents($part, 'not-a-png'); + $this->uploadProcessor->processChunkUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($part), + 'tmp_name' => $part, + 'name' => 'retry.part', + ], 'retry_session', 0, 1, 'image.png'); + + expect(fn () => $this->uploadProcessor->finalizeChunkUpload('retry_session')) + ->toThrow(UploadException::class) + ->and(is_file($this->uploadDir . DIRECTORY_SEPARATOR . 'pathwise_chunks' + . DIRECTORY_SEPARATOR . 'retry_session' . DIRECTORY_SEPARATOR . 'chunk_000000.part'))->toBeTrue() + ->and(glob($this->uploadDir . DIRECTORY_SEPARATOR . 'upload_*.png'))->toBe([]); +}); From e854691db321c3c720b00b05c6d8122bf7f016fb Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 13 Aug 2026 20:47:07 +0600 Subject: [PATCH 2/2] updated & fixed ops/tech issues --- tests/Feature/DirectoryOperationsTest.php | 14 +++++++------- tests/Feature/MetadataHelperTest.php | 8 +++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/Feature/DirectoryOperationsTest.php b/tests/Feature/DirectoryOperationsTest.php index 3948837..eecbe60 100644 --- a/tests/Feature/DirectoryOperationsTest.php +++ b/tests/Feature/DirectoryOperationsTest.php @@ -182,16 +182,16 @@ function createTempDirectory(): string file_put_contents($file1, 'file one'); file_put_contents($file2, 'file two'); - // Set permissions compatible across platforms - if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') { + $criteria = [ + 'name' => basename($file1), + 'extension' => 'txt', + ]; + if (PHP_OS_FAMILY !== 'Windows') { chmod($file1, 0644); + $criteria['permissions'] = 0644; } - $foundFiles = $this->directoryOperations->find([ - 'name' => basename($file1), - 'extension' => 'txt', - 'permissions' => 0644, - ]); + $foundFiles = $this->directoryOperations->find($criteria); $foundFiles = array_map(fn($path) => realpath($path), $foundFiles); diff --git a/tests/Feature/MetadataHelperTest.php b/tests/Feature/MetadataHelperTest.php index 8a89309..925a821 100644 --- a/tests/Feature/MetadataHelperTest.php +++ b/tests/Feature/MetadataHelperTest.php @@ -159,7 +159,13 @@ test('it retrieves last modified by user', function () { $lastModifiedBy = MetadataHelper::getLastModifiedBy($this->tempFilePath); - expect($lastModifiedBy)->toBeString()->not->toBeNull(); + if (PHP_OS_FAMILY === 'Windows') { + expect($lastModifiedBy)->toBeNull(); + + return; + } + + expect($lastModifiedBy)->toBeString(); }); test('it retrieves file extension', function () {