diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 268fb2db..b7d0995e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,57 @@ jobs: } - name: Validate SPK13 icon integration safeguards run: php tests/WindowsSpk13IconIntegrationTest.php + + windows-smoke: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: sodium,zip,pdo_mysql,mbstring,intl,dom,xml + coverage: none + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Validate and install locked Composer dependencies + shell: pwsh + run: | + composer validate --strict --no-check-publish + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + composer install --no-interaction --prefer-dist + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + composer audit --no-interaction + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: PHP syntax check + shell: pwsh + run: | + Get-ChildItem app,bin,public,tests -Recurse -File -Filter *.php | ForEach-Object { + php -l $_.FullName + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: Runtime self-check and source audit + shell: pwsh + run: | + php bin/self-check.php + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + php bin/source-audit.php + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: V14 package round-trip and restore safety + shell: pwsh + run: | + php tests/V14PackageRoundTripTest.php + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + php tests/V14RestoreSafetyTest.php + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + php tests/V14PackageArchiveTest.php + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Browser V13 automated round-trip + shell: pwsh + run: node tests/BrowserV13RoundTripTest.mjs + - name: Release signer external trust-root test + shell: pwsh + run: php tests/ReleaseSignerExternalTrustTest.php diff --git a/.github/workflows/refresh-composer-lock.yml b/.github/workflows/refresh-composer-lock.yml index 5ae1d7f7..55e49f12 100644 --- a/.github/workflows/refresh-composer-lock.yml +++ b/.github/workflows/refresh-composer-lock.yml @@ -32,6 +32,12 @@ jobs: continue-on-error: true run: composer audit --no-interaction + - name: Fail the workflow when dependency audit fails + if: steps.audit.outcome == 'failure' + run: | + echo "::error::composer audit failed. Review and resolve or document dependency findings before merging." + exit 1 + - name: Open a pull request with the generated lockfile uses: peter-evans/create-pull-request@v7 with: @@ -45,7 +51,7 @@ jobs: ## Summary - Generate `composer.lock` using Composer in a clean GitHub Actions runner. - Validate the manifest and lockfile with `composer validate --strict --no-check-publish`. - - Run `composer audit --no-interaction`; inspect the workflow result before merging. + - Run `composer audit --no-interaction`; this workflow fails before opening a PR if the audit step fails (including an audit command marked continue-on-error for outcome reporting). ## Review checklist - [ ] Confirm the lockfile was generated by Composer (not hand-written). diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fe31551c..fd7977d5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -25,6 +25,14 @@ jobs: run: | find app bin public tests -type f -name '*.php' -print0 | xargs -0 -n1 php -l + - name: Require Composer lockfile for release-readiness branches + if: github.head_ref == 'release/v1.0.0-readiness' || github.base_ref == 'release/v1.0.0-readiness' || startsWith(github.head_ref, 'work/v1.0.0-reconciliation-') || startsWith(github.ref_name, 'work/v1.0.0-reconciliation-') + run: | + if [ ! -f composer.lock ]; then + echo "::error::A Composer-generated composer.lock is required for release-readiness changes." + exit 1 + fi + - name: Composer manifest validation run: composer validate --no-check-publish @@ -92,5 +100,8 @@ jobs: - name: Source security audit run: php bin/source-audit.php + - name: Full regression and security suite + run: php bin/security-test.php --full + - name: Legacy browser V13 round-trip run: node tests/BrowserV13RoundTripTest.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d92b991d..2249384b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ # Changelog -## Unreleased — MVP readiness and repository maintenance +## Unreleased — v1.0.0 readiness, API reliability, and storage hardening + +- Put API configuration loading and service initialization inside the generic error boundary; typed authentication failures now return HTTP 401, and missing resources are separated from storage/programming failures. +- Added typed archive-step conflicts, rejected archive/package input, and storage faults so malformed input can return a documented 4xx while I/O failures remain generic 500 responses. +- Hardened restore rollback, controller path cleanup, portable archive publication, and resumable-upload deletion with ownership checks, no-overwrite publication, post-cleanup verification, and surfaced cleanup errors. +- Verified cleanup of incomplete V14 package builds and added a regression ensuring a failed build leaves no encrypted-package artifacts behind. +- Made catalog listings fail closed on malformed JSON, invalid records, and symlinked records instead of silently presenting an incomplete inventory. +- Ensured missing and unauthorized package lookups use the same privacy-preserving catalog response, and exposed the legacy server package format explicitly in runtime diagnostics. +- Added regression coverage for runtime version metadata consistency and the diagnostics package-format contract. +- Added HTTP regressions for configuration failure privacy, method Allow headers, authentication, missing job/package privacy, catalog failure, and restore storage failure. +- Added regressions for corrupt ZIPs, archive traversal, existing-output preservation, catalog listing integrity, controller cleanup, and resumable-upload symlink safety. +- Separated V14 authentication/stream rejection from storage errors while preserving atomic link-based no-replace publication; temporary file identities and restrictive modes are checked before publication. +- Added the Composer-generated lockfile to the release-readiness work branch and made the manual lock refresh workflow fail before creating a PR when dependency audit fails. +- Ported the Windows source-audit self-path normalization and strengthened release-signing input/write verification. +- Reconciled the API error contract and reproducible QA matrix; manual Windows/XAMPP, real-browser, independently trusted signed-artifact, and independent security-review gates remain NOT RUN. +- Kept the stable binary package format isolated in PR #38; it is not the default production writer and is not yet approved for v1.0.0. +- Added a Windows CI smoke job for locked Composer install/audit, PHP syntax, self-check, source audit, V14 restore/archive safety, browser V13 compatibility, and external-trust-root signer verification. +- Added a release-readiness lockfile gate and full regression/security suite to the Security and Release Integrity workflow. +- Fixed a false-positive restore-safety test so an existing destination is accepted as rejected only when the expected typed storage exception is thrown, and verified its content remains unchanged. +- Documented V14/stable-format metadata leakage, offline password-and-pattern guessing, sender-authenticity limits, and the explicit legacy-reader/migration policy. - Add an executable release-signing regression that verifies Ed25519 signatures against an independently pinned external trust root. - Fail closed when the audit hash-chain head is missing or corrupt, verify chain integrity before appending, and redact sensitive audit context keys by normalized fragments. diff --git a/README.md b/README.md index a036a714..99d6b289 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,16 @@ After a real root `composer.lock` has been generated with Composer and committed Before a release, work through the [reproducible release QA matrix](docs/RELEASE-QA.md). It separates CI evidence from manual Windows/XAMPP and real-browser checks; manual scenarios must remain NOT RUN until tested. +## Security limitations and exposed metadata + +Encryption does not hide every property of a V14 package. The readable package header/completion records expose the format and generation identifiers, random package ID, creation time, cryptographic/KDF profile, whether a recovery slot exists, and integrity-binding values. ZIP entry structure and encrypted blob lengths can reveal the approximate number and sizes of files and the total package size. The encrypted manifest protects filenames and directory relationships; it does not make the entire package indistinguishable from random data of a fixed size. + +A copied package can be attacked offline: an attacker can test password-and-pattern guesses against the wrapped key slot without contacting the application. Argon2id raises the cost of each guess but cannot prevent offline guessing. Use a long, unique password and a separate, non-reused pattern. Web rate limits do not protect a package after an attacker obtains a copy. A lost password/pattern or lost recovery key cannot be recovered by the project. + +Package encryption also does not prove who sent a package. Use HTTPS, SFTP, or another trusted transfer channel; when sender identity or artifact substitution matters, verify a checksum or signature through an independently trusted channel. Do not rely on a public key embedded only inside the release artifact as the sole trust root. + +The version-independent single-file binary format is still a separately reviewed candidate. Until it is approved and integrated, V14 remains the active server writer and browser V13 remains a separate compatibility format. Do not assume that a legacy package has been converted merely because the application version changes. + ## Security philosophy The application treats cryptography, parsing, filesystem handling, authentication, resource limits, and supply-chain integrity as one security boundary. A stronger cipher alone does not make the system secure; every step between untrusted input and plaintext restore must fail closed. diff --git a/app/Api/PackageController.php b/app/Api/PackageController.php index 950bd632..d9c30e4a 100644 --- a/app/Api/PackageController.php +++ b/app/Api/PackageController.php @@ -6,13 +6,19 @@ use InvalidArgumentException; use RuntimeException; use SecurePackage\Archive\ArchivePolicy; +use SecurePackage\Archive\ArchiveProcessingException; +use SecurePackage\Archive\ArchiveStepConflictException; use SecurePackage\Archive\ArchiveWorkflow; use SecurePackage\Project\PackageArchive; +use SecurePackage\Project\PackageNotAvailableException; +use SecurePackage\Project\PackageRestoreRejectedException; +use SecurePackage\Project\PackageStorageException; use SecurePackage\Project\PackageDeletionTransaction; use SecurePackage\Project\PackageCatalog; use SecurePackage\Project\V14PackageReader; use SecurePackage\Project\V14PackageService; use SecurePackage\Security\AccessContext; +use SecurePackage\Security\AuthenticationRequiredException; use SecurePackage\Security\ApplicationSecrets; use SecurePackage\Security\AccountStore; use SecurePackage\Security\DuplicateAccountException; @@ -27,6 +33,7 @@ use SecurePackage\Storage\AuditLogger; use SecurePackage\Storage\FileStore; use SecurePackage\Storage\JobStore; +use SecurePackage\Storage\JobNotAvailableException; use SecurePackage\Storage\ResumableUploadStore; use SecurePackage\Storage\QuotaExceededException; use SecurePackage\Storage\UploadException; @@ -214,11 +221,20 @@ public function archiveStep(): never $this->rateLimiter->success($ipRateKey); sodium_memzero($password); JsonResponse::send(['ok' => true, 'state' => $state]); - } catch (\Throwable) { + } catch (JobNotAvailableException) { + sodium_memzero($password); + JsonResponse::send(['ok' => false, 'error' => 'Archive job was not found.'], 404); + } catch (ArchiveStepConflictException) { + sodium_memzero($password); + JsonResponse::send(['ok' => false, 'error' => 'Archive step is no longer available.'], 409); + } catch (ArchiveProcessingException) { sodium_memzero($password); JsonResponse::send(['ok' => false, 'error' => 'Unable to open archive with the supplied password.'], 422); + } catch (\Throwable $exception) { + sodium_memzero($password); + throw $exception; } - } catch (\Throwable) { + } catch (JobNotAvailableException) { sodium_memzero($password); JsonResponse::send(['ok' => false, 'error' => 'Archive job was not found.'], 404); } @@ -271,20 +287,33 @@ public function build(): never 'recovery_key' => $result['recovery_key'] ?? null, 'format' => 'SECURE-PKG-V14', ], 201); - } catch (\Throwable) { - // Never discard the only plaintext source copy when packaging fails. If catalog - // creation did not complete, remove incomplete encrypted artifacts; once catalogued, - // retain the package so a transient token/audit failure cannot destroy user data. - if (!$packageCreated) { - if (is_string($portableArchive)) { - try { $this->removePath($portableArchive); } catch (\Throwable) {} - } - if (is_string($packageDirectory)) { - try { $this->removePath($packageDirectory); } catch (\Throwable) {} - } + } catch (JobNotAvailableException $exception) { + try { + $this->cleanupFailedBuild($portableArchive, $packageDirectory, $packageCreated); + } finally { + sodium_memzero($password); + sodium_memzero($pattern); + } + if ($packageCreated) { + throw $exception; + } + JsonResponse::send(['ok' => false, 'error' => 'Archive job was not found.'], 404); + } catch (InvalidArgumentException) { + try { + $this->cleanupFailedBuild($portableArchive, $packageDirectory, $packageCreated); + } finally { + sodium_memzero($password); + sodium_memzero($pattern); } - sodium_memzero($password); sodium_memzero($pattern); JsonResponse::send(['ok' => false, 'error' => 'Unable to build package.'], 422); + } catch (\Throwable $exception) { + try { + $this->cleanupFailedBuild($portableArchive, $packageDirectory, $packageCreated); + } finally { + sodium_memzero($password); + sodium_memzero($pattern); + } + throw $exception; } } @@ -317,7 +346,7 @@ public function decrypt(): never $portable = $packageDir . '.spkg14'; try { if (!is_dir($packageDir)) { - if (!is_file($portable)) throw new RuntimeException('Package does not exist.'); + if (!is_file($portable)) throw new PackageStorageException('Encrypted package data is missing.'); $temporaryPackageDir = $this->config['storage']['temp'] . DIRECTORY_SEPARATOR . 'package-' . bin2hex(random_bytes(16)); (new PackageArchive())->extract($portable, $temporaryPackageDir); $packageDir = $temporaryPackageDir; @@ -325,7 +354,12 @@ public function decrypt(): never $outputDir = $this->config['storage']['temp'] . DIRECTORY_SEPARATOR . 'restore-' . bin2hex(random_bytes(16)); $result = (new V14PackageReader())->restore($packageDir, $outputDir, $password, $pattern, $recoveryKey); } finally { - if ($temporaryPackageDir !== null) $this->removePath($temporaryPackageDir); + if ($temporaryPackageDir !== null) { + $this->removePath($temporaryPackageDir); + if (file_exists($temporaryPackageDir) || is_link($temporaryPackageDir)) { + throw new PackageStorageException('Unable to clean temporary package extraction.'); + } + } } $tokenStore = new RestoreTokenStore($this->config['storage']['temp'] . DIRECTORY_SEPARATOR . 'restore-tokens', (int) $this->config['limits']['restore_ttl_seconds']); $restoreToken = $tokenStore->issue($outputDir); @@ -335,13 +369,18 @@ public function decrypt(): never sodium_memzero($password); sodium_memzero($pattern); if (is_string($recoveryKey)) sodium_memzero($recoveryKey); JsonResponse::send(['ok' => true, 'restore_download' => 'download-restored.php?token=' . rawurlencode($restoreToken), 'stats' => $result]); - } catch (\Throwable) { - // A failed restore can leave partially or fully decrypted files behind. - // Remove them before returning an error; only successful responses expose a restore token. - if (is_string($outputDir)) $this->removePath($outputDir); - sodium_memzero($password); sodium_memzero($pattern); - if (is_string($recoveryKey)) sodium_memzero($recoveryKey); + } catch (PackageNotAvailableException) { + $this->cleanupFailedRestore($outputDir, $password, $pattern, $recoveryKey); + JsonResponse::send(['ok' => false, 'error' => 'Package not available.'], 404); + } catch (PackageRestoreRejectedException|InvalidArgumentException) { + $this->cleanupFailedRestore($outputDir, $password, $pattern, $recoveryKey); JsonResponse::send(['ok' => false, 'error' => 'Unable to open package.'], 422); + } catch (PackageStorageException $exception) { + $this->cleanupFailedRestore($outputDir, $password, $pattern, $recoveryKey); + throw $exception; + } catch (\Throwable $exception) { + $this->cleanupFailedRestore($outputDir, $password, $pattern, $recoveryKey); + throw $exception; } } @@ -380,10 +419,12 @@ public function accessToken(): never try { $this->authorization->requireAuthenticated(); $record = $this->catalog->assertOwner($packageId, AccessContext::ownerId()); - if (($record['revoked_at'] ?? null) !== null) throw new RuntimeException('Package revoked.'); + if (($record['revoked_at'] ?? null) !== null || (($record['expires_at'] ?? null) !== null && (int) $record['expires_at'] <= time())) throw new PackageNotAvailableException(); $token = $this->accessTokens->issue($packageId, AccessContext::ownerId(), false); JsonResponse::send(['ok' => true, 'download' => 'download.php?token=' . rawurlencode($token)]); - } catch (\Throwable) { + } catch (AuthenticationRequiredException $exception) { + throw $exception; + } catch (PackageNotAvailableException) { JsonResponse::send(['ok' => false, 'error' => 'Package not available.'], 404); } } @@ -398,8 +439,10 @@ public function setExpiry(): never $this->catalog->setExpiry($packageId, AccessContext::ownerId(), $expiresAt); $this->audit->event('package.expiry_changed', ['package_id' => $packageId, 'account_id' => AccessContext::accountId()]); JsonResponse::send(['ok' => true]); - } catch (\Throwable) { - JsonResponse::send(['ok' => false, 'error' => 'Unable to update package expiration.'], 404); + } catch (AuthenticationRequiredException $exception) { + throw $exception; + } catch (PackageNotAvailableException) { + JsonResponse::send(['ok' => false, 'error' => 'Package not available.'], 404); } } @@ -413,8 +456,10 @@ public function revoke(): never $this->accessTokens->revokeForPackage($packageId); $this->audit->event('package.revoked', ['package_id' => $packageId, 'account_id' => AccessContext::accountId()]); JsonResponse::send(['ok' => true]); - } catch (\Throwable) { - JsonResponse::send(['ok' => false, 'error' => 'Unable to revoke package.'], 404); + } catch (AuthenticationRequiredException $exception) { + throw $exception; + } catch (PackageNotAvailableException) { + JsonResponse::send(['ok' => false, 'error' => 'Package not available.'], 404); } } @@ -441,8 +486,10 @@ public function delete(): never $transaction->cleanupCommitted($staged, fn(string $stagedPath) => $this->removePath($stagedPath)); $this->audit->event('package.deleted', ['package_id' => $packageId, 'account_id' => AccessContext::accountId()]); JsonResponse::send(['ok' => true]); - } catch (\Throwable) { - JsonResponse::send(['ok' => false, 'error' => 'Unable to delete package.'], 404); + } catch (AuthenticationRequiredException $exception) { + throw $exception; + } catch (PackageNotAvailableException) { + JsonResponse::send(['ok' => false, 'error' => 'Package not available.'], 404); } } @@ -462,6 +509,52 @@ public function securityStatus(): never ]); } + private function cleanupFailedRestore(?string $outputDir, string &$password, string &$pattern, ?string &$recoveryKey): void + { + try { + if (is_string($outputDir)) { + try { + $this->removePath($outputDir); + } catch (\Throwable $exception) { + throw new PackageStorageException('Unable to clean incomplete restore output.', 0, $exception); + } + if (file_exists($outputDir) || is_link($outputDir)) { + throw new PackageStorageException('Unable to clean incomplete restore output.'); + } + } + } finally { + sodium_memzero($password); + sodium_memzero($pattern); + if (is_string($recoveryKey)) sodium_memzero($recoveryKey); + } + } + + private function cleanupFailedBuild(?string $portableArchive, ?string $packageDirectory, bool $packageCreated): void + { + if ($packageCreated) { + return; + } + + $cleanupFailed = false; + foreach ([$portableArchive, $packageDirectory] as $path) { + if (!is_string($path)) { + continue; + } + try { + $this->removePath($path); + } catch (\Throwable) { + $cleanupFailed = true; + } + if (file_exists($path) || is_link($path)) { + $cleanupFailed = true; + } + } + + if ($cleanupFailed) { + throw new PackageStorageException('Unable to clean incomplete package artifacts.'); + } + } + private function removeJobDirectory(string $jobId): void { $path = $this->jobs->path($jobId); @@ -473,15 +566,101 @@ private function removeJobDirectory(string $jobId): void private function removePath(string $path): void { - if (is_file($path) || is_link($path)) { @unlink($path); return; } - if (!is_dir($path)) return; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); - foreach ($iterator as $item) { - $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat)) { + if (file_exists($path) || is_link($path)) { + throw new PackageStorageException('Unable to inspect cleanup target.'); + } + return; + } + + $mode = ((int) ($stat['mode'] ?? 0)) & 0170000; + $identity = [ + 'dev' => (int) ($stat['dev'] ?? -1), + 'ino' => (int) ($stat['ino'] ?? 0), + 'mode' => $mode, + ]; + + if (is_link($path) || $mode !== 0040000) { + clearstatcache(true, $path); + $current = @lstat($path); + if (!is_array($current) + || (int) ($current['dev'] ?? -2) !== $identity['dev'] + || (int) ($current['ino'] ?? -1) !== $identity['ino'] + || ((((int) ($current['mode'] ?? 0)) & 0170000) !== $mode)) { + throw new PackageStorageException('Cleanup target identity changed before deletion.'); + } + if (!@unlink($path) && (file_exists($path) || is_link($path))) { + throw new PackageStorageException('Unable to remove cleanup file.'); + } + clearstatcache(true, $path); + if (file_exists($path) || is_link($path)) { + throw new PackageStorageException('Cleanup file remains after deletion.'); + } + return; + } + + $assertRoot = static function () use ($path, $identity): void { + clearstatcache(true, $path); + $current = @lstat($path); + if (!is_array($current) + || is_link($path) + || (((int) ($current['mode'] ?? 0) & 0170000) !== 0040000) + || (int) ($current['dev'] ?? -2) !== $identity['dev'] + || ((int) ($identity['ino'] ?? 0) !== 0 + && (int) ($current['ino'] ?? 0) !== $identity['ino'])) { + throw new PackageStorageException('Cleanup directory identity changed.'); + } + }; + + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $assertRoot(); + $itemPath = $item->getPathname(); + clearstatcache(true, $itemPath); + $itemStat = @lstat($itemPath); + if (!is_array($itemStat)) { + if (file_exists($itemPath) || is_link($itemPath)) { + throw new PackageStorageException('Unable to inspect cleanup entry.'); + } + continue; + } + $itemMode = ((int) ($itemStat['mode'] ?? 0)) & 0170000; + if ($itemMode === 0040000 && !is_link($itemPath)) { + if (!@rmdir($itemPath) && is_dir($itemPath)) { + throw new PackageStorageException('Unable to remove cleanup directory.'); + } + } else { + if (!@unlink($itemPath) && (file_exists($itemPath) || is_link($itemPath))) { + throw new PackageStorageException('Unable to remove cleanup entry.'); + } + } + clearstatcache(true, $itemPath); + if (file_exists($itemPath) || is_link($itemPath)) { + throw new PackageStorageException('Cleanup entry remains after deletion.'); + } + } + } catch (\Throwable $exception) { + if ($exception instanceof PackageStorageException) throw $exception; + throw new PackageStorageException('Unable to enumerate cleanup directory.', 0, $exception); + } + + $assertRoot(); + if (!@rmdir($path) && is_dir($path)) { + throw new PackageStorageException('Unable to remove cleanup directory.'); + } + clearstatcache(true, $path); + if (file_exists($path) || is_link($path)) { + throw new PackageStorageException('Cleanup directory remains after deletion.'); } - @rmdir($path); } + private function makePackageService(): V14PackageService { $pepper = ApplicationSecrets::namePepper($this->config['storage']['root']); diff --git a/app/Archive/ArchivePolicy.php b/app/Archive/ArchivePolicy.php index a1f2a62a..9c74210d 100644 --- a/app/Archive/ArchivePolicy.php +++ b/app/Archive/ArchivePolicy.php @@ -22,23 +22,23 @@ public function __construct( public function assertSafePath(string $path): void { if ($path === '' || strlen($path) > $this->maxPathLength || str_contains($path, "\0")) { - throw new RuntimeException('Unsafe archive path.'); + throw new ArchiveProcessingException('Unsafe archive path.'); } $normalized = str_replace('\\', '/', $path); if (str_starts_with($normalized, '/') || preg_match('/^[A-Za-z]:\//', $normalized)) { - throw new RuntimeException('Unsafe archive path.'); + throw new ArchiveProcessingException('Unsafe archive path.'); } $segments = explode('/', $normalized); if (end($segments) === '') array_pop($segments); foreach ($segments as $segment) { if ($segment === '..' || $segment === '' || $segment === '.') { - throw new RuntimeException('Unsafe archive path.'); + throw new ArchiveProcessingException('Unsafe archive path.'); } if (strlen($segment) > 255 || preg_match('/[\x00-\x1F\x7F]/', $segment) === 1 || str_contains($segment, ':') || str_ends_with($segment, '.') || str_ends_with($segment, ' ')) { - throw new RuntimeException('Unsafe archive path.'); + throw new ArchiveProcessingException('Unsafe archive path.'); } if (preg_match('/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i', $segment) === 1) { - throw new RuntimeException('Unsafe archive path.'); + throw new ArchiveProcessingException('Unsafe archive path.'); } } } diff --git a/app/Archive/ArchiveProcessingException.php b/app/Archive/ArchiveProcessingException.php new file mode 100644 index 00000000..cd4009a1 --- /dev/null +++ b/app/Archive/ArchiveProcessingException.php @@ -0,0 +1,11 @@ +jobs->writeState($job['id'], $state); return ['job_id' => $job['id'], 'state' => $this->publicState($state)]; } catch (\Throwable $error) { - try { $this->jobs->remove($job['id']); } catch (\Throwable) {} + try { + $this->jobs->remove($job['id']); + } catch (\Throwable $cleanupError) { + throw new RuntimeException('Unable to clean failed archive initialization.', 0, $cleanupError); + } throw $error; } } @@ -60,10 +64,10 @@ public function process(string $jobId, string $archiveId, string $password): arr } } if ($pendingIndex === null) { - throw new RuntimeException('Archive is not pending.'); + throw new ArchiveStepConflictException('Archive is not pending.'); } if (strlen($password) > 4096 || str_contains($password, "\0")) { - throw new RuntimeException('Invalid archive password.'); + throw new ArchiveProcessingException('Invalid archive password.'); } $jobPath = $this->jobs->path($jobId); @@ -88,26 +92,26 @@ public function process(string $jobId, string $archiveId, string $password): arr $mergedEntries = []; try { if ($zip->open($archivePath) !== true) { - throw new RuntimeException('Unable to open archive.'); + throw new ArchiveProcessingException('Unable to open archive.'); } $zip->setPassword($password); if ($zip->numFiles > $this->policy->maxEntries) { - throw new RuntimeException('Archive entry limit exceeded.'); + throw new ArchiveProcessingException('Archive entry limit exceeded.'); } for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i, ZipArchive::FL_UNCHANGED); - if ($stat === false) throw new RuntimeException('Unable to inspect archive entry.'); + if ($stat === false) throw new ArchiveProcessingException('Unable to inspect archive entry.'); $name = str_replace('\\', '/', (string) ($stat['name'] ?? '')); $this->validateEntryPath($name); $this->policy->assertSafePath($name); - if (strlen($name) > $this->policy->maxPathLength) throw new RuntimeException('Archive path is too long.'); - if ($this->isSymbolicLink($zip, $i)) throw new RuntimeException('Symbolic links are not supported.'); + if (strlen($name) > $this->policy->maxPathLength) throw new ArchiveProcessingException('Archive path is too long.'); + if ($this->isSymbolicLink($zip, $i)) throw new ArchiveProcessingException('Symbolic links are not supported.'); $isDir = str_ends_with($name, '/'); $size = max(0, (int) ($stat['size'] ?? 0)); if (!$isDir) { if ($size > $this->policy->maxSingleFileBytes || (int) $state['stats']['bytes'] + $localBytes + $size > $this->policy->maxTotalUncompressedBytes) { - throw new RuntimeException('Archive extraction limit exceeded.'); + throw new ArchiveProcessingException('Archive extraction limit exceeded.'); } } $target = $stage . DIRECTORY_SEPARATOR . $name; @@ -117,9 +121,9 @@ public function process(string $jobId, string $archiveId, string $password): arr } $parent = dirname($target); if (!is_dir($parent) && !mkdir($parent, 0700, true) && !is_dir($parent)) throw new RuntimeException('Unable to create extraction directory.'); - if (file_exists($target)) throw new RuntimeException('Archive contains duplicate output paths.'); + if (file_exists($target)) throw new ArchiveProcessingException('Archive contains duplicate output paths.'); $stream = $zip->getStream($name); - if ($stream === false) throw new RuntimeException('Unable to open archive content.'); + if ($stream === false) throw new ArchiveProcessingException('Unable to open archive content.'); $out = fopen($target, 'xb'); if ($out === false) { fclose($stream); throw new RuntimeException('Unable to create extracted file.'); } try { @@ -136,8 +140,8 @@ public function process(string $jobId, string $archiveId, string $password): arr if ($this->looksLikeZip($target)) { $childDepth = (int) ($state['pending'][$pendingIndex]['depth'] ?? 0) + 1; - if ($childDepth > $this->policy->maxDepth) throw new RuntimeException('Nested archive depth limit exceeded.'); - if (count($state['pending']) + count($newArchives) >= $this->policy->maxNestedArchives) throw new RuntimeException('Nested archive count limit exceeded.'); + if ($childDepth > $this->policy->maxDepth) throw new ArchiveProcessingException('Nested archive depth limit exceeded.'); + if (count($state['pending']) + count($newArchives) >= $this->policy->maxNestedArchives) throw new ArchiveProcessingException('Nested archive count limit exceeded.'); $childId = bin2hex(random_bytes(16)); $stored = $jobPath . DIRECTORY_SEPARATOR . 'archives' . DIRECTORY_SEPARATOR . $childId . '.zip'; if (!rename($target, $stored)) throw new RuntimeException('Unable to queue nested archive.'); @@ -163,15 +167,35 @@ public function process(string $jobId, string $archiveId, string $password): arr return $this->publicState($state); } catch (\Throwable $e) { try { $zip->close(); } catch (\Throwable) {} - $this->rollbackMergedEntries($mergedEntries); - $this->removeDirectory($stage); - // Nested archives moved out of staging are not committed to state until the whole - // parent archive succeeds; remove those orphan copies when processing fails. + + // Attempt every cleanup even if one operation fails. Cleanup failures must not be + // silently hidden behind the original bad-archive response. + $cleanupFailure = null; + try { + $this->rollbackMergedEntries($mergedEntries); + } catch (\Throwable $cleanupError) { + $cleanupFailure ??= $cleanupError; + } + try { + $this->removeDirectory($stage); + } catch (\Throwable $cleanupError) { + $cleanupFailure ??= $cleanupError; + } foreach ($newArchives as $child) { $childPath = $jobPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $child['file']); - if (is_link($childPath) || is_file($childPath)) @unlink($childPath); + if (is_link($childPath) || is_file($childPath)) { + if (!@unlink($childPath) || file_exists($childPath) || is_link($childPath)) { + $cleanupFailure ??= new RuntimeException('Unable to remove an uncommitted nested archive.'); + } + } + } + if ($cleanupFailure !== null) { + throw new RuntimeException('Unable to clean failed archive extraction.', 0, $cleanupFailure); } - throw new RuntimeException('Unable to extract archive. The supplied archive password may be invalid.', 0, $e); + if ($e instanceof ArchiveProcessingException) { + throw new ArchiveProcessingException('Unable to extract archive. The supplied archive password may be invalid.', 0, $e); + } + throw $e; } } @@ -210,7 +234,7 @@ private function mergeDirectory(string $source, string $target): array foreach (new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS) as $item) { $destination = $target . DIRECTORY_SEPARATOR . $item->getFilename(); if (file_exists($destination) || is_link($destination)) { - throw new RuntimeException('Archive output path collision detected.'); + throw new ArchiveProcessingException('Archive output path collision detected.'); } $moves[] = ['source' => $item->getPathname(), 'destination' => $destination]; } @@ -233,26 +257,35 @@ private function mergeDirectory(string $source, string $target): array private function rollbackMergedEntries(array $moves): void { + $failed = false; foreach (array_reverse($moves) as $move) { $source = (string) ($move['source'] ?? ''); $destination = (string) ($move['destination'] ?? ''); if ($source === '' || $destination === '' || (!file_exists($destination) && !is_link($destination))) { continue; } - if (!file_exists($source) && !is_link($source)) { - @rename($destination, $source); + if (file_exists($source) || is_link($source)) { + $failed = true; + continue; } + if (!@rename($destination, $source) + && (file_exists($destination) || is_link($destination))) { + $failed = true; + } + } + if ($failed) { + throw new RuntimeException('Unable to roll back partially merged archive output.'); } } private function validateEntryPath(string $path): void { if ($path === '' || str_starts_with($path, '/') || preg_match('/^[A-Za-z]:\//', $path) === 1 || str_contains($path, "\0")) { - throw new RuntimeException('Archive contains an unsafe path.'); + throw new ArchiveProcessingException('Archive contains an unsafe path.'); } foreach (explode('/', $path) as $part) { if ($part === '.' || $part === '..') { - throw new RuntimeException('Archive contains an unsafe path.'); + throw new ArchiveProcessingException('Archive contains an unsafe path.'); } } } @@ -278,18 +311,17 @@ private function looksLikeZip(string $path): bool } private function removeDirectory(string $path): void { - if (is_link($path)) { - @unlink($path); - return; - } - if (is_file($path)) { - @unlink($path); + if (is_link($path) || is_file($path)) { + if (!@unlink($path) && (file_exists($path) || is_link($path))) { + throw new RuntimeException('Unable to remove archive staging path.'); + } return; } if (!is_dir($path)) { return; } + $failed = false; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST @@ -297,12 +329,19 @@ private function removeDirectory(string $path): void foreach ($iterator as $item) { $itemPath = $item->getPathname(); if ($item->isLink() || !$item->isDir()) { - @unlink($itemPath); - } else { - @rmdir($itemPath); + if (!@unlink($itemPath) && (file_exists($itemPath) || is_link($itemPath))) { + $failed = true; + } + } elseif (!@rmdir($itemPath) && is_dir($itemPath)) { + $failed = true; } } - @rmdir($path); + if (!@rmdir($path) && is_dir($path)) { + $failed = true; + } + if ($failed || file_exists($path) || is_link($path)) { + throw new RuntimeException('Unable to remove archive staging directory.'); + } } } diff --git a/app/Archive/BoundedStreamCopier.php b/app/Archive/BoundedStreamCopier.php index 52556016..5bb488fc 100644 --- a/app/Archive/BoundedStreamCopier.php +++ b/app/Archive/BoundedStreamCopier.php @@ -33,18 +33,18 @@ public function copy($source, $destination, int $maxFileBytes, int $maxTotalByte $readLength = max(1, min(self::CHUNK_BYTES, $remainingFile + 1, $remainingTotal + 1)); $chunk = fread($source, $readLength); if ($chunk === false) { - throw new RuntimeException('Unable to read archive content.'); + throw new ArchiveProcessingException('Unable to read archive content.'); } if ($chunk === '') { if (feof($source)) { break; } - throw new RuntimeException('Archive stream stopped unexpectedly.'); + throw new ArchiveProcessingException('Archive stream stopped unexpectedly.'); } $length = strlen($chunk); if ($copied + $length > $maxFileBytes || $copied + $length > $maxTotalBytes) { - throw new RuntimeException('Archive extraction limit exceeded.'); + throw new ArchiveProcessingException('Archive extraction limit exceeded.'); } $offset = 0; diff --git a/app/Crypto/V14Aead.php b/app/Crypto/V14Aead.php index e22aa8a5..95d4a35e 100644 --- a/app/Crypto/V14Aead.php +++ b/app/Crypto/V14Aead.php @@ -21,11 +21,11 @@ public static function open(string $packed, string $key, string $aad): string { self::assertKey($key); $min = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES; - if (strlen($packed) < $min) throw new RuntimeException('Unable to authenticate package data.'); + if (strlen($packed) < $min) throw new V14AuthenticationException('Unable to authenticate package data.'); $nonce = substr($packed, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); $cipher = substr($packed, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); $plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, $aad, $nonce, $key); - if ($plain === false) throw new RuntimeException('Unable to authenticate package data.'); + if ($plain === false) throw new V14AuthenticationException('Unable to authenticate package data.'); return $plain; } @@ -41,10 +41,10 @@ public static function wrapKey(string $rootKey, string $wrappingKey, string $aad public static function unwrapKey(array $slot, string $wrappingKey, string $aad): string { - if (!isset($slot['nonce'], $slot['wrapped_key'])) throw new RuntimeException('Invalid key slot.'); + if (!isset($slot['nonce'], $slot['wrapped_key'])) throw new V14AuthenticationException('Invalid key slot.'); $nonce = base64_decode((string) $slot['nonce'], true); $cipher = base64_decode((string) $slot['wrapped_key'], true); - if ($nonce === false || strlen($nonce) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES || $cipher === false || strlen($cipher) !== V14Descriptor::KEY_BYTES + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) throw new RuntimeException('Invalid key slot.'); + if ($nonce === false || strlen($nonce) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES || $cipher === false || strlen($cipher) !== V14Descriptor::KEY_BYTES + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) throw new V14AuthenticationException('Invalid key slot.'); return self::open($nonce . $cipher, $wrappingKey, $aad); } diff --git a/app/Crypto/V14AuthenticationException.php b/app/Crypto/V14AuthenticationException.php new file mode 100644 index 00000000..d376898b --- /dev/null +++ b/app/Crypto/V14AuthenticationException.php @@ -0,0 +1,11 @@ + 8 * 1024 * 1024) throw new RuntimeException('Invalid stream configuration.'); - if (file_exists($destination) || is_link($destination)) throw new RuntimeException('Encrypted destination already exists.'); - if (!is_file($source)) throw new RuntimeException('Source file does not exist.'); - $in = fopen($source, 'rb'); - if ($in === false) throw new RuntimeException('Unable to open source file.'); + if (file_exists($destination) || is_link($destination)) throw new PackageStorageException('Encrypted destination already exists.'); + clearstatcache(true, $source); + $sourcePathStat = @lstat($source); + if (is_link($source) || !is_file($source) || !is_array($sourcePathStat) + || (((int) ($sourcePathStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Source file does not exist or is not a regular file.'); + } + $in = @fopen($source, 'rb'); + if ($in === false) throw new PackageStorageException('Unable to open source file.'); + $sourceHandleStat = fstat($in); + if (!is_array($sourceHandleStat) + || (((int) ($sourceHandleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($sourceHandleStat['dev'] ?? -2) !== (int) ($sourcePathStat['dev'] ?? -1) + || (int) ($sourceHandleStat['ino'] ?? 0) !== (int) ($sourcePathStat['ino'] ?? -1) + || (int) ($sourceHandleStat['size'] ?? -1) !== $plainSize) { + fclose($in); + throw new PackageStorageException('Source file identity or size changed before encryption.'); + } $tmp = $destination . '.partial.' . bin2hex(random_bytes(12)); self::ensureParent($destination); - $out = fopen($tmp, 'wb'); - if ($out === false) { fclose($in); throw new RuntimeException('Unable to create encrypted file.'); } + $out = @fopen($tmp, 'x+b'); + if ($out === false) { fclose($in); throw new PackageStorageException('Unable to create encrypted file.'); } + $tmpHandleStat = fstat($out); + if (!is_array($tmpHandleStat) || (((int) ($tmpHandleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($tmpHandleStat['ino'] ?? 0) === 0) { + fclose($out); + fclose($in); + if (!@unlink($tmp) && (file_exists($tmp) || is_link($tmp))) throw new PackageStorageException('Unable to clean unverified encrypted temporary file.'); + throw new PackageStorageException('Unable to verify encrypted temporary file.'); + } + $tmpIdentity = ['dev' => (int) ($tmpHandleStat['dev'] ?? -1), 'ino' => (int) $tmpHandleStat['ino']]; + if (!@chmod($tmp, 0600)) { + fclose($out); + fclose($in); + if (!@unlink($tmp) && (file_exists($tmp) || is_link($tmp))) { + throw new PackageStorageException('Unable to clean encrypted temporary file after permission failure.'); + } + throw new PackageStorageException('Unable to protect encrypted temporary file.'); + } + $published = false; + $state = null; try { [$state, $header] = sodium_crypto_secretstream_xchacha20poly1305_init_push($key); self::writeAll($out, self::MAGIC . $header); @@ -35,27 +69,53 @@ public static function encryptFile(string $source, string $destination, string $ while ($remaining > 0) { $want = min($chunkBytes, $remaining); $plain = self::readExact($in, $want); - if (strlen($plain) !== $want) throw new RuntimeException('Source file changed during encryption.'); + if (strlen($plain) !== $want) throw new PackageStorageException('Source file changed during encryption.'); $remaining -= $want; $tag = $remaining === 0 ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE; - $cipher = sodium_crypto_secretstream_xchacha20poly1305_push($state, $plain, $aad . '|chunk|' . $index . '|' . $plainSize, $tag); - self::writeRecord($out, $index, $cipher); - sodium_memzero($plain); + try { + $cipher = sodium_crypto_secretstream_xchacha20poly1305_push($state, $plain, $aad . '|chunk|' . $index . '|' . $plainSize, $tag); + self::writeRecord($out, $index, $cipher); + } finally { + if ($plain !== '') sodium_memzero($plain); + } $index++; } } - fflush($out); + $sourceTail = fread($in, 1); + if ($sourceTail === false) throw new PackageStorageException('Unable to verify encrypted source length.'); + if ($sourceTail !== '') throw new PackageStorageException('Source file changed during encryption.'); + if (!fflush($out)) throw new PackageStorageException('Unable to flush encrypted temporary file.'); + if (is_string($state)) sodium_memzero($state); + $state = null; + if (function_exists('fsync') && !fsync($out)) throw new PackageStorageException('Unable to synchronize encrypted temporary file.'); fclose($out); $out = null; fclose($in); $in = null; + clearstatcache(true, $tmp); + $tmpStat = @lstat($tmp); + if (!is_array($tmpStat) || is_link($tmp) + || (int) ($tmpStat['dev'] ?? -2) !== $tmpIdentity['dev'] + || (int) ($tmpStat['ino'] ?? 0) !== $tmpIdentity['ino'] + || (((int) ($tmpStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Encrypted temporary file identity changed before publication.'); + } // link() atomically creates the final path and fails rather than replacing an existing file. - if (!@link($tmp, $destination)) throw new RuntimeException('Unable to finalize encrypted file without overwriting an existing destination.'); - if (!@unlink($tmp)) throw new RuntimeException('Unable to remove encrypted temporary file.'); - @chmod($destination, 0600); + if (!@link($tmp, $destination)) throw new PackageStorageException('Unable to finalize encrypted file without overwriting an existing destination.'); + $published = true; + clearstatcache(true, $destination); + $destinationStat = @lstat($destination); + if (!is_array($destinationStat) || is_link($destination) + || (int) ($destinationStat['dev'] ?? -2) !== $tmpIdentity['dev'] + || (int) ($destinationStat['ino'] ?? 0) !== $tmpIdentity['ino']) { + throw new PackageStorageException('Published encrypted file identity could not be verified.'); + } + if (!@unlink($tmp) && (file_exists($tmp) || is_link($tmp))) throw new PackageStorageException('Unable to remove encrypted temporary file.'); + if (file_exists($tmp) || is_link($tmp)) throw new PackageStorageException('Encrypted temporary file remains after publication.'); + $published = false; } catch (\Throwable $e) { if (is_resource($in)) fclose($in); if (is_resource($out)) fclose($out); - @unlink($tmp); - // Never delete the destination here: it may belong to the caller. + if (isset($state) && is_string($state)) sodium_memzero($state); + self::cleanupFailedStream($tmp, $destination, $published, $tmpIdentity, $e); throw $e; } } @@ -63,56 +123,138 @@ public static function encryptFile(string $source, string $destination, string $ public static function decryptFile(string $source, string $destination, string $key, string $aad, int $expectedPlainSize): void { if (strlen($key) !== V14Descriptor::KEY_BYTES || $expectedPlainSize < 0) throw new RuntimeException('Invalid stream configuration.'); - if (file_exists($destination) || is_link($destination)) throw new RuntimeException('Restored destination already exists.'); - $in = fopen($source, 'rb'); - if ($in === false) throw new RuntimeException('Unable to open encrypted file.'); + if (file_exists($destination) || is_link($destination)) throw new PackageStorageException('Restored destination already exists.'); + clearstatcache(true, $source); + $sourcePathStat = @lstat($source); + if (is_link($source) || !is_file($source) || !is_array($sourcePathStat) + || (((int) ($sourcePathStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Encrypted source file is unavailable.'); + } + $in = @fopen($source, 'rb'); + if ($in === false) throw new PackageStorageException('Unable to open encrypted file.'); + $sourceHandleStat = fstat($in); + if (!is_array($sourceHandleStat) + || (((int) ($sourceHandleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($sourceHandleStat['dev'] ?? -2) !== (int) ($sourcePathStat['dev'] ?? -1) + || (int) ($sourceHandleStat['ino'] ?? 0) !== (int) ($sourcePathStat['ino'] ?? -1)) { + fclose($in); + throw new PackageStorageException('Encrypted source file identity changed before restore.'); + } $tmp = $destination . '.partial.' . bin2hex(random_bytes(12)); self::ensureParent($destination); - $out = fopen($tmp, 'wb'); - if ($out === false) { fclose($in); throw new RuntimeException('Unable to create restored file.'); } + $out = @fopen($tmp, 'x+b'); + if ($out === false) { fclose($in); throw new PackageStorageException('Unable to create restored file.'); } + $tmpHandleStat = fstat($out); + if (!is_array($tmpHandleStat) || (((int) ($tmpHandleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($tmpHandleStat['ino'] ?? 0) === 0) { + fclose($out); + fclose($in); + if (!@unlink($tmp) && (file_exists($tmp) || is_link($tmp))) throw new PackageStorageException('Unable to clean unverified restored temporary file.'); + throw new PackageStorageException('Unable to verify restored temporary file.'); + } + $tmpIdentity = ['dev' => (int) ($tmpHandleStat['dev'] ?? -1), 'ino' => (int) $tmpHandleStat['ino']]; + if (!@chmod($tmp, 0600)) { + fclose($out); + fclose($in); + if (!@unlink($tmp) && (file_exists($tmp) || is_link($tmp))) { + throw new PackageStorageException('Unable to clean restored temporary file after permission failure.'); + } + throw new PackageStorageException('Unable to protect restored temporary file.'); + } + $published = false; + $state = null; try { - $header = self::readExact($in, strlen(self::MAGIC) + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES); - if (!hash_equals(self::MAGIC, substr($header, 0, strlen(self::MAGIC)))) throw new RuntimeException('Invalid encrypted stream.'); - $state = sodium_crypto_secretstream_xchacha20poly1305_init_pull(substr($header, strlen(self::MAGIC)), $key); + try { + $header = self::readExact($in, strlen(self::MAGIC) + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES); + } catch (PackageStorageException $exception) { + throw $exception; + } catch (RuntimeException $exception) { + throw new V14StreamRejectedException('Encrypted stream is truncated.', 0, $exception); + } + if (!hash_equals(self::MAGIC, substr($header, 0, strlen(self::MAGIC)))) throw new V14StreamRejectedException('Invalid encrypted stream.'); + try { + $state = sodium_crypto_secretstream_xchacha20poly1305_init_pull(substr($header, strlen(self::MAGIC)), $key); + } catch (\SodiumException $exception) { + throw new V14StreamRejectedException('Invalid encrypted stream header.', 0, $exception); + } $written = 0; $index = 0; $final = false; while (!feof($in)) { $lenBytes = fread($in, self::LENGTH_BYTES); - if ($lenBytes === '' || $lenBytes === false) break; - if (strlen($lenBytes) !== self::LENGTH_BYTES) throw new RuntimeException('Encrypted stream is truncated.'); + if ($lenBytes === false) throw new PackageStorageException('Unable to read encrypted stream.'); + if ($lenBytes === '') { + if (!feof($in)) throw new PackageStorageException('Unable to read encrypted stream.'); + break; + } + if (strlen($lenBytes) !== self::LENGTH_BYTES) throw new V14StreamRejectedException('Encrypted stream is truncated.'); $length = unpack('Nlen', $lenBytes)['len'] ?? 0; - if ($length < SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES || $length > self::MAX_CIPHER_CHUNK) throw new RuntimeException('Encrypted stream chunk is invalid.'); - $cipher = self::readExact($in, $length); - $plainResult = sodium_crypto_secretstream_xchacha20poly1305_pull($state, $cipher, $aad . '|chunk|' . $index . '|' . $expectedPlainSize); - if ($plainResult === false) throw new RuntimeException('Unable to authenticate encrypted stream.'); + if ($length < SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES || $length > self::MAX_CIPHER_CHUNK) throw new V14StreamRejectedException('Encrypted stream chunk is invalid.'); + try { + $cipher = self::readExact($in, $length); + } catch (PackageStorageException $exception) { + throw $exception; + } catch (RuntimeException $exception) { + throw new V14StreamRejectedException('Encrypted stream is truncated.', 0, $exception); + } + try { + $plainResult = sodium_crypto_secretstream_xchacha20poly1305_pull($state, $cipher, $aad . '|chunk|' . $index . '|' . $expectedPlainSize); + } catch (\SodiumException $exception) { + throw new V14StreamRejectedException('Unable to authenticate encrypted stream.', 0, $exception); + } + if ($plainResult === false) throw new V14StreamRejectedException('Unable to authenticate encrypted stream.'); [$plain, $tag] = $plainResult; if ($tag === SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL) $final = true; - elseif ($tag !== SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE) throw new RuntimeException('Unexpected stream tag.'); + elseif ($tag !== SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE) throw new V14StreamRejectedException('Unexpected stream tag.'); $written += strlen($plain); - if ($written > $expectedPlainSize) throw new RuntimeException('Encrypted stream length is invalid.'); - if ($plain !== '') self::writeAll($out, $plain); - sodium_memzero($plain); + if ($written > $expectedPlainSize) throw new V14StreamRejectedException('Encrypted stream length is invalid.'); + try { + if ($plain !== '') self::writeAll($out, $plain); + } finally { + if ($plain !== '') sodium_memzero($plain); + } $index++; if ($final) { $tail = fread($in, 1); - if ($tail !== '' && $tail !== false) throw new RuntimeException('Encrypted stream contains trailing data.'); + if ($tail === false) throw new PackageStorageException('Unable to verify encrypted stream end.'); + if ($tail !== '') throw new V14StreamRejectedException('Encrypted stream contains trailing data.'); + if (!feof($in)) throw new PackageStorageException('Unable to verify encrypted stream end.'); break; } } - if (!$final || $written !== $expectedPlainSize) throw new RuntimeException('Encrypted stream length is invalid.'); - fflush($out); + if (!$final || $written !== $expectedPlainSize) throw new V14StreamRejectedException('Encrypted stream length is invalid.'); + if (is_string($state)) sodium_memzero($state); + $state = null; + if (!fflush($out)) throw new PackageStorageException('Unable to flush restored temporary file.'); + if (function_exists('fsync') && !fsync($out)) throw new PackageStorageException('Unable to synchronize restored temporary file.'); fclose($out); $out = null; fclose($in); $in = null; + clearstatcache(true, $tmp); + $tmpStat = @lstat($tmp); + if (!is_array($tmpStat) || is_link($tmp) + || (int) ($tmpStat['dev'] ?? -2) !== $tmpIdentity['dev'] + || (int) ($tmpStat['ino'] ?? 0) !== $tmpIdentity['ino'] + || (((int) ($tmpStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Restored temporary file identity changed before publication.'); + } // link() atomically creates the final path and fails rather than replacing an existing file. - if (!@link($tmp, $destination)) throw new RuntimeException('Unable to finalize restored file without overwriting an existing destination.'); - if (!@unlink($tmp)) throw new RuntimeException('Unable to remove restored temporary file.'); - @chmod($destination, 0600); + if (!@link($tmp, $destination)) throw new PackageStorageException('Unable to finalize restored file without overwriting an existing destination.'); + $published = true; + clearstatcache(true, $destination); + $destinationStat = @lstat($destination); + if (!is_array($destinationStat) || is_link($destination) + || (int) ($destinationStat['dev'] ?? -2) !== $tmpIdentity['dev'] + || (int) ($destinationStat['ino'] ?? 0) !== $tmpIdentity['ino']) { + throw new PackageStorageException('Published restored file identity could not be verified.'); + } + if (!@unlink($tmp) && (file_exists($tmp) || is_link($tmp))) throw new PackageStorageException('Unable to remove restored temporary file.'); + if (file_exists($tmp) || is_link($tmp)) throw new PackageStorageException('Restored temporary file remains after publication.'); + $published = false; } catch (\Throwable $e) { if (is_resource($in)) fclose($in); if (is_resource($out)) fclose($out); - @unlink($tmp); - // Never delete the destination here: it may belong to the caller. + if (isset($state) && is_string($state)) sodium_memzero($state); + self::cleanupFailedStream($tmp, $destination, $published, $tmpIdentity, $e); throw $e; } } @@ -126,7 +268,7 @@ private static function writeRecord($out, int $index, string $cipher): void private static function ensureParent(string $path): void { $dir = dirname($path); - if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) throw new RuntimeException('Unable to initialize destination directory.'); + if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) throw new PackageStorageException('Unable to initialize destination directory.'); } private static function writeAll($handle, string $data): void @@ -135,20 +277,74 @@ private static function writeAll($handle, string $data): void $len = strlen($data); while ($offset < $len) { $n = fwrite($handle, substr($data, $offset)); - if ($n === false || $n === 0) throw new RuntimeException('Unable to write encrypted data.'); + if ($n === false || $n === 0) throw new PackageStorageException('Unable to write encrypted data.'); $offset += $n; } } + private static function cleanupFailedStream(string $temporaryPath, string $destinationPath, bool $published, array $temporaryIdentity, \Throwable $previous): void + { + $cleanupFailed = false; + + if ($published) { + clearstatcache(true, $destinationPath); + $destinationStat = @lstat($destinationPath); + if (is_array($destinationStat) && !is_link($destinationPath) + && (int) ($destinationStat['dev'] ?? -2) === $temporaryIdentity['dev'] + && (int) ($destinationStat['ino'] ?? 0) === $temporaryIdentity['ino'] + && (((int) ($destinationStat['mode'] ?? 0) & 0170000) === 0100000)) { + if (!@unlink($destinationPath) && (file_exists($destinationPath) || is_link($destinationPath))) { + $cleanupFailed = true; + } + } elseif (file_exists($destinationPath) || is_link($destinationPath)) { + // Never delete a path whose identity changed after our atomic publication. + $cleanupFailed = true; + } + } + + clearstatcache(true, $temporaryPath); + if (file_exists($temporaryPath) || is_link($temporaryPath)) { + $temporaryStat = @lstat($temporaryPath); + if (is_array($temporaryStat) && !is_link($temporaryPath) + && (int) ($temporaryStat['dev'] ?? -2) === $temporaryIdentity['dev'] + && (int) ($temporaryStat['ino'] ?? 0) === $temporaryIdentity['ino'] + && (((int) ($temporaryStat['mode'] ?? 0) & 0170000) === 0100000)) { + if (!@unlink($temporaryPath) && (file_exists($temporaryPath) || is_link($temporaryPath))) { + $cleanupFailed = true; + } + } else { + // Do not unlink a symlink or unexpected file that replaced our temporary file. + $cleanupFailed = true; + } + } + + clearstatcache(true, $temporaryPath); + clearstatcache(true, $destinationPath); + if (file_exists($temporaryPath) || is_link($temporaryPath) + || ($published && (file_exists($destinationPath) || is_link($destinationPath)))) { + $cleanupFailed = true; + } + if ($cleanupFailed) { + throw new PackageStorageException('Unable to clean failed stream output safely.', 0, $previous); + } + } + private static function readExact($handle, int $length): string { $data = ''; while (strlen($data) < $length && !feof($handle)) { $chunk = fread($handle, $length - strlen($data)); - if ($chunk === false || $chunk === '') break; + if ($chunk === false) { + if ($data !== '') sodium_memzero($data); + throw new PackageStorageException('Unable to read stream data.'); + } + if ($chunk === '') break; $data .= $chunk; } - if (strlen($data) !== $length) throw new RuntimeException('Encrypted data is truncated.'); + if (strlen($data) !== $length) { + if ($data !== '') sodium_memzero($data); + throw new RuntimeException('Encrypted data is truncated.'); + } return $data; } } diff --git a/app/Crypto/V14StreamRejectedException.php b/app/Crypto/V14StreamRejectedException.php new file mode 100644 index 00000000..53943684 --- /dev/null +++ b/app/Crypto/V14StreamRejectedException.php @@ -0,0 +1,11 @@ +assertZipRuntime(); + if (is_link($packageDir)) { + throw new RuntimeException('Package directory must not be a symlink.'); + } $root = realpath($packageDir); - if ($root === false || !is_dir($root)) throw new RuntimeException('Package directory does not exist.'); + if ($root === false || !is_dir($root)) { + throw new RuntimeException('Package directory does not exist.'); + } + // Never replace an existing archive or follow a destination symlink. - if (file_exists($outputFile) || is_link($outputFile)) throw new RuntimeException('Package archive destination already exists.'); - $tmpOutput = $outputFile . '.partial.' . bin2hex(random_bytes(12)); + if (file_exists($outputFile) || is_link($outputFile)) { + throw new PackageStorageException('Package archive destination already exists.'); + } + $parent = dirname($outputFile); + clearstatcache(true, $parent); + $parentStat = @lstat($parent); + if (!is_dir($parent) || is_link($parent) || !is_array($parentStat) + || (((int) ($parentStat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new PackageStorageException('Package archive output directory is unavailable.'); + } + + $tmpOutput = $parent . DIRECTORY_SEPARATOR . basename($outputFile) . '.partial.' . bin2hex(random_bytes(12)); + if (file_exists($tmpOutput) || is_link($tmpOutput)) { + throw new PackageStorageException('Temporary package archive path already exists.'); + } + $zip = new ZipArchive(); - if ($zip->open($tmpOutput, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - @unlink($tmpOutput); - throw new RuntimeException('Unable to create package archive.'); + $openResult = $zip->open($tmpOutput, ZipArchive::CREATE | ZipArchive::EXCL); + if ($openResult !== true) { + clearstatcache(true, $tmpOutput); + if (file_exists($tmpOutput) || is_link($tmpOutput)) { + // A failed ZipArchive open does not prove ownership of the path it left behind. + throw new PackageStorageException('Unable to initialize package archive output.'); + } + throw new PackageStorageException('Unable to create package archive.'); } + + $tmpIdentity = null; + $published = false; try { $count = 0; $used = []; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + $rootStat = @lstat($root); + if (!is_array($rootStat) || is_link($root) + || (((int) ($rootStat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new PackageStorageException('Package source directory identity is invalid.'); + } + $rootIdentity = [ + 'dev' => (int) ($rootStat['dev'] ?? -1), + 'ino' => (int) ($rootStat['ino'] ?? 0), + ]; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS) + ); foreach ($iterator as $file) { + $currentRoot = @lstat($root); + if (!is_array($currentRoot) || is_link($root) + || (int) ($currentRoot['dev'] ?? -2) !== $rootIdentity['dev'] + || (int) ($currentRoot['ino'] ?? 0) !== $rootIdentity['ino']) { + throw new PackageStorageException('Package source directory changed while archiving.'); + } if (!$file->isFile() || $file->isLink() || $file->getFilename() === 'record.json') continue; - if (++$count > self::MAX_ENTRIES) throw new RuntimeException('Package archive contains too many entries.'); + if (++$count > self::MAX_ENTRIES) { + throw new PackageRestoreRejectedException('Package archive contains too many entries.'); + } $path = $file->getPathname(); + clearstatcache(true, $path); + $sourceStat = @lstat($path); + if (!is_array($sourceStat) || is_link($path) + || (((int) ($sourceStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Package source entry is not a regular file.'); + } $relative = str_replace('\\', '/', substr($path, strlen(rtrim($root, DIRECTORY_SEPARATOR)) + 1)); $this->assertArchivePath($relative); $normalized = function_exists('mb_strtolower') ? mb_strtolower($relative, 'UTF-8') : strtolower($relative); - if (isset($used[$normalized])) throw new RuntimeException('Package archive contains colliding paths.'); + if (isset($used[$normalized])) { + throw new PackageRestoreRejectedException('Package archive contains colliding paths.'); + } $used[$normalized] = true; - if (!$zip->addFile($path, $relative)) throw new RuntimeException('Unable to add package file to archive.'); - $zip->setCompressionName($relative, ZipArchive::CM_STORE); - } - if (!$zip->close()) throw new RuntimeException('Unable to finalize package archive.'); - // link() creates the final name atomically and fails if it already exists. - // The temporary file is in the same directory, so it is on the same filesystem. - if (!@link($tmpOutput, $outputFile)) throw new RuntimeException('Unable to finalize package archive without overwriting an existing file.'); - if (!@unlink($tmpOutput)) { - @unlink($outputFile); - throw new RuntimeException('Unable to finalize package archive.'); - } - } catch (\Throwable $e) { - @$zip->close(); - @unlink($tmpOutput); - // Do not remove $outputFile here: it may predate this operation. - throw $e; - } - @chmod($outputFile, 0600); + if (!$zip->addFile($path, $relative)) { + throw new PackageStorageException('Unable to add package file to archive.'); + } + if (!$zip->setCompressionName($relative, ZipArchive::CM_STORE)) { + throw new PackageStorageException('Unable to configure package archive entry.'); + } + } + if (!$zip->close()) { + throw new PackageStorageException('Unable to finalize package archive.'); + } + + // libzip may materialize or atomically replace the path only during close(). + // Capture the resulting regular-file identity after finalization, then bind + // publication and rollback to that exact inode. + clearstatcache(true, $tmpOutput); + $tempStat = @lstat($tmpOutput); + if (!is_array($tempStat) || is_link($tmpOutput) + || (((int) ($tempStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Temporary package archive identity could not be verified.'); + } + $tmpIdentity = [ + 'dev' => (int) ($tempStat['dev'] ?? -1), + 'ino' => (int) ($tempStat['ino'] ?? 0), + ]; + +// Re-read the resulting identity immediately before publication. + clearstatcache(true, $tmpOutput); + $tempStat = @lstat($tmpOutput); + if (!is_array($tempStat) || is_link($tmpOutput) + || (int) ($tempStat['dev'] ?? -2) !== $tmpIdentity['dev'] + || (int) ($tempStat['ino'] ?? 0) !== $tmpIdentity['ino'] + || (((int) ($tempStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Temporary package archive changed during creation.'); + } + if (!@chmod($tmpOutput, 0600)) { + throw new PackageStorageException('Unable to protect temporary package archive.'); + } + + // link() publishes atomically and fails rather than replacing an existing destination. + if (!@link($tmpOutput, $outputFile)) { + throw new PackageStorageException('Unable to publish package archive without overwriting an existing destination.'); + } + $published = true; + clearstatcache(true, $outputFile); + $outputStat = @lstat($outputFile); + if (!is_array($outputStat) || is_link($outputFile) + || (int) ($outputStat['dev'] ?? -2) !== $tmpIdentity['dev'] + || (int) ($outputStat['ino'] ?? 0) !== $tmpIdentity['ino']) { + throw new PackageStorageException('Published package archive identity could not be verified.'); + } + if (!@unlink($tmpOutput) && (file_exists($tmpOutput) || is_link($tmpOutput))) { + throw new PackageStorageException('Unable to remove temporary package archive after publication.'); + } + if (file_exists($tmpOutput) || is_link($tmpOutput)) { + throw new PackageStorageException('Temporary package archive remains after publication.'); + } + $published = false; + } catch (\Throwable $exception) { + try { $zip->close(); } catch (\Throwable) {} + if ($tmpIdentity === null) { + clearstatcache(true, $tmpOutput); + $partialStat = @lstat($tmpOutput); + if (is_array($partialStat) && !is_link($tmpOutput) + && (((int) ($partialStat['mode'] ?? 0) & 0170000) === 0100000)) { + $tmpIdentity = [ + 'dev' => (int) ($partialStat['dev'] ?? -1), + 'ino' => (int) ($partialStat['ino'] ?? 0), + ]; + } + } + $this->cleanupFailedCreate($tmpOutput, $outputFile, $published, $tmpIdentity, $exception); + throw $exception; + } } + public function extract(string $archiveFile, string $destinationDir): void { $this->assertZipRuntime(); - if (!is_file($archiveFile)) throw new RuntimeException('Package archive does not exist.'); - if (file_exists($destinationDir)) throw new RuntimeException('Destination directory already exists.'); - if (!mkdir($destinationDir, 0700, true)) throw new RuntimeException('Unable to create extraction directory.'); + if (is_link($archiveFile) || !is_file($archiveFile)) { + throw new PackageRestoreRejectedException('Package archive does not exist.'); + } + if (file_exists($destinationDir) || is_link($destinationDir)) { + throw new PackageStorageException('Extraction destination already exists.'); + } + if (!@mkdir($destinationDir, 0700, true)) { + throw new PackageStorageException('Unable to create extraction directory.'); + } + + $destinationStat = @lstat($destinationDir); + if (!is_array($destinationStat) || is_link($destinationDir) + || (((int) ($destinationStat['mode'] ?? 0) & 0170000) !== 0040000)) { + if (is_dir($destinationDir) && !is_link($destinationDir)) { + if (!@rmdir($destinationDir) && is_dir($destinationDir)) { + throw new PackageStorageException('Unable to clean extraction directory after identity verification failed.'); + } + } + if (file_exists($destinationDir) || is_link($destinationDir)) { + throw new PackageStorageException('Unverified extraction directory remains after cleanup.'); + } + throw new PackageStorageException('Unable to verify extraction directory identity.'); + } + $destinationIdentity = [ + 'dev' => (int) ($destinationStat['dev'] ?? -1), + 'ino' => (int) ($destinationStat['ino'] ?? 0), + ]; $zip = new ZipArchive(); if ($zip->open($archiveFile) !== true) { - $this->removeDirectory($destinationDir); - throw new RuntimeException('Unable to open package archive.'); + try { + $this->removeDirectory($destinationDir, $destinationIdentity); + } catch (\Throwable $cleanupError) { + throw new PackageStorageException('Unable to clean failed package extraction.', 0, $cleanupError); + } + throw new PackageRestoreRejectedException('Unable to open package archive.'); } + try { - if ($zip->numFiles < 1 || $zip->numFiles > self::MAX_ENTRIES) throw new RuntimeException('Package archive is invalid.'); + if ($zip->numFiles < 1 || $zip->numFiles > self::MAX_ENTRIES) { + throw new PackageRestoreRejectedException('Package archive is invalid.'); + } $entries = []; $totalDeclared = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i, ZipArchive::FL_UNCHANGED); - if ($stat === false) throw new RuntimeException('Package archive is invalid.'); + if ($stat === false) throw new PackageRestoreRejectedException('Package archive is invalid.'); $name = str_replace('\\', '/', (string) ($stat['name'] ?? '')); $isDir = str_ends_with($name, '/'); if ($isDir) $name = rtrim($name, '/'); if ($name === '') continue; - $this->assertArchivePath($name); + $this->assertArchivePath($name, true); $size = (int) ($stat['size'] ?? -1); - if ($size < 0) throw new RuntimeException('Package archive is invalid.'); - if (!$isDir && $size > self::MAX_UNCOMPRESSED_BYTES) throw new RuntimeException('Package archive is invalid.'); + if ($size < 0 || (!$isDir && $size > self::MAX_UNCOMPRESSED_BYTES)) { + throw new PackageRestoreRejectedException('Package archive is invalid.'); + } $totalDeclared += $size; - if ($totalDeclared > self::MAX_UNCOMPRESSED_BYTES) throw new RuntimeException('Package archive is invalid.'); + if ($totalDeclared > self::MAX_UNCOMPRESSED_BYTES) { + throw new PackageRestoreRejectedException('Package archive is invalid.'); + } $normalized = function_exists('mb_strtolower') ? mb_strtolower($name, 'UTF-8') : strtolower($name); - if (isset($entries[$normalized])) throw new RuntimeException('Package archive contains duplicate paths.'); + if (isset($entries[$normalized])) { + throw new PackageRestoreRejectedException('Package archive contains duplicate paths.'); + } $entries[$normalized] = ['name' => $name, 'is_dir' => $isDir, 'size' => $size, 'index' => $i]; } + ksort($entries, SORT_STRING); $entryTypes = []; foreach ($entries as $entry) $entryTypes[$entry['name']] = $entry['is_dir'] ? 'dir' : 'file'; @@ -104,18 +252,25 @@ public function extract(string $archiveFile, string $destinationDir): void $ancestorKey = function_exists('mb_strtolower') ? mb_strtolower($ancestor, 'UTF-8') : strtolower($ancestor); foreach ($entryTypes as $entryName => $entryType) { $entryKey = function_exists('mb_strtolower') ? mb_strtolower($entryName, 'UTF-8') : strtolower($entryName); - if ($entryKey === $ancestorKey && $entryType === 'file') throw new RuntimeException('Package archive contains a file/directory conflict.'); + if ($entryKey === $ancestorKey && $entryType === 'file') { + throw new PackageRestoreRejectedException('Package archive contains a file/directory conflict.'); + } } $parentPath = $destinationDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $ancestor); - if (file_exists($parentPath) && !is_dir($parentPath)) throw new RuntimeException('Package archive contains a file/directory conflict.'); + if (file_exists($parentPath) && !is_dir($parentPath)) { + throw new PackageRestoreRejectedException('Package archive contains a file/directory conflict.'); + } } } + // Create directories in path-depth order before any file writes. $dirs = array_filter($entries, static fn(array $entry): bool => $entry['is_dir']); usort($dirs, static fn(array $a, array $b): int => (substr_count($a['name'], '/') <=> substr_count($b['name'], '/')) ?: strcmp($a['name'], $b['name'])); foreach ($dirs as $entry) { $target = $destinationDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entry['name']); - if (!mkdir($target, 0700, true) && !is_dir($target)) throw new RuntimeException('Unable to recreate package directory.'); + if (!@mkdir($target, 0700, true) && !is_dir($target)) { + throw new PackageStorageException('Unable to recreate package directory.'); + } } $copiedTotal = 0; @@ -124,57 +279,138 @@ public function extract(string $archiveFile, string $destinationDir): void $name = $entry['name']; $target = $destinationDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $name); $parent = dirname($target); - if (!is_dir($parent) && !mkdir($parent, 0700, true) && !is_dir($parent)) throw new RuntimeException('Unable to recreate package directory.'); - if (file_exists($target) || is_link($target)) throw new RuntimeException('Package archive contains a duplicate path.'); + if (!is_dir($parent) && !@mkdir($parent, 0700, true) && !is_dir($parent)) { + throw new PackageStorageException('Unable to recreate package directory.'); + } + if (file_exists($target) || is_link($target)) { + throw new PackageRestoreRejectedException('Package archive contains a duplicate path.'); + } $stream = $zip->getStream($name); - if ($stream === false) throw new RuntimeException('Unable to read package archive.'); - $out = fopen($target, 'xb'); - if ($out === false) { fclose($stream); throw new RuntimeException('Unable to create package file.'); } + if ($stream === false) { + throw new PackageRestoreRejectedException('Unable to read package archive.'); + } + $out = @fopen($target, 'xb'); + if ($out === false) { + fclose($stream); + throw new PackageStorageException('Unable to create package file.'); + } $copied = 0; try { while (!feof($stream)) { $chunk = fread($stream, self::COPY_BUFFER_BYTES); - if ($chunk === false) throw new RuntimeException('Unable to extract package file.'); + if ($chunk === false) throw new PackageRestoreRejectedException('Unable to extract package file.'); if ($chunk === '') break; $copied += strlen($chunk); $copiedTotal += strlen($chunk); - if ($copied > $entry['size'] || $copiedTotal > self::MAX_UNCOMPRESSED_BYTES) throw new RuntimeException('Package archive exceeds extraction limits.'); + if ($copied > $entry['size'] || $copiedTotal > self::MAX_UNCOMPRESSED_BYTES) { + throw new PackageRestoreRejectedException('Package archive exceeds extraction limits.'); + } $this->writeAll($out, $chunk); } - if ($copied !== $entry['size']) throw new RuntimeException('Package archive size mismatch.'); + if ($copied !== $entry['size']) { + throw new PackageRestoreRejectedException('Package archive size mismatch.'); + } + if (!fflush($out)) throw new PackageStorageException('Unable to flush extracted package file.'); + if (function_exists('fsync') && !fsync($out)) { + throw new PackageStorageException('Unable to synchronize extracted package file.'); + } } finally { fclose($stream); fclose($out); } - @chmod($target, 0600); + if (!@chmod($target, 0600)) throw new PackageStorageException('Unable to protect extracted package file.'); } - } catch (\Throwable $e) { - $zip->close(); - $this->removeDirectory($destinationDir); - throw $e; + if (!$zip->close()) throw new PackageRestoreRejectedException('Package archive could not be finalized after reading.'); + } catch (\Throwable $exception) { + try { $zip->close(); } catch (\Throwable) {} + try { + $this->removeDirectory($destinationDir, $destinationIdentity); + } catch (\Throwable $cleanupError) { + throw new PackageStorageException('Unable to clean failed package extraction.', 0, $cleanupError); + } + throw $exception; } - $zip->close(); } - private function assertArchivePath(string $name): void + private function cleanupFailedCreate(string $temporaryPath, string $destinationPath, bool $published, ?array $temporaryIdentity, \Throwable $previous): void { - if ($name === '' || strlen($name) > 4096 || str_starts_with($name, '/') || preg_match('/^[A-Za-z]:\//', $name) === 1 || str_contains($name, "\0") || preg_match('#(?:^|/)\.\.(?:/|$)#', $name) || preg_match('/[\x00-\x1F\x7F:]/u', $name) === 1) { - throw new RuntimeException('Package archive contains an unsafe path.'); + $cleanupFailed = false; + if ($published) { + clearstatcache(true, $destinationPath); + $destStat = @lstat($destinationPath); + if (is_array($destStat) && !is_link($destinationPath) + && is_array($temporaryIdentity) + && (int) ($destStat['dev'] ?? -2) === (int) ($temporaryIdentity['dev'] ?? -1) + && (int) ($destStat['ino'] ?? 0) === (int) ($temporaryIdentity['ino'] ?? -1) + && (((int) ($destStat['mode'] ?? 0) & 0170000) === 0100000)) { + if (!@unlink($destinationPath) && (file_exists($destinationPath) || is_link($destinationPath))) { + $cleanupFailed = true; + } + } elseif (file_exists($destinationPath) || is_link($destinationPath)) { + // Do not delete a destination whose identity cannot be proved to belong to this operation. + $cleanupFailed = true; + } } - foreach (explode('/', $name) as $segment) { - if ($segment === '' || $segment === '.' || $segment === '..' || str_ends_with($segment, ' ') || str_ends_with($segment, '.') || preg_match('/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i', $segment) === 1) { - throw new RuntimeException('Package archive contains an unsafe path.'); + + clearstatcache(true, $temporaryPath); + if (file_exists($temporaryPath) || is_link($temporaryPath)) { + $tempStat = @lstat($temporaryPath); + if (is_array($tempStat) && !is_link($temporaryPath) + && is_array($temporaryIdentity) + && (int) ($tempStat['dev'] ?? -2) === (int) ($temporaryIdentity['dev'] ?? -1) + && (int) ($tempStat['ino'] ?? 0) === (int) ($temporaryIdentity['ino'] ?? -1) + && (((int) ($tempStat['mode'] ?? 0) & 0170000) === 0100000)) { + if (!@unlink($temporaryPath) && (file_exists($temporaryPath) || is_link($temporaryPath))) { + $cleanupFailed = true; + } + } else { + $cleanupFailed = true; } } + + clearstatcache(true, $temporaryPath); + clearstatcache(true, $destinationPath); + if (file_exists($temporaryPath) || is_link($temporaryPath) + || ($published && (file_exists($destinationPath) || is_link($destinationPath)))) { + $cleanupFailed = true; + } + if ($cleanupFailed) { + throw new PackageStorageException('Unable to clean failed package archive output safely.', 0, $previous); + } } + private function assertArchivePath(string $name, bool $untrustedInput = false): void + { + $invalid = $name === '' + || strlen($name) > 4096 + || str_starts_with($name, '/') + || preg_match('/^[A-Za-z]:\//', $name) === 1 + || str_contains($name, "\0") + || preg_match('#(?:^|/)\.\.(?:/|$)#', $name) === 1 + || preg_match('/[\x00-\x1F\x7F:]/u', $name) === 1; + if (!$invalid) { + foreach (explode('/', $name) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..' + || str_ends_with($segment, ' ') || str_ends_with($segment, '.') + || preg_match('/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i', $segment) === 1) { + $invalid = true; + break; + } + } + } + if (!$invalid) return; + if ($untrustedInput) { + throw new PackageRestoreRejectedException('Package archive contains an unsafe path.'); + } + throw new RuntimeException('Package archive contains an unsafe path.'); + } private function writeAll($handle, string $data): void { $offset = 0; $length = strlen($data); while ($offset < $length) { $written = fwrite($handle, substr($data, $offset)); - if ($written === false || $written === 0) throw new RuntimeException('Unable to write package data.'); + if ($written === false || $written === 0) throw new PackageStorageException('Unable to write package data.'); $offset += $written; } } @@ -184,11 +420,40 @@ private function assertZipRuntime(): void if (!extension_loaded('zip') || !class_exists(ZipArchive::class)) throw new RuntimeException('PHP Zip extension is required.'); } - private function removeDirectory(string $path): void + private function removeDirectory(string $path, array $expectedIdentity): void { - if (!is_dir($path)) return; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \FilesystemIterator::CHILD_FIRST); - foreach ($iterator as $item) $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); - @rmdir($path); + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat) + || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0040000) + || (int) ($stat['dev'] ?? -2) !== (int) ($expectedIdentity['dev'] ?? -1) + || ((int) ($expectedIdentity['ino'] ?? 0) !== 0 + && (int) ($stat['ino'] ?? 0) !== (int) $expectedIdentity['ino'])) { + throw new PackageStorageException('Extraction directory identity changed before cleanup.'); + } + + $failed = false; + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $itemPath = $item->getPathname(); + if ($item->isLink() || !$item->isDir()) { + if (!@unlink($itemPath) && (file_exists($itemPath) || is_link($itemPath))) $failed = true; + } elseif (!@rmdir($itemPath) && is_dir($itemPath)) { + $failed = true; + } + } + } catch (\Throwable $exception) { + throw new PackageStorageException('Unable to inspect extraction cleanup paths.', 0, $exception); + } + + if (!@rmdir($path) && is_dir($path)) $failed = true; + if ($failed || file_exists($path) || is_link($path)) { + throw new PackageStorageException('Unable to remove failed package extraction.'); + } } } diff --git a/app/Project/PackageCatalog.php b/app/Project/PackageCatalog.php index 40625b15..72e3a274 100644 --- a/app/Project/PackageCatalog.php +++ b/app/Project/PackageCatalog.php @@ -10,10 +10,21 @@ final class PackageCatalog { public function __construct(private readonly string $directory) { - if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { + if (is_link($directory)) { + throw new RuntimeException('Package catalog directory must not be a symlink.'); + } + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { throw new RuntimeException('Unable to initialize package catalog.'); } - @chmod($directory, 0700); + clearstatcache(true, $directory); + $directoryStat = @lstat($directory); + if (!is_array($directoryStat) || is_link($directory) + || (((int) ($directoryStat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Package catalog directory is invalid.'); + } + if (!@chmod($directory, 0700)) { + throw new RuntimeException('Unable to protect package catalog directory.'); + } } public function create(string $packageId, string $ownerId, string $name): array @@ -44,38 +55,14 @@ public function create(string $packageId, string $ownerId, string $name): array public function get(string $packageId): array { - $file = $this->file($packageId); - if (is_link($file) || !is_file($file)) { - throw new RuntimeException('Package does not exist.'); - } - $raw = @file_get_contents($file); - if ($raw === false) { - throw new RuntimeException('Package record is unavailable.'); - } - try { - $data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); - } catch (\JsonException) { - throw new RuntimeException('Package record is invalid.'); - } - if (!is_array($data) - || ($data['package_id'] ?? null) !== $packageId - || !is_string($data['owner_hash'] ?? null) - || !preg_match('/^[a-f0-9]{64}$/', $data['owner_hash']) - || !is_string($data['name_hash'] ?? null) - || !preg_match('/^[a-f0-9]{64}$/', $data['name_hash']) - || !is_int($data['created_at'] ?? null) - || !(is_int($data['expires_at'] ?? null) || ($data['expires_at'] ?? null) === null) - || !(is_int($data['revoked_at'] ?? null) || ($data['revoked_at'] ?? null) === null)) { - throw new RuntimeException('Package record is invalid.'); - } - return $data; + return $this->readRecordFile($this->file($packageId), $packageId, true); } public function assertOwner(string $packageId, string $ownerId): array { $record = $this->get($packageId); if (!hash_equals((string) $record['owner_hash'], hash('sha256', $ownerId))) { - throw new RuntimeException('Package does not exist.'); + throw new PackageNotAvailableException(); } return $record; } @@ -84,8 +71,8 @@ public function assertOwner(string $packageId, string $ownerId): array public function assertAvailable(string $packageId): array { $record = $this->get($packageId); - if (($record['revoked_at'] ?? null) !== null) throw new RuntimeException('Package is revoked.'); - if (($record['expires_at'] ?? null) !== null && (int) $record['expires_at'] <= time()) throw new RuntimeException('Package is expired.'); + if (($record['revoked_at'] ?? null) !== null) throw new PackageNotAvailableException(); + if (($record['expires_at'] ?? null) !== null && (int) $record['expires_at'] <= time()) throw new PackageNotAvailableException(); return $record; } @@ -95,8 +82,14 @@ public function delete(string $packageId, string $ownerId): void $lock = $this->acquireLock($packageId); try { $this->assertOwner($packageId, $ownerId); - if (!@unlink($file) && is_file($file)) { - throw new RuntimeException('Unable to delete package record.'); + $identity = $this->regularFileIdentity($file, false); + $this->assertPathIdentity($file, $identity, 'Package catalog record changed before deletion.'); + if (!@unlink($file) && (file_exists($file) || is_link($file))) { + throw new PackageStorageException('Unable to delete package record.'); + } + clearstatcache(true, $file); + if (file_exists($file) || is_link($file)) { + throw new PackageStorageException('Package catalog record remains after deletion.'); } } finally { $this->releaseLock($lock); @@ -134,14 +127,36 @@ public function listForOwner(string $ownerId): array { $hash = hash('sha256', $ownerId); $items = []; - foreach (glob($this->directory . DIRECTORY_SEPARATOR . '*.json') ?: [] as $file) { - if (is_link($file) || !is_file($file)) continue; - $data = json_decode((string) @file_get_contents($file), true); - if (!is_array($data) || !hash_equals((string) ($data['owner_hash'] ?? ''), $hash)) { - continue; + + try { + $iterator = new \FilesystemIterator($this->directory, \FilesystemIterator::SKIP_DOTS); + foreach ($iterator as $entry) { + $fileName = $entry->getFilename(); + if (!str_ends_with($fileName, '.json')) continue; + + $packageId = substr($fileName, 0, -5); + try { + PackageId::assert($packageId); + } catch (RuntimeException $exception) { + throw new RuntimeException('Package catalog contains an invalid record path.', 0, $exception); + } + + $file = $entry->getPathname(); + if ($entry->isLink() || !$entry->isFile() || is_link($file)) { + throw new RuntimeException('Package catalog contains a non-regular record.'); + } + $data = $this->readRecordFile($file, $packageId, false); + + if (hash_equals($data['owner_hash'], $hash)) { + $items[] = $data; + } } - $items[] = $data; + } catch (\RuntimeException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new RuntimeException('Unable to enumerate package catalog.', 0, $exception); } + usort($items, static fn(array $a, array $b): int => ((int) ($b['created_at'] ?? 0)) <=> ((int) ($a['created_at'] ?? 0))); return $items; } @@ -149,27 +164,163 @@ public function listForOwner(string $ownerId): array private function acquireLock(string $packageId) { $path = $this->file($packageId) . '.lock'; + clearstatcache(true, $path); if (is_link($path)) { - throw new RuntimeException('Package catalog lock is invalid.'); + throw new PackageStorageException('Package catalog lock is invalid.'); + } + + // Create the lock atomically when absent; otherwise open only a verified regular file. + $handle = @fopen($path, 'x+b'); + if ($handle === false) { + clearstatcache(true, $path); + $before = @lstat($path); + if (!is_array($before) || is_link($path) + || (((int) ($before['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new PackageStorageException('Package catalog lock is invalid.'); + } + $handle = @fopen($path, 'r+b'); + if ($handle === false) { + throw new PackageStorageException('Unable to open package catalog lock.'); + } + $expected = ['dev' => (int) ($before['dev'] ?? -1), 'ino' => (int) ($before['ino'] ?? 0)]; + } else { + $created = fstat($handle); + if (!is_array($created) + || (((int) ($created['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($created['ino'] ?? 0) === 0) { + fclose($handle); + throw new PackageStorageException('Unable to verify new package catalog lock.'); + } + $expected = ['dev' => (int) ($created['dev'] ?? -1), 'ino' => (int) $created['ino']]; } - $handle = @fopen($path, 'c+b'); - if ($handle === false || !flock($handle, LOCK_EX)) { - if (is_resource($handle)) fclose($handle); - throw new RuntimeException('Unable to lock package catalog record.'); + + try { + $this->assertLockIdentity($path, $handle, $expected); + if (!flock($handle, LOCK_EX)) { + throw new PackageStorageException('Unable to lock package catalog record.'); + } + $this->assertLockIdentity($path, $handle, $expected); + if (!@chmod($path, 0600)) { + throw new PackageStorageException('Unable to protect package catalog lock.'); + } + return $handle; + } catch (\Throwable $exception) { + if (is_resource($handle)) { + @flock($handle, LOCK_UN); + fclose($handle); + } + throw $exception; } - @chmod($path, 0600); - return $handle; } - private function releaseLock($handle): void { - flock($handle, LOCK_UN); - fclose($handle); + if (!flock($handle, LOCK_UN)) { + fclose($handle); + throw new PackageStorageException('Unable to release package catalog lock.'); + } + if (!fclose($handle)) { + throw new PackageStorageException('Unable to close package catalog lock.'); + } + } + + private function readRecordFile(string $file, string $packageId, bool $missingIsUnavailable): array + { + clearstatcache(true, $file); + $pathStat = @lstat($file); + if (!is_array($pathStat) || is_link($file) + || (((int) ($pathStat['mode'] ?? 0) & 0170000) !== 0100000)) { + if ($missingIsUnavailable) { + throw new PackageNotAvailableException(); + } + throw new RuntimeException('Package catalog contains a non-regular record.'); + } + $identity = ['dev' => (int) ($pathStat['dev'] ?? -1), 'ino' => (int) ($pathStat['ino'] ?? 0)]; + if ($identity['ino'] === 0) throw new RuntimeException('Unable to verify package catalog record identity.'); + + $handle = @fopen($file, 'rb'); + if ($handle === false) throw new RuntimeException('Unable to read package catalog record.'); + try { + $handleStat = fstat($handle); + if (!is_array($handleStat) + || (((int) ($handleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($handleStat['dev'] ?? -2) !== $identity['dev'] + || (int) ($handleStat['ino'] ?? 0) !== $identity['ino']) { + throw new RuntimeException('Package catalog record identity changed while opening.'); + } + $raw = stream_get_contents($handle, 16385); + if ($raw === false) throw new RuntimeException('Unable to read package catalog record.'); + if (strlen($raw) > 16384) throw new RuntimeException('Package catalog record exceeds its size limit.'); + $this->assertPathIdentity($file, $identity, 'Package catalog record identity changed while reading.'); + } finally { + fclose($handle); + } + + try { + $data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new RuntimeException('Package catalog record is corrupt.', 0, $exception); + } + if (!is_array($data) + || ($data['package_id'] ?? null) !== $packageId + || !is_string($data['owner_hash'] ?? null) + || !preg_match('/^[a-f0-9]{64}$/', $data['owner_hash']) + || !is_string($data['name_hash'] ?? null) + || !preg_match('/^[a-f0-9]{64}$/', $data['name_hash']) + || !is_int($data['created_at'] ?? null) + || !(is_int($data['expires_at'] ?? null) || ($data['expires_at'] ?? null) === null) + || !(is_int($data['revoked_at'] ?? null) || ($data['revoked_at'] ?? null) === null)) { + throw new RuntimeException('Package catalog record is invalid.'); + } + return $data; + } + + private function regularFileIdentity(string $path, bool $missingIsUnavailable): array + { + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat) || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000)) { + if ($missingIsUnavailable) throw new PackageNotAvailableException(); + throw new PackageStorageException('Package catalog record is unavailable for deletion.'); + } + return ['dev' => (int) ($stat['dev'] ?? -1), 'ino' => (int) ($stat['ino'] ?? 0)]; + } + + private function assertPathIdentity(string $path, array $expected, string $message): void + { + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat) || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($stat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($stat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1)) { + throw new PackageStorageException($message); + } + } + + private function assertLockIdentity(string $path, $handle, array $expected): void + { + clearstatcache(true, $path); + $pathStat = @lstat($path); + $handleStat = fstat($handle); + if (!is_array($pathStat) || !is_array($handleStat) || is_link($path) + || (((int) ($pathStat['mode'] ?? 0) & 0170000) !== 0100000) + || (((int) ($handleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($pathStat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($pathStat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1) + || (int) ($handleStat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($handleStat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1)) { + throw new PackageStorageException('Package catalog lock identity changed.'); + } } private function file(string $packageId): string { - PackageId::assert($packageId); + try { + PackageId::assert($packageId); + } catch (RuntimeException) { + throw new PackageNotAvailableException(); + } return $this->directory . DIRECTORY_SEPARATOR . $packageId . '.json'; } } diff --git a/app/Project/PackageNotAvailableException.php b/app/Project/PackageNotAvailableException.php new file mode 100644 index 00000000..8c182b3a --- /dev/null +++ b/app/Project/PackageNotAvailableException.php @@ -0,0 +1,15 @@ +assertPackageDirectory($packageDir); $root = realpath($packageDir); - if ($root === false) throw new RuntimeException('Unable to open package.'); + if ($root === false) throw new PackageRestoreRejectedException('Unable to open package.'); $binding = (string) $header['header_binding']; $salt = base64_decode((string) $header['salt'], true); - if ($salt === false) throw new RuntimeException('Unable to open package.'); + if ($salt === false) throw new PackageRestoreRejectedException('Unable to open package.'); $packageRootKey = null; $manifestKey = null; @@ -42,24 +43,27 @@ public function restore(string $packageDir, string $destinationDir, string $pass $credentialKey = V14KeyDerivation::credentialWrapKey($password, $pattern, $salt); $packageRootKey = V14Aead::unwrapKey($header['key_slots']['primary'], $credentialKey, 'SecurePackage|V14|slot|primary|' . $header['package_id'] . '|' . $binding); } - if (strlen($packageRootKey) !== V14Descriptor::KEY_BYTES) throw new RuntimeException('Invalid package key.'); + if (strlen($packageRootKey) !== V14Descriptor::KEY_BYTES) throw new PackageRestoreRejectedException('Invalid package key.'); $manifestKey = V14KeyDerivation::deriveSubkey($packageRootKey, 'manifest', $header['package_id']); $filenameKey = V14KeyDerivation::deriveSubkey($packageRootKey, 'filename', $header['package_id']); $fileRootKey = V14KeyDerivation::deriveSubkey($packageRootKey, 'files', $header['package_id']); $manifestPath = $root . DIRECTORY_SEPARATOR . 'manifest.enc'; $manifestCipher = file_get_contents($manifestPath); - if ($manifestCipher === false || strlen($manifestCipher) > V14Descriptor::MAX_MANIFEST_BYTES + 64) throw new RuntimeException('Unable to open package.'); + if ($manifestCipher === false) throw new PackageStorageException('Unable to read encrypted manifest.'); + if (strlen($manifestCipher) > V14Descriptor::MAX_MANIFEST_BYTES + 64) throw new PackageRestoreRejectedException('Unable to open package.'); $manifestPlain = V14Aead::open($manifestCipher, $manifestKey, 'SecurePackage|V14|manifest|' . $header['package_id'] . '|' . $binding); - if (strlen($manifestPlain) > V14Descriptor::MAX_MANIFEST_BYTES) throw new RuntimeException('Manifest exceeds V14 limits.'); + if (strlen($manifestPlain) > V14Descriptor::MAX_MANIFEST_BYTES) throw new PackageRestoreRejectedException('Manifest exceeds V14 limits.'); $manifest = json_decode($manifestPlain, true, 64, JSON_THROW_ON_ERROR); $this->validateManifest($manifest, $header); - $complete = json_decode((string) file_get_contents($root . DIRECTORY_SEPARATOR . 'complete.json'), true, 32, JSON_THROW_ON_ERROR); - if (!is_array($complete)) throw new RuntimeException('Package completion record is invalid.'); + $completeRaw = file_get_contents($root . DIRECTORY_SEPARATOR . 'complete.json'); + if ($completeRaw === false) throw new PackageStorageException('Unable to read package completion record.'); + $complete = json_decode($completeRaw, true, 32, JSON_THROW_ON_ERROR); + if (!is_array($complete)) throw new PackageRestoreRejectedException('Package completion record is invalid.'); $completeKeys = array_keys($complete); sort($completeKeys); if ($completeKeys !== ['created_at', 'format', 'header_binding', 'manifest_sha256', 'package_id', 'version'] || ($complete['format'] ?? null) !== V14Descriptor::FORMAT || (int) ($complete['version'] ?? 0) !== V14Descriptor::VERSION || ($complete['package_id'] ?? null) !== $header['package_id'] || ($complete['created_at'] ?? null) !== $header['created_at'] || ($complete['header_binding'] ?? null) !== $binding || !hash_equals((string) ($complete['manifest_sha256'] ?? ''), hash('sha256', $manifestCipher))) { - throw new RuntimeException('Package completion record is invalid.'); + throw new PackageRestoreRejectedException('Package completion record is invalid.'); } $blobs = $this->inventoryBlobs($root . DIRECTORY_SEPARATOR . 'blobs'); @@ -68,19 +72,36 @@ public function restore(string $packageDir, string $destinationDir, string $pass foreach ($manifest['nodes'] as $node) { if (($node['type'] ?? null) !== 'file') continue; $blob = $node['blob']; - if (isset($referenced[$blob])) throw new RuntimeException('Duplicate blob reference.'); + if (isset($referenced[$blob])) throw new PackageRestoreRejectedException('Duplicate blob reference.'); $referenced[$blob] = true; - if (!isset($blobs[$blob])) throw new RuntimeException('Manifest references a missing blob.'); + if (!isset($blobs[$blob])) throw new PackageRestoreRejectedException('Manifest references a missing blob.'); $actualHash = hash_file('sha256', $blobs[$blob]); - if ($actualHash === false || !hash_equals((string) $node['cipher_hash'], $actualHash)) throw new RuntimeException('Ciphertext integrity check failed.'); + if ($actualHash === false) throw new PackageStorageException('Unable to hash package blob.'); + if (!hash_equals((string) $node['cipher_hash'], $actualHash)) throw new PackageRestoreRejectedException('Ciphertext integrity check failed.'); $leaves[] = V14Merkle::leaf((string) $node['id'], (string) $node['cipher_hash'], (int) $node['size']); } - if (count($referenced) !== count($blobs)) throw new RuntimeException('Package contains unreachable blobs.'); - if (!hash_equals((string) $manifest['merkle_root'], V14Merkle::root($leaves))) throw new RuntimeException('Merkle integrity verification failed.'); + if (count($referenced) !== count($blobs)) throw new PackageRestoreRejectedException('Package contains unreachable blobs.'); + if (!hash_equals((string) $manifest['merkle_root'], V14Merkle::root($leaves))) throw new PackageRestoreRejectedException('Merkle integrity verification failed.'); - if (file_exists($destinationDir)) throw new RuntimeException('Destination directory already exists.'); - if (!mkdir($destinationDir, 0700, true)) throw new RuntimeException('Unable to create destination directory.'); - $created = true; + if (file_exists($destinationDir) || is_link($destinationDir)) throw new PackageStorageException('Restore target already exists.'); + if (!mkdir($destinationDir, 0700, true)) throw new PackageStorageException('Unable to create destination directory.'); + $destinationStat = @lstat($destinationDir); + if (!is_array($destinationStat) || is_link($destinationDir) + || (((int) ($destinationStat['mode'] ?? 0) & 0170000) !== 0040000)) { + if (is_dir($destinationDir) && !is_link($destinationDir)) { + if (!@rmdir($destinationDir) && is_dir($destinationDir)) { + throw new PackageStorageException('Unable to clean restore destination after identity verification failed.'); + } + } + if (file_exists($destinationDir) || is_link($destinationDir)) { + throw new PackageStorageException('Unverified restore destination remains after cleanup.'); + } + throw new PackageStorageException('Unable to verify restore destination identity.'); + } + $destinationIdentity = [ + 'dev' => (int) ($destinationStat['dev'] ?? -1), + 'ino' => (int) ($destinationStat['ino'] ?? 0), + ]; try { $paths = $this->preflightPaths($manifest, $filenameKey, $header['package_id'], $binding, $destinationDir); $directories = []; @@ -92,7 +113,7 @@ public function restore(string $packageDir, string $destinationDir, string $pass usort($directories, static fn(array $a, array $b): int => ($paths[$a['id']]['depth'] <=> $paths[$b['id']]['depth']) ?: strcmp($paths[$a['id']]['normalized'], $paths[$b['id']]['normalized'])); foreach ($directories as $node) { $target = $paths[$node['id']]['target']; - if (!mkdir($target, 0700) && !is_dir($target)) throw new RuntimeException('Unable to restore directory.'); + if (!mkdir($target, 0700) && !is_dir($target)) throw new PackageStorageException('Unable to restore directory.'); } usort($files, static fn(array $a, array $b): int => strcmp($paths[$a['id']]['normalized'], $paths[$b['id']]['normalized'])); foreach ($files as $node) { @@ -107,8 +128,11 @@ public function restore(string $packageDir, string $destinationDir, string $pass } } } catch (\Throwable $e) { - $this->removeDirectory($destinationDir); - $created = false; + try { + $this->removeDirectory($destinationDir, $destinationIdentity); + } catch (\Throwable $cleanupError) { + throw new PackageStorageException('Unable to clean failed restore output.', 0, $cleanupError); + } throw $e; } @@ -122,8 +146,14 @@ public function restore(string $packageDir, string $destinationDir, string $pass 'total_plain_bytes' => (int) $manifest['total_plain_bytes'], 'merkle_root' => $manifest['merkle_root'], ]; - } catch (\Throwable $e) { - throw new RuntimeException('Unable to open V14 package.', 0, $e); + } catch (PackageStorageException $exception) { + throw $exception; + } catch (PackageRestoreRejectedException $exception) { + throw $exception; + } catch (\JsonException|V14AuthenticationException|V14StreamRejectedException $exception) { + throw new PackageRestoreRejectedException('Unable to open V14 package.', 0, $exception); + } catch (\Throwable $exception) { + throw $exception; } finally { if ($credentialKey !== null) sodium_memzero($credentialKey); if ($packageRootKey !== null) sodium_memzero($packageRootKey); @@ -137,60 +167,61 @@ public function restore(string $packageDir, string $destinationDir, string $pass private function validateManifest(array $manifest, array $header): void { $manifestKeys = array_keys($manifest); sort($manifestKeys); - if ($manifestKeys !== ['directories', 'files', 'format', 'header_binding', 'merkle_root', 'nodes', 'package_id', 'schema', 'total_plain_bytes', 'version']) throw new RuntimeException('Invalid manifest.'); - if (($manifest['format'] ?? null) !== V14Descriptor::FORMAT || (int) ($manifest['version'] ?? 0) !== V14Descriptor::VERSION || (int) ($manifest['schema'] ?? 0) !== V14Descriptor::SCHEMA || ($manifest['package_id'] ?? null) !== $header['package_id'] || ($manifest['header_binding'] ?? null) !== $header['header_binding']) throw new RuntimeException('Invalid manifest.'); - foreach (['files', 'directories', 'total_plain_bytes'] as $field) if (!isset($manifest[$field]) || !is_int($manifest[$field]) || $manifest[$field] < 0) throw new RuntimeException('Invalid manifest.'); - if (!isset($manifest['nodes']) || !is_array($manifest['nodes']) || $manifest['nodes'] === [] || count($manifest['nodes']) > V14Descriptor::MAX_NODES) throw new RuntimeException('Invalid manifest.'); - if ($manifest['files'] < 1 || $manifest['files'] + $manifest['directories'] !== count($manifest['nodes']) || $manifest['total_plain_bytes'] > V14Descriptor::MAX_TOTAL_PLAIN_BYTES || !isset($manifest['merkle_root']) || !preg_match('/^[a-f0-9]{64}$/', (string) $manifest['merkle_root'])) throw new RuntimeException('Invalid manifest.'); + if ($manifestKeys !== ['directories', 'files', 'format', 'header_binding', 'merkle_root', 'nodes', 'package_id', 'schema', 'total_plain_bytes', 'version']) throw new PackageRestoreRejectedException('Invalid manifest.'); + if (($manifest['format'] ?? null) !== V14Descriptor::FORMAT || (int) ($manifest['version'] ?? 0) !== V14Descriptor::VERSION || (int) ($manifest['schema'] ?? 0) !== V14Descriptor::SCHEMA || ($manifest['package_id'] ?? null) !== $header['package_id'] || ($manifest['header_binding'] ?? null) !== $header['header_binding']) throw new PackageRestoreRejectedException('Invalid manifest.'); + foreach (['files', 'directories', 'total_plain_bytes'] as $field) if (!isset($manifest[$field]) || !is_int($manifest[$field]) || $manifest[$field] < 0) throw new PackageRestoreRejectedException('Invalid manifest.'); + if (!isset($manifest['nodes']) || !is_array($manifest['nodes']) || $manifest['nodes'] === [] || count($manifest['nodes']) > V14Descriptor::MAX_NODES) throw new PackageRestoreRejectedException('Invalid manifest.'); + if ($manifest['files'] < 1 || $manifest['files'] + $manifest['directories'] !== count($manifest['nodes']) || $manifest['total_plain_bytes'] > V14Descriptor::MAX_TOTAL_PLAIN_BYTES || !isset($manifest['merkle_root']) || !preg_match('/^[a-f0-9]{64}$/', (string) $manifest['merkle_root'])) throw new PackageRestoreRejectedException('Invalid manifest.'); $ids = []; $types = []; $total = 0; foreach ($manifest['nodes'] as $node) { - if (!is_array($node)) throw new RuntimeException('Invalid manifest node.'); + if (!is_array($node)) throw new PackageRestoreRejectedException('Invalid manifest node.'); $keys = array_keys($node); sort($keys); $type = (string) ($node['type'] ?? ''); $expected = $type === 'file' ? ['cipher_hash','blob','id','name','parent','size','type'] : ['id','name','parent','type']; $expected2 = $expected; sort($expected2); - if ($keys !== $expected2 || !in_array($type, ['file', 'dir'], true)) throw new RuntimeException('Invalid manifest node.'); + if ($keys !== $expected2 || !in_array($type, ['file', 'dir'], true)) throw new PackageRestoreRejectedException('Invalid manifest node.'); $id = (string) ($node['id'] ?? ''); - if (!preg_match('/^[a-f0-9]{32}$/', $id) || isset($ids[$id])) throw new RuntimeException('Invalid manifest node id.'); + if (!preg_match('/^[a-f0-9]{32}$/', $id) || isset($ids[$id])) throw new PackageRestoreRejectedException('Invalid manifest node id.'); $ids[$id] = true; $types[$id] = $type; - if (!is_string($node['name']) || strlen($node['name']) > 1_000_000 || base64_decode($node['name'], true) === false) throw new RuntimeException('Invalid encrypted name.'); + if (!is_string($node['name']) || strlen($node['name']) > 1_000_000 || base64_decode($node['name'], true) === false) throw new PackageRestoreRejectedException('Invalid encrypted name.'); $parent = $node['parent'] ?? null; - if ($parent !== null && (!is_string($parent) || !preg_match('/^[a-f0-9]{32}$/', $parent))) throw new RuntimeException('Invalid manifest parent.'); + if ($parent !== null && (!is_string($parent) || !preg_match('/^[a-f0-9]{32}$/', $parent))) throw new PackageRestoreRejectedException('Invalid manifest parent.'); if ($type === 'file') { - if (!is_string($node['blob']) || !preg_match('/^[a-f0-9]{48}\.bin$/', $node['blob']) || !preg_match('/^[a-f0-9]{64}$/', (string) $node['cipher_hash']) || !is_int($node['size']) || $node['size'] < 0 || $node['size'] > V14Descriptor::MAX_FILE_BYTES) throw new RuntimeException('Invalid manifest file.'); + if (!is_string($node['blob']) || !preg_match('/^[a-f0-9]{48}\.bin$/', $node['blob']) || !preg_match('/^[a-f0-9]{64}$/', (string) $node['cipher_hash']) || !is_int($node['size']) || $node['size'] < 0 || $node['size'] > V14Descriptor::MAX_FILE_BYTES) throw new PackageRestoreRejectedException('Invalid manifest file.'); $total += $node['size']; - if ($total > V14Descriptor::MAX_TOTAL_PLAIN_BYTES) throw new RuntimeException('Manifest plaintext total exceeds V14 limits.'); + if ($total > V14Descriptor::MAX_TOTAL_PLAIN_BYTES) throw new PackageRestoreRejectedException('Manifest plaintext total exceeds V14 limits.'); } } $files = 0; $dirs = 0; foreach ($manifest['nodes'] as $node) { $type = $node['type']; $parent = $node['parent']; - if ($parent !== null && (!isset($types[$parent]) || $types[$parent] !== 'dir')) throw new RuntimeException('Manifest parent is not a directory.'); + if ($parent !== null && (!isset($types[$parent]) || $types[$parent] !== 'dir')) throw new PackageRestoreRejectedException('Manifest parent is not a directory.'); $type === 'file' ? $files++ : $dirs++; } - if ($files !== $manifest['files'] || $dirs !== $manifest['directories'] || $total !== $manifest['total_plain_bytes']) throw new RuntimeException('Manifest totals are invalid.'); + if ($files !== $manifest['files'] || $dirs !== $manifest['directories'] || $total !== $manifest['total_plain_bytes']) throw new PackageRestoreRejectedException('Manifest totals are invalid.'); } private function inventoryBlobs(string $dir): array { $entries = scandir($dir); - if ($entries === false) throw new RuntimeException('Unable to inventory package blobs.'); + if ($entries === false) throw new PackageStorageException('Unable to inventory package blobs.'); $map = []; foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') continue; - if (!preg_match('/^[a-f0-9]{48}\.bin$/', $entry)) throw new RuntimeException('Invalid package blob name.'); + if (!preg_match('/^[a-f0-9]{48}\.bin$/', $entry)) throw new PackageRestoreRejectedException('Invalid package blob name.'); $path = $dir . DIRECTORY_SEPARATOR . $entry; - if (is_link($path) || !is_file($path)) throw new RuntimeException('Invalid package blob.'); + if (is_link($path) || !is_file($path)) throw new PackageRestoreRejectedException('Invalid package blob.'); $size = filesize($path); - if ($size === false || $size < SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES + 8 || $size > V14Descriptor::MAX_FILE_BYTES + 64 * 1024 * 1024) throw new RuntimeException('Package blob exceeds limits.'); + if ($size === false) throw new PackageStorageException('Unable to inspect package blob.'); + if ($size < SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES + 8 || $size > V14Descriptor::MAX_FILE_BYTES + 64 * 1024 * 1024) throw new PackageRestoreRejectedException('Package blob exceeds limits.'); $map[$entry] = $path; - if (count($map) > V14Descriptor::MAX_BLOBS) throw new RuntimeException('Too many package blobs.'); + if (count($map) > V14Descriptor::MAX_BLOBS) throw new PackageRestoreRejectedException('Too many package blobs.'); } return $map; } @@ -207,13 +238,13 @@ private function preflightPaths(array $manifest, string $filenameKey, string $pa $cursor = $node; $guard = 0; while ($cursor !== null) { - if (++$guard > V14Descriptor::MAX_DEPTH) throw new RuntimeException('Manifest hierarchy is too deep.'); + if (++$guard > V14Descriptor::MAX_DEPTH) throw new PackageRestoreRejectedException('Manifest hierarchy is too deep.'); $name = $this->decryptName($cursor['name'], $filenameKey, $packageId, $binding, $cursor['id']); $this->assertName($name); array_unshift($parts, $name); $parent = $cursor['parent']; if ($parent === null) { $cursor = null; continue; } - if (!isset($ids[$parent]) || $ids[$parent]['type'] !== 'dir') throw new RuntimeException('Manifest hierarchy is invalid.'); + if (!isset($ids[$parent]) || $ids[$parent]['type'] !== 'dir') throw new PackageRestoreRejectedException('Manifest hierarchy is invalid.'); $cursor = $ids[$parent]; } $safeParts = []; @@ -221,7 +252,7 @@ private function preflightPaths(array $manifest, string $filenameKey, string $pa static $casefold = null; $casefold ??= static fn(string $value): string => function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); $normalized = implode('/', array_map($casefold, $safeParts)); - if (isset($all[$normalized])) throw new RuntimeException('Duplicate restored path.'); + if (isset($all[$normalized])) throw new PackageRestoreRejectedException('Duplicate restored path.'); $all[$normalized] = ['id' => $id, 'type' => $node['type']]; $target = $destinationDir . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $safeParts); $resolved[$id] = ['target' => $target, 'normalized' => $normalized]; @@ -232,12 +263,12 @@ private function preflightPaths(array $manifest, string $filenameKey, string $pa $prev = $all[$pathList[$i - 1]]; $prevPath = $pathList[$i - 1]; $currentPath = $pathList[$i]; - if (str_starts_with($currentPath, $prevPath . '/') && $prev['type'] === 'file') throw new RuntimeException('File/directory path conflict.'); + if (str_starts_with($currentPath, $prevPath . '/') && $prev['type'] === 'file') throw new PackageRestoreRejectedException('File/directory path conflict.'); } // Existing destination conflicts are checked before creating any child path. foreach ($resolved as $entry) { $target = $entry['target']; - if (file_exists($target) || is_link($target)) throw new RuntimeException('Restore target already exists.'); + if (file_exists($target) || is_link($target)) throw new PackageStorageException('Restore target already exists.'); } return $resolved; } @@ -245,28 +276,67 @@ private function preflightPaths(array $manifest, string $filenameKey, string $pa private function decryptName(string $encoded, string $filenameKey, string $packageId, string $binding, string $id): string { $packed = base64_decode($encoded, true); - if ($packed === false || strlen($packed) > 8192) throw new RuntimeException('Invalid encrypted name.'); + if ($packed === false || strlen($packed) > 8192) throw new PackageRestoreRejectedException('Invalid encrypted name.'); return V14Aead::open($packed, $filenameKey, 'SecurePackage|V14|name|' . $packageId . '|' . $binding . '|' . $id); } private function assertName(string $name): void { - if ($name === '' || $name === '.' || $name === '..' || strlen($name) > 255 || !preg_match('//u', $name) || str_contains($name, "\0") || str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F:]/u', $name) === 1 || str_ends_with($name, ' ') || str_ends_with($name, '.') || preg_match('/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i', $name) === 1 || trim($name, ' .') === '') throw new RuntimeException('Unsafe restored name.'); + if ($name === '' || $name === '.' || $name === '..' || strlen($name) > 255 || !preg_match('//u', $name) || str_contains($name, "\0") || str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F:]/u', $name) === 1 || str_ends_with($name, ' ') || str_ends_with($name, '.') || preg_match('/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i', $name) === 1 || trim($name, ' .') === '') throw new PackageRestoreRejectedException('Unsafe restored name.'); } private static function decodeRecoveryKey(string $encoded): string { - if (!preg_match('/^[A-Za-z0-9_-]{43}$/', $encoded)) throw new RuntimeException('Invalid recovery key.'); + if (!preg_match('/^[A-Za-z0-9_-]{43}$/', $encoded)) throw new PackageRestoreRejectedException('Invalid recovery key.'); $raw = base64_decode(strtr($encoded, '-_', '+/') . '=', true); - if ($raw === false || strlen($raw) !== V14Descriptor::KEY_BYTES) throw new RuntimeException('Invalid recovery key.'); + if ($raw === false || strlen($raw) !== V14Descriptor::KEY_BYTES) throw new PackageRestoreRejectedException('Invalid recovery key.'); return $raw; } - private function removeDirectory(string $path): void + private function removeDirectory(string $path, array $expectedIdentity): void + { + $this->assertOwnedDirectoryIdentity($path, $expectedIdentity); + try { + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $item) { + $this->assertOwnedDirectoryIdentity($path, $expectedIdentity); + $itemPath = $item->getPathname(); + if ($item->isLink() || !$item->isDir()) { + if (!@unlink($itemPath) && (file_exists($itemPath) || is_link($itemPath))) { + throw new RuntimeException('Unable to remove failed restore file.'); + } + } elseif (!@rmdir($itemPath) && is_dir($itemPath)) { + throw new RuntimeException('Unable to remove failed restore directory.'); + } + } + } catch (\Throwable $exception) { + throw new PackageStorageException('Unable to inspect failed restore cleanup paths.', 0, $exception); + } + + $this->assertOwnedDirectoryIdentity($path, $expectedIdentity); + if (!@rmdir($path) && is_dir($path)) { + throw new PackageStorageException('Unable to remove failed restore output.'); + } + clearstatcache(true, $path); + if (file_exists($path) || is_link($path)) { + throw new PackageStorageException('Failed restore output remains after cleanup.'); + } + } + + private function assertOwnedDirectoryIdentity(string $path, array $expectedIdentity): void { - if (!is_dir($path)) return; - $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); - foreach ($it as $item) $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); - @rmdir($path); + clearstatcache(true, $path); + $actual = @lstat($path); + if (!is_array($actual) + || is_link($path) + || (((int) ($actual['mode'] ?? 0) & 0170000) !== 0040000) + || (int) ($actual['dev'] ?? -2) !== (int) ($expectedIdentity['dev'] ?? -1) + || ((int) ($expectedIdentity['ino'] ?? 0) !== 0 + && (int) ($actual['ino'] ?? 0) !== (int) $expectedIdentity['ino'])) { + throw new PackageStorageException('Restore output identity changed during cleanup.'); + } } } diff --git a/app/Project/V14PackageService.php b/app/Project/V14PackageService.php index 7df2793b..11147325 100644 --- a/app/Project/V14PackageService.php +++ b/app/Project/V14PackageService.php @@ -3,7 +3,6 @@ namespace SecurePackage\Project; -use RuntimeException; use SecurePackage\Security\ProjectNameRegistryInterface; use SecurePackage\Security\Validator; use SecurePackage\Storage\FileStore; @@ -33,9 +32,17 @@ public function build(string $sourceDir, string $projectName, string $password, 'stats' => $stats, 'recovery_key' => $stats['recovery_key'] ?? null, ]; - } catch (\Throwable $e) { - $this->removeDirectory($packageDir); - throw $e; + } catch (\Throwable $exception) { + try { + $this->removeDirectory($packageDir); + } catch (\Throwable $cleanupException) { + throw new PackageStorageException( + 'Unable to clean incomplete package build.', + 0, + $cleanupException + ); + } + throw $exception; } finally { sodium_memzero($password); sodium_memzero($pattern); @@ -44,9 +51,30 @@ public function build(string $sourceDir, string $projectName, string $password, private function removeDirectory(string $path): void { + if (is_link($path) || is_file($path)) { + if (!unlink($path)) { + throw new PackageStorageException('Unable to remove incomplete package artifact.'); + } + return; + } if (!is_dir($path)) return; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \FilesystemIterator::CHILD_FIRST); - foreach ($iterator as $item) $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); - @rmdir($path); + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $itemPath = $item->getPathname(); + if ($item->isDir() && !$item->isLink()) { + if (!rmdir($itemPath)) { + throw new PackageStorageException('Unable to remove incomplete package directory.'); + } + } elseif (!unlink($itemPath)) { + throw new PackageStorageException('Unable to remove incomplete package file.'); + } + } + if (!rmdir($path)) { + throw new PackageStorageException('Unable to remove incomplete package directory.'); + } } } diff --git a/app/Project/V14PackageVerifier.php b/app/Project/V14PackageVerifier.php index 9f9b6e98..1562792b 100644 --- a/app/Project/V14PackageVerifier.php +++ b/app/Project/V14PackageVerifier.php @@ -3,47 +3,54 @@ namespace SecurePackage\Project; -use RuntimeException; -use SecurePackage\Security\CanonicalJson; final class V14PackageVerifier { public function assertPackageDirectory(string $packageDir): array { $root = realpath($packageDir); - if ($root === false || !is_dir($root) || is_link($packageDir)) throw new RuntimeException('Unable to open package.'); + if ($root === false || !is_dir($root) || is_link($packageDir)) throw new PackageRestoreRejectedException('Unable to open package.'); $headerPath = $root . DIRECTORY_SEPARATOR . 'header.json'; $manifestPath = $root . DIRECTORY_SEPARATOR . 'manifest.enc'; $completePath = $root . DIRECTORY_SEPARATOR . 'complete.json'; $blobDir = $root . DIRECTORY_SEPARATOR . 'blobs'; - if (!is_file($headerPath) || is_link($headerPath) || !is_file($manifestPath) || is_link($manifestPath) || !is_file($completePath) || is_link($completePath) || !is_dir($blobDir) || is_link($blobDir)) throw new RuntimeException('Unable to open package.'); + if (!is_file($headerPath) || is_link($headerPath) || !is_file($manifestPath) || is_link($manifestPath) || !is_file($completePath) || is_link($completePath) || !is_dir($blobDir) || is_link($blobDir)) throw new PackageRestoreRejectedException('Unable to open package.'); $completeSize = filesize($completePath); - if ($completeSize === false || $completeSize > 4096) throw new RuntimeException('Unable to open package.'); + if ($completeSize === false) throw new PackageStorageException('Unable to inspect package completion record.'); + if ($completeSize > 4096) throw new PackageRestoreRejectedException('Unable to open package.'); $headerRaw = file_get_contents($headerPath); - if ($headerRaw === false || strlen($headerRaw) > V14Descriptor::MAX_HEADER_BYTES) throw new RuntimeException('Unable to open package.'); - try { $header = json_decode($headerRaw, true, 32, JSON_THROW_ON_ERROR); } catch (\Throwable) { throw new RuntimeException('Unable to open package.'); } - if (!is_array($header)) throw new RuntimeException('Unable to open package.'); - V14Descriptor::assertHeader($header); + if ($headerRaw === false) throw new PackageStorageException('Unable to read package header.'); + if (strlen($headerRaw) > V14Descriptor::MAX_HEADER_BYTES) throw new PackageRestoreRejectedException('Unable to open package.'); + try { $header = json_decode($headerRaw, true, 32, JSON_THROW_ON_ERROR); } catch (\JsonException) { throw new PackageRestoreRejectedException('Unable to open package.'); } + if (!is_array($header)) throw new PackageRestoreRejectedException('Unable to open package.'); + try { + V14Descriptor::assertHeader($header); + } catch (\RuntimeException|\InvalidArgumentException $exception) { + throw new PackageRestoreRejectedException('Unable to open package.', 0, $exception); + } $manifestSize = filesize($manifestPath); - if ($manifestSize === false || $manifestSize < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES || $manifestSize > V14Descriptor::MAX_MANIFEST_BYTES + 64) throw new RuntimeException('Unable to open package.'); + if ($manifestSize === false) throw new PackageStorageException('Unable to inspect encrypted manifest.'); + if ($manifestSize < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES || $manifestSize > V14Descriptor::MAX_MANIFEST_BYTES + 64) throw new PackageRestoreRejectedException('Unable to open package.'); $entries = scandir($root); - if ($entries === false) throw new RuntimeException('Unable to open package.'); + if ($entries === false) throw new PackageStorageException('Unable to inventory package directory.'); foreach ($entries as $entry) { if (in_array($entry, ['.', '..', 'header.json', 'manifest.enc', 'complete.json', 'blobs'], true)) continue; - throw new RuntimeException('Unable to open package.'); + throw new PackageRestoreRejectedException('Unable to open package.'); } $blobEntries = scandir($blobDir); - if ($blobEntries === false || count($blobEntries) - 2 > V14Descriptor::MAX_BLOBS) throw new RuntimeException('Unable to open package.'); + if ($blobEntries === false) throw new PackageStorageException('Unable to inventory package blobs.'); + if (count($blobEntries) - 2 > V14Descriptor::MAX_BLOBS) throw new PackageRestoreRejectedException('Unable to open package.'); $totalCipher = 0; foreach ($blobEntries as $entry) { if ($entry === '.' || $entry === '..') continue; - if (!preg_match('/^[a-f0-9]{48}\.bin$/', $entry)) throw new RuntimeException('Unable to open package.'); + if (!preg_match('/^[a-f0-9]{48}\.bin$/', $entry)) throw new PackageRestoreRejectedException('Unable to open package.'); $path = $blobDir . DIRECTORY_SEPARATOR . $entry; - if (is_link($path) || !is_file($path)) throw new RuntimeException('Unable to open package.'); + if (is_link($path) || !is_file($path)) throw new PackageRestoreRejectedException('Unable to open package.'); $size = filesize($path); - if ($size === false || $size < 1 || $size > V14Descriptor::MAX_FILE_BYTES + 64 * 1024 * 1024) throw new RuntimeException('Unable to open package.'); + if ($size === false) throw new PackageStorageException('Unable to inspect package blob.'); + if ($size < 1 || $size > V14Descriptor::MAX_FILE_BYTES + 64 * 1024 * 1024) throw new PackageRestoreRejectedException('Unable to open package.'); $totalCipher += $size; - if ($totalCipher > V14Descriptor::MAX_TOTAL_PLAIN_BYTES + 64 * 1024 * 1024) throw new RuntimeException('Unable to open package.'); + if ($totalCipher > V14Descriptor::MAX_TOTAL_PLAIN_BYTES + 64 * 1024 * 1024) throw new PackageRestoreRejectedException('Unable to open package.'); } return $header; } diff --git a/app/Security/AuthenticationRequiredException.php b/app/Security/AuthenticationRequiredException.php new file mode 100644 index 00000000..c4a6330a --- /dev/null +++ b/app/Security/AuthenticationRequiredException.php @@ -0,0 +1,15 @@ +file($token); $now = time(); - AtomicFile::write($this->file($token), json_encode([ + $contents = json_encode([ 'package_id' => $packageId, 'owner_id_hash' => hash('sha256', $ownerId), 'created_at' => $now, 'expires_at' => $now + $this->ttlSeconds, 'one_time' => $oneTime, - ], JSON_THROW_ON_ERROR)); + ], JSON_THROW_ON_ERROR); + + // This record is not visible to the caller until the complete write succeeds. + // Exclusive-create prevents replacing a path that already exists. + $handle = @fopen($file, 'x+b'); + if ($handle === false) { + throw new RuntimeException('Unable to create access token.'); + } + $stat = fstat($handle); + $pathStat = @lstat($file); + if (!is_array($stat) || !is_array($pathStat) || is_link($file) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000) + || (((int) ($pathStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($stat['ino'] ?? 0) === 0 + || (int) ($stat['dev'] ?? -2) !== (int) ($pathStat['dev'] ?? -1) + || (int) ($stat['ino'] ?? 0) !== (int) ($pathStat['ino'] ?? -1)) { + fclose($handle); + // Do not unlink a path whose ownership cannot be established. + throw new RuntimeException('Unable to verify access-token storage identity.'); + } + $identity = ['dev' => (int) ($stat['dev'] ?? -1), 'ino' => (int) $stat['ino']]; + + try { + if (!@chmod($file, 0600)) { + throw new RuntimeException('Unable to protect access-token record.'); + } + $this->assertSameFileIdentity($file, $handle, $identity); + self::writeAll($handle, $contents); + if (!fflush($handle)) { + throw new RuntimeException('Unable to flush access-token record.'); + } + if (function_exists('fsync') && !fsync($handle)) { + throw new RuntimeException('Unable to synchronize access-token record.'); + } + $this->assertSameFileIdentity($file, $handle, $identity); + } catch (\Throwable $exception) { + fclose($handle); + $this->removeOwnedFile($file, $identity); + throw $exception; + } + + if (!fclose($handle)) { + $this->removeOwnedFile($file, $identity); + throw new RuntimeException('Unable to close access-token record.'); + } return $token; } @@ -44,71 +102,267 @@ public function consume(string $token): array } $file = $this->file($token); - if (is_link($file) || !is_file($file)) { - throw new RuntimeException('Invalid or expired access token.'); + $opened = $this->openLockedRecord($file, true); + $handle = $opened['handle']; + $identity = $opened['identity']; + $removeAfterUnlock = false; + + try { + $data = $this->readRecord($handle); + if (!$this->isConsumedMarker($data)) { + $this->assertTokenRecord($data); + if ($data['expires_at'] <= time()) { + throw new RuntimeException('Invalid or expired access token.'); + } + + if ($data['one_time']) { + // A durable consumed marker prevents already-open waiters from replaying it. + $marker = json_encode(['consumed' => true], JSON_THROW_ON_ERROR); + $this->replaceLockedContents($handle, $marker); + $removeAfterUnlock = true; + } + } else { + throw new RuntimeException('Invalid or expired access token.'); + } + } finally { + if (!flock($handle, LOCK_UN)) { + fclose($handle); + throw new RuntimeException('Unable to release access-token lock.'); + } + if (!fclose($handle)) { + throw new RuntimeException('Unable to close access-token record.'); + } } - // r+ avoids creating an empty token file if it disappears between the - // existence check and open. Every consumer locks the same inode. - $handle = @fopen($file, 'r+'); - if ($handle === false || !flock($handle, LOCK_EX)) { - if (is_resource($handle)) fclose($handle); - throw new RuntimeException('Invalid or expired access token.'); + if ($removeAfterUnlock) { + $this->removeOwnedFile($file, $identity); } + return $data; + } + + public function revokeForPackage(string $packageId): void + { + if ($packageId === '') { + throw new RuntimeException('Invalid package identifier for token revocation.'); + } + $rootStat = @lstat($this->directory); + if (!is_array($rootStat) || is_link($this->directory) + || (((int) ($rootStat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Token storage directory is unavailable.'); + } + $rootIdentity = ['dev' => (int) ($rootStat['dev'] ?? -1), 'ino' => (int) ($rootStat['ino'] ?? 0)]; - $removeAfterUnlock = false; try { - rewind($handle); - $raw = stream_get_contents($handle); - try { - $data = json_decode((string) $raw, true, 16, JSON_THROW_ON_ERROR); - } catch (\JsonException) { - throw new RuntimeException('Invalid or expired access token.'); - } + $iterator = new FilesystemIterator($this->directory, FilesystemIterator::SKIP_DOTS); + foreach ($iterator as $entry) { + $name = $entry->getFilename(); + if (!str_ends_with($name, '.json')) { + continue; + } + if (!preg_match('/^[a-f0-9]{64}\.json$/D', $name)) { + throw new RuntimeException('Token storage contains an invalid record path.'); + } - if (!is_array($data) - || ($data['consumed'] ?? false) === true - || !is_string($data['package_id'] ?? null) - || $data['package_id'] === '' - || !is_string($data['owner_id_hash'] ?? null) - || !preg_match('/^[a-f0-9]{64}$/', $data['owner_id_hash']) - || !is_int($data['expires_at'] ?? null) - || $data['expires_at'] <= time() - || !is_bool($data['one_time'] ?? null)) { - throw new RuntimeException('Invalid or expired access token.'); - } + $this->assertDirectoryIdentity($this->directory, $rootIdentity); + $file = $entry->getPathname(); + $opened = $this->openLockedRecord($file, false); + $handle = $opened['handle']; + $identity = $opened['identity']; + $shouldRemove = false; + try { + $data = $this->readRecord($handle); + if ($this->isConsumedMarker($data)) { + $shouldRemove = true; + } else { + $this->assertTokenRecord($data); + if (hash_equals($packageId, $data['package_id'])) { + $this->replaceLockedContents($handle, json_encode(['consumed' => true], JSON_THROW_ON_ERROR)); + $shouldRemove = true; + } + } + } finally { + if (!flock($handle, LOCK_UN)) { + fclose($handle); + throw new RuntimeException('Unable to release access-token lock during revocation.'); + } + if (!fclose($handle)) { + throw new RuntimeException('Unable to close access-token record during revocation.'); + } + } - if ($data['one_time']) { - // Unlinking alone is not enough: a concurrent request may - // already have opened the same inode and be waiting on flock. - // Persist a consumed marker before releasing the lock so that - // every waiter observes the token as unusable. - $marker = json_encode(['consumed' => true], JSON_THROW_ON_ERROR); - rewind($handle); - if (!ftruncate($handle, 0) || fwrite($handle, $marker) !== strlen($marker) || !fflush($handle)) { - throw new RuntimeException('Unable to consume access token safely.'); + if ($shouldRemove) { + $this->removeOwnedFile($file, $identity); } - $removeAfterUnlock = true; } + } catch (\RuntimeException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new RuntimeException('Unable to enumerate or revoke package access tokens.', 0, $exception); + } + } - return $data; - } finally { + private function openLockedRecord(string $file, bool $missingIsInvalid): array + { + clearstatcache(true, $file); + $pathStat = @lstat($file); + if (!is_array($pathStat) || is_link($file) + || (((int) ($pathStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new RuntimeException($missingIsInvalid + ? 'Invalid or expired access token.' + : 'Token storage contains an invalid record.'); + } + + $identity = ['dev' => (int) ($pathStat['dev'] ?? -1), 'ino' => (int) ($pathStat['ino'] ?? 0)]; + if ($identity['ino'] === 0) { + throw new RuntimeException('Unable to verify access-token record identity.'); + } + $handle = @fopen($file, 'r+b'); + if ($handle === false) { + throw new RuntimeException('Unable to open access-token storage.'); + } + $handleStat = fstat($handle); + if (!is_array($handleStat) + || (((int) ($handleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($handleStat['dev'] ?? -2) !== $identity['dev'] + || (int) ($handleStat['ino'] ?? 0) !== $identity['ino']) { + fclose($handle); + throw new RuntimeException('Access-token path changed while opening.'); + } + if (!flock($handle, LOCK_EX)) { + fclose($handle); + throw new RuntimeException('Unable to lock access-token storage.'); + } + + try { + $this->assertSameFileIdentity($file, $handle, $identity); + } catch (\Throwable $exception) { flock($handle, LOCK_UN); fclose($handle); - if ($removeAfterUnlock) { - @unlink($file); + throw $exception; + } + return ['handle' => $handle, 'identity' => $identity]; + } + + private function readRecord($handle): array + { + if (!rewind($handle)) { + throw new RuntimeException('Unable to read access-token record.'); + } + $raw = stream_get_contents($handle, self::MAX_RECORD_BYTES + 1); + if ($raw === false) { + throw new RuntimeException('Unable to read access-token record.'); + } + if (strlen($raw) > self::MAX_RECORD_BYTES) { + throw new RuntimeException('Access-token record exceeds the supported size.'); + } + try { + $data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new RuntimeException('Access-token record is corrupt.', 0, $exception); + } + if (!is_array($data)) { + throw new RuntimeException('Access-token record is invalid.'); + } + return $data; + } + + private function assertTokenRecord(array $data): void + { + if (!is_string($data['package_id'] ?? null) + || $data['package_id'] === '' + || !is_string($data['owner_id_hash'] ?? null) + || !preg_match('/^[a-f0-9]{64}$/', $data['owner_id_hash']) + || !is_int($data['created_at'] ?? null) + || !is_int($data['expires_at'] ?? null) + || !is_bool($data['one_time'] ?? null) + || (isset($data['consumed']) && $data['consumed'] !== false)) { + throw new RuntimeException('Access-token record is invalid.'); + } + } + + private function isConsumedMarker(array $data): bool + { + return count($data) === 1 && ($data['consumed'] ?? null) === true; + } + + private function replaceLockedContents($handle, string $contents): void + { + if (!rewind($handle) || !ftruncate($handle, 0)) { + throw new RuntimeException('Unable to update access-token state.'); + } + self::writeAll($handle, $contents); + if (!fflush($handle)) { + throw new RuntimeException('Unable to flush access-token state.'); + } + if (function_exists('fsync') && !fsync($handle)) { + throw new RuntimeException('Unable to synchronize access-token state.'); + } + } + + private function assertSameFileIdentity(string $path, $handle, array $expected): void + { + clearstatcache(true, $path); + $pathStat = @lstat($path); + $handleStat = fstat($handle); + if (!is_array($pathStat) || !is_array($handleStat) || is_link($path) + || (((int) ($pathStat['mode'] ?? 0) & 0170000) !== 0100000) + || (((int) ($handleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($pathStat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($pathStat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1) + || (int) ($handleStat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($handleStat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1)) { + throw new RuntimeException('Access-token record identity changed.'); + } + } + + private function assertDirectoryIdentity(string $path, array $expected): void + { + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat) || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0040000) + || (int) ($stat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($stat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1)) { + throw new RuntimeException('Token storage directory identity changed.'); + } + } + + private function removeOwnedFile(string $file, array $identity): void + { + clearstatcache(true, $file); + $pathStat = @lstat($file); + if (!is_array($pathStat)) { + if (file_exists($file) || is_link($file)) { + throw new RuntimeException('Unable to inspect access-token cleanup path.'); } + return; + } + if (is_link($file) + || (((int) ($pathStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($pathStat['dev'] ?? -2) !== (int) ($identity['dev'] ?? -1) + || (int) ($pathStat['ino'] ?? 0) !== (int) ($identity['ino'] ?? -1)) { + throw new RuntimeException('Access-token cleanup path identity changed.'); + } + if (!@unlink($file) && (file_exists($file) || is_link($file))) { + throw new RuntimeException('Unable to remove access-token record.'); + } + clearstatcache(true, $file); + if (file_exists($file) || is_link($file)) { + throw new RuntimeException('Access-token record remains after cleanup.'); } } - public function revokeForPackage(string $packageId): void + + private static function writeAll($handle, string $contents): void { - foreach (glob($this->directory . DIRECTORY_SEPARATOR . '*.json') ?: [] as $file) { - if (is_link($file) || !is_file($file)) continue; - $data = json_decode((string) @file_get_contents($file), true); - if (is_array($data) && ($data['package_id'] ?? '') === $packageId) { - @unlink($file); + $offset = 0; + $length = strlen($contents); + while ($offset < $length) { + $written = fwrite($handle, substr($contents, $offset)); + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write access-token record.'); } + $offset += $written; } } diff --git a/app/Security/SecurityDiagnostics.php b/app/Security/SecurityDiagnostics.php index 47e7fc53..2f5b2137 100644 --- a/app/Security/SecurityDiagnostics.php +++ b/app/Security/SecurityDiagnostics.php @@ -30,6 +30,8 @@ public static function run(string $projectRoot): array return [ 'version' => SECURE_PACKAGE_MAJOR_VERSION, 'version_string' => SECURE_PACKAGE_VERSION, + 'server_package_format' => 'SECURE-PKG-V14', + 'server_package_format_version' => 14, 'author' => 'ElmanCod110', 'checks' => $checks, 'secure' => $secure, diff --git a/app/Storage/JobNotAvailableException.php b/app/Storage/JobNotAvailableException.php new file mode 100644 index 00000000..36a0d250 --- /dev/null +++ b/app/Storage/JobNotAvailableException.php @@ -0,0 +1,15 @@ +directoryIdentity($root); + if (!@chmod($root, 0700)) { + throw new RuntimeException('Unable to protect job storage.'); + } } public function create(?string $ownerToken = null): array @@ -22,30 +32,62 @@ public function create(?string $ownerToken = null): array do { $id = bin2hex(random_bytes(24)); $path = $this->root . DIRECTORY_SEPARATOR . $id; - } while (file_exists($path)); - if (!mkdir($path, 0700, true)) { + } while (file_exists($path) || is_link($path)); + + if (!@mkdir($path, 0700, false)) { throw new RuntimeException('Unable to create job.'); } - mkdir($path . DIRECTORY_SEPARATOR . 'source', 0700, true); - mkdir($path . DIRECTORY_SEPARATOR . 'archives', 0700, true); - $this->writeState($id, ['status' => 'created', 'created_at' => time(), 'owner_hash' => $ownerToken !== null ? hash('sha256', $ownerToken) : null]); - return ['id' => $id, 'path' => $path]; + $identity = $this->directoryIdentity($path); + + try { + foreach (['source', 'archives'] as $child) { + $childPath = $path . DIRECTORY_SEPARATOR . $child; + if (!@mkdir($childPath, 0700, false)) { + throw new RuntimeException('Unable to initialize job directories.'); + } + $this->directoryIdentity($childPath); + if (!@chmod($childPath, 0700)) { + throw new RuntimeException('Unable to protect job directories.'); + } + } + $this->writeState($id, [ + 'status' => 'created', + 'created_at' => time(), + 'owner_hash' => $ownerToken !== null ? hash('sha256', $ownerToken) : null, + ]); + $this->assertDirectoryIdentity($path, $identity); + return ['id' => $id, 'path' => $path]; + } catch (Throwable $exception) { + try { + $this->removeOwnedDirectory($path, $identity); + } catch (Throwable $cleanupError) { + throw new RuntimeException('Unable to clean incomplete job initialization.', 0, $cleanupError); + } + throw $exception; + } } public function path(string $id): string { if (!preg_match('/^[a-f0-9]{48}$/', $id)) { - throw new RuntimeException('Invalid job identifier.'); + throw new JobNotAvailableException(); } $path = $this->root . DIRECTORY_SEPARATOR . $id; - if (!is_dir($path) || is_link($path)) { - throw new RuntimeException('Job does not exist.'); + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat)) { + if (!file_exists($path) && !is_link($path)) throw new JobNotAvailableException(); + throw new RuntimeException('Unable to inspect job directory.'); + } + if (is_link($path) || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Job path is not a regular directory.'); } return $path; } /** - * Remove one validated job without following symlinks inside its directory. + * Remove a validated job without following symlinks and only while the + * identity of its owned root directory remains unchanged. */ public function remove(string $id): void { @@ -53,25 +95,64 @@ public function remove(string $id): void throw new RuntimeException('Invalid job identifier.'); } $path = $this->root . DIRECTORY_SEPARATOR . $id; - if (is_link($path)) { - if (!unlink($path)) throw new RuntimeException('Unable to remove job link.'); + clearstatcache(true, $path); + $rootStat = @lstat($path); + if (!is_array($rootStat)) { + if (file_exists($path) || is_link($path)) { + throw new RuntimeException('Unable to inspect job cleanup target.'); + } return; } - if (!is_dir($path)) return; + if (is_link($path) || (((int) ($rootStat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Job cleanup target is not an owned directory.'); + } + $identity = ['dev' => (int) ($rootStat['dev'] ?? -1), 'ino' => (int) ($rootStat['ino'] ?? 0)]; - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ($iterator as $item) { - $itemPath = $item->getPathname(); - if ($item->isLink() || !$item->isDir()) { - if (!unlink($itemPath)) throw new RuntimeException('Unable to remove job file.'); - } elseif (!rmdir($itemPath)) { - throw new RuntimeException('Unable to remove job directory.'); + try { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $this->assertDirectoryIdentity($path, $identity); + $itemPath = $item->getPathname(); + clearstatcache(true, $itemPath); + $itemStat = @lstat($itemPath); + if (!is_array($itemStat)) { + if (file_exists($itemPath) || is_link($itemPath)) { + throw new RuntimeException('Unable to inspect job cleanup entry.'); + } + continue; + } + + $itemMode = ((int) ($itemStat['mode'] ?? 0)) & 0170000; + if ($itemMode === 0040000 && !is_link($itemPath)) { + if (!@rmdir($itemPath) && is_dir($itemPath)) { + throw new RuntimeException('Unable to remove job directory.'); + } + } else { + if (!@unlink($itemPath) && (file_exists($itemPath) || is_link($itemPath))) { + throw new RuntimeException('Unable to remove job file.'); + } + } + clearstatcache(true, $itemPath); + if (file_exists($itemPath) || is_link($itemPath)) { + throw new RuntimeException('Job cleanup entry remains after deletion.'); + } } + } catch (Throwable $exception) { + if ($exception instanceof RuntimeException) throw $exception; + throw new RuntimeException('Unable to enumerate job cleanup entries.', 0, $exception); + } + + $this->assertDirectoryIdentity($path, $identity); + if (!@rmdir($path) && is_dir($path)) { + throw new RuntimeException('Unable to remove job.'); + } + clearstatcache(true, $path); + if (file_exists($path) || is_link($path)) { + throw new RuntimeException('Job directory remains after cleanup.'); } - if (!rmdir($path)) throw new RuntimeException('Unable to remove job.'); } public function assertOwner(string $id, string $ownerToken): void @@ -79,35 +160,274 @@ public function assertOwner(string $id, string $ownerToken): void $state = $this->readState($id); $stored = (string) ($state['owner_hash'] ?? ''); if ($stored === '' || !hash_equals($stored, hash('sha256', $ownerToken))) { - throw new RuntimeException('Job does not exist.'); + throw new JobNotAvailableException(); } } public function readState(string $id): array { - $path = $this->path($id) . DIRECTORY_SEPARATOR . 'state.json'; - if (!is_file($path)) { - throw new RuntimeException('Job state does not exist.'); + $jobPath = $this->path($id); + $path = $jobPath . DIRECTORY_SEPARATOR . 'state.json'; + $identity = $this->regularFileIdentity($path, false); + $handle = @fopen($path, 'rb'); + if ($handle === false) { + throw new RuntimeException('Unable to read job state.'); + } + try { + $handleStat = fstat($handle); + if (!is_array($handleStat) + || (((int) ($handleStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($handleStat['dev'] ?? -2) !== $identity['dev'] + || (int) ($handleStat['ino'] ?? 0) !== $identity['ino']) { + throw new RuntimeException('Job state identity changed while opening.'); + } + $raw = stream_get_contents($handle, self::MAX_STATE_BYTES + 1); + if ($raw === false) { + throw new RuntimeException('Unable to read job state.'); + } + if (strlen($raw) > self::MAX_STATE_BYTES) { + throw new RuntimeException('Job state exceeds its size limit.'); + } + $this->assertRegularFileIdentity($path, $identity, 'Job state identity changed while reading.'); + } finally { + if (!fclose($handle)) { + throw new RuntimeException('Unable to close job state.'); + } + } + + try { + $state = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new RuntimeException('Job state is corrupt.', 0, $exception); } - $state = json_decode((string) file_get_contents($path), true, 32, JSON_THROW_ON_ERROR); if (!is_array($state)) { - throw new RuntimeException('Invalid job state.'); + throw new RuntimeException('Job state is invalid.'); } return $state; } public function writeState(string $id, array $state): void { - $path = $this->path($id) . DIRECTORY_SEPARATOR . 'state.json'; - $tmp = $path . '.partial.' . bin2hex(random_bytes(6)); - if (file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), LOCK_EX) === false) { - @unlink($tmp); - throw new RuntimeException('Unable to write job state.'); + $jobPath = $this->path($id); + $jobIdentity = $this->directoryIdentity($jobPath); + $path = $jobPath . DIRECTORY_SEPARATOR . 'state.json'; + + clearstatcache(true, $path); + $oldStat = @lstat($path); + $oldIdentity = null; + if (is_array($oldStat)) { + if (is_link($path) || (((int) ($oldStat['mode'] ?? 0) & 0170000) !== 0100000)) { + throw new RuntimeException('Job state is not a regular file.'); + } + $oldIdentity = ['dev' => (int) ($oldStat['dev'] ?? -1), 'ino' => (int) ($oldStat['ino'] ?? 0)]; + } elseif (file_exists($path) || is_link($path)) { + throw new RuntimeException('Unable to inspect existing job state.'); } - if (!rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Unable to commit job state.'); + + $json = json_encode($state, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + if (strlen($json) > self::MAX_STATE_BYTES) { + throw new RuntimeException('Job state exceeds its size limit.'); + } + $tmp = $path . '.partial.' . bin2hex(random_bytes(8)); + $handle = @fopen($tmp, 'x+b'); + if ($handle === false) { + throw new RuntimeException('Unable to create temporary job state.'); + } + $tmpStat = fstat($handle); + $tmpPathStat = @lstat($tmp); + if (!is_array($tmpStat) || !is_array($tmpPathStat) || is_link($tmp) + || (((int) ($tmpStat['mode'] ?? 0) & 0170000) !== 0100000) + || (((int) ($tmpPathStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($tmpStat['dev'] ?? -2) !== (int) ($tmpPathStat['dev'] ?? -1) + || (int) ($tmpStat['ino'] ?? 0) !== (int) ($tmpPathStat['ino'] ?? -1) + || (int) ($tmpStat['ino'] ?? 0) === 0) { + fclose($handle); + throw new RuntimeException('Temporary job state identity could not be verified.'); + } + $tmpIdentity = ['dev' => (int) ($tmpStat['dev'] ?? -1), 'ino' => (int) $tmpStat['ino']]; + $published = false; + + try { + if (!@chmod($tmp, 0600)) { + throw new RuntimeException('Unable to protect temporary job state.'); + } + self::writeAll($handle, $json); + if (!fflush($handle)) { + throw new RuntimeException('Unable to flush temporary job state.'); + } + if (function_exists('fsync') && !fsync($handle)) { + throw new RuntimeException('Unable to synchronize temporary job state.'); + } + $this->assertDirectoryIdentity($jobPath, $jobIdentity); + $this->assertRegularFileIdentity($tmp, $tmpIdentity, 'Temporary job state identity changed.'); + $this->assertStateDestinationIdentity($path, $oldIdentity); + if (!fclose($handle)) { + $handle = null; + throw new RuntimeException('Unable to close temporary job state.'); + } + $handle = null; + + // State updates intentionally replace the prior state, but only if it + // is still the exact record observed before writing. + $this->assertDirectoryIdentity($jobPath, $jobIdentity); + $this->assertStateDestinationIdentity($path, $oldIdentity); + if (!@rename($tmp, $path)) { + throw new RuntimeException('Unable to commit job state.'); + } + $published = true; + $this->assertDirectoryIdentity($jobPath, $jobIdentity); + $this->assertRegularFileIdentity($path, $tmpIdentity, 'Committed job state identity could not be verified.'); + if (!@chmod($path, 0600)) { + throw new RuntimeException('Unable to protect committed job state.'); + } + } catch (Throwable $exception) { + if (is_resource($handle)) fclose($handle); + if (!$published) { + $this->removeOwnedFileIfPresent($tmp, $tmpIdentity); + } + throw $exception; } - @chmod($path, 0600); } + + private function directoryIdentity(string $path): array + { + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat) || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Storage path is not a regular directory.'); + } + return ['dev' => (int) ($stat['dev'] ?? -1), 'ino' => (int) ($stat['ino'] ?? 0)]; + } + + private function assertDirectoryIdentity(string $path, array $expected): void + { + $actual = $this->directoryIdentity($path); + if ($actual['dev'] !== (int) ($expected['dev'] ?? -1) + || $actual['ino'] !== (int) ($expected['ino'] ?? -1)) { + throw new RuntimeException('Job directory identity changed.'); + } + } + + private function regularFileIdentity(string $path, bool $missingIsError): array + { + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat) || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000)) { + if (!$missingIsError && !file_exists($path) && !is_link($path)) { + throw new RuntimeException('Job state does not exist.'); + } + throw new RuntimeException('Job state is not a regular file.'); + } + if ((int) ($stat['ino'] ?? 0) === 0) { + throw new RuntimeException('Unable to verify job state identity.'); + } + return ['dev' => (int) ($stat['dev'] ?? -1), 'ino' => (int) $stat['ino']]; + } + + private function assertRegularFileIdentity(string $path, array $expected, string $message): void + { + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat) || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($stat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($stat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1)) { + throw new RuntimeException($message); + } + } + + private function assertStateDestinationIdentity(string $path, ?array $expected): void + { + clearstatcache(true, $path); + $stat = @lstat($path); + if ($expected === null) { + if (is_array($stat) || file_exists($path) || is_link($path)) { + throw new RuntimeException('Job state destination appeared during creation.'); + } + return; + } + if (!is_array($stat) || is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($stat['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || (int) ($stat['ino'] ?? 0) !== (int) ($expected['ino'] ?? -1)) { + throw new RuntimeException('Job state destination changed before commit.'); + } + } + + private function removeOwnedDirectory(string $path, array $identity): void + { + $this->assertDirectoryIdentity($path, $identity); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $this->assertDirectoryIdentity($path, $identity); + $itemPath = $item->getPathname(); + $itemStat = @lstat($itemPath); + if (!is_array($itemStat)) { + if (file_exists($itemPath) || is_link($itemPath)) { + throw new RuntimeException('Unable to inspect incomplete job cleanup entry.'); + } + continue; + } + if ((((int) ($itemStat['mode'] ?? 0) & 0170000) === 0040000) && !is_link($itemPath)) { + if (!@rmdir($itemPath) && is_dir($itemPath)) { + throw new RuntimeException('Unable to remove incomplete job directory.'); + } + } elseif (!@unlink($itemPath) && (file_exists($itemPath) || is_link($itemPath))) { + throw new RuntimeException('Unable to remove incomplete job file.'); + } + if (file_exists($itemPath) || is_link($itemPath)) { + throw new RuntimeException('Incomplete job entry remains after cleanup.'); + } + } + $this->assertDirectoryIdentity($path, $identity); + if (!@rmdir($path) && is_dir($path)) { + throw new RuntimeException('Unable to remove incomplete job directory.'); + } + if (file_exists($path) || is_link($path)) { + throw new RuntimeException('Incomplete job directory remains after cleanup.'); + } + } + + private function removeOwnedFileIfPresent(string $path, array $identity): void + { + clearstatcache(true, $path); + $stat = @lstat($path); + if (!is_array($stat)) { + if (file_exists($path) || is_link($path)) { + throw new RuntimeException('Unable to inspect temporary job state cleanup path.'); + } + return; + } + if (is_link($path) + || (((int) ($stat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($stat['dev'] ?? -2) !== (int) ($identity['dev'] ?? -1) + || (int) ($stat['ino'] ?? 0) !== (int) ($identity['ino'] ?? -1)) { + throw new RuntimeException('Temporary job state identity changed before cleanup.'); + } + if (!@unlink($path) && (file_exists($path) || is_link($path))) { + throw new RuntimeException('Unable to remove temporary job state.'); + } + if (file_exists($path) || is_link($path)) { + throw new RuntimeException('Temporary job state remains after cleanup.'); + } + } + + private static function writeAll($handle, string $contents): void + { + $offset = 0; + $length = strlen($contents); + while ($offset < $length) { + $written = fwrite($handle, substr($contents, $offset)); + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write job state.'); + } + $offset += $written; + } + } + } diff --git a/app/Storage/ResumableUploadStore.php b/app/Storage/ResumableUploadStore.php index 36b59600..de513a72 100644 --- a/app/Storage/ResumableUploadStore.php +++ b/app/Storage/ResumableUploadStore.php @@ -125,35 +125,135 @@ public function finalize(string $id, string $ownerId): string public function remove(string $id): void { - if (!preg_match('/^[a-f0-9]{48}$/', $id)) return; + if (!preg_match('/^[a-f0-9]{48}$/', $id)) { + throw new RuntimeException('Invalid upload identifier for cleanup.'); + } + $dir = $this->directory($id); + clearstatcache(true, $dir); + $rootStat = @lstat($dir); + if (!is_array($rootStat)) { + if (file_exists($dir) || is_link($dir)) { + throw new RuntimeException('Unable to inspect upload cleanup target.'); + } + return; + } + if (is_link($dir)) { - @unlink($dir); + if (!@unlink($dir) && is_link($dir)) { + throw new RuntimeException('Unable to remove upload directory symlink.'); + } + if (file_exists($dir) || is_link($dir)) { + throw new RuntimeException('Upload directory symlink remains after cleanup.'); + } return; } - if (!is_dir($dir)) return; - foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { - if (is_link($file) || is_file($file)) { - @unlink($file); - } elseif (is_dir($file)) { - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($file, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ($iterator as $item) { - $item->isLink() || !$item->isDir() - ? @unlink($item->getPathname()) - : @rmdir($item->getPathname()); + if ((((int) ($rootStat['mode'] ?? 0) & 0170000) !== 0040000)) { + throw new RuntimeException('Upload cleanup target is not a directory.'); + } + + $identity = [ + 'dev' => (int) ($rootStat['dev'] ?? -1), + 'ino' => (int) ($rootStat['ino'] ?? 0), + ]; + $lockPath = $dir . DIRECTORY_SEPARATOR . 'upload.lock'; + if (is_link($lockPath)) { + throw new RuntimeException('Upload cleanup lock is invalid.'); + } + $lock = @fopen($lockPath, 'c+b'); + if ($lock === false) { + throw new RuntimeException('Unable to lock upload for cleanup.'); + } + $lockStat = fstat($lock); + $lockPathStat = @lstat($lockPath); + if (!is_array($lockStat) || !is_array($lockPathStat) || is_link($lockPath) + || (((int) ($lockStat['mode'] ?? 0) & 0170000) !== 0100000) + || (int) ($lockStat['dev'] ?? -2) !== (int) ($lockPathStat['dev'] ?? -1) + || (int) ($lockStat['ino'] ?? 0) !== (int) ($lockPathStat['ino'] ?? -1)) { + fclose($lock); + throw new RuntimeException('Upload cleanup lock identity could not be verified.'); + } + if (!flock($lock, LOCK_EX)) { + fclose($lock); + throw new RuntimeException('Unable to acquire upload cleanup lock.'); + } + + try { + $this->assertDirectoryIdentity($dir, $identity); + $statePath = $dir . DIRECTORY_SEPARATOR . 'state.json'; + if (is_link($statePath)) { + // Do not follow a forged state symlink. It will be unlinked as an entry below. + } elseif (is_file($statePath)) { + $raw = @file_get_contents($statePath); + if ($raw === false) { + throw new RuntimeException('Unable to read upload state before cleanup.'); + } + try { + $state = json_decode($raw, true, 16, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $state = null; + } + if (is_array($state)) { + $state['status'] = 'expired'; + AtomicFile::write($statePath, json_encode($state, JSON_THROW_ON_ERROR)); + } + } + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + + // The expired state remains visible until this lock is released, so a concurrent + // operation that wins the lock before deletion fails closed instead of using the upload. + $this->assertDirectoryIdentity($dir, $identity); + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $this->assertDirectoryIdentity($dir, $identity); + $itemPath = $item->getPathname(); + if ($item->isLink() || !$item->isDir()) { + if (!@unlink($itemPath) && (file_exists($itemPath) || is_link($itemPath))) { + throw new RuntimeException('Unable to remove upload file during cleanup.'); + } + } elseif (!@rmdir($itemPath) && is_dir($itemPath)) { + throw new RuntimeException('Unable to remove upload directory during cleanup.'); } - @rmdir($file); } + } catch (\Throwable $exception) { + throw new RuntimeException('Unable to complete upload cleanup safely.', 0, $exception); + } + + $this->assertDirectoryIdentity($dir, $identity); + if (!@rmdir($dir) && is_dir($dir)) { + throw new RuntimeException('Unable to remove upload storage directory.'); + } + clearstatcache(true, $dir); + if (file_exists($dir) || is_link($dir)) { + throw new RuntimeException('Upload storage directory remains after cleanup.'); + } + } + + private function assertDirectoryIdentity(string $directory, array $expected): void + { + clearstatcache(true, $directory); + $actual = @lstat($directory); + if (!is_array($actual) + || is_link($directory) + || (((int) ($actual['mode'] ?? 0) & 0170000) !== 0040000) + || (int) ($actual['dev'] ?? -2) !== (int) ($expected['dev'] ?? -1) + || ((int) ($expected['ino'] ?? 0) !== 0 + && (int) ($actual['ino'] ?? 0) !== (int) $expected['ino'])) { + throw new RuntimeException('Upload storage directory identity changed during cleanup.'); } - @rmdir($dir); } public function cleanup(): void { - if (is_link($this->root) || !is_dir($this->root)) return; + if (is_link($this->root)) throw new RuntimeException('Upload storage root must not be a symlink.'); + if (!is_dir($this->root)) return; // Serialize cleanup with upload initialization, then lock each upload before // deciding whether it can be removed. Expired state is marked before unlinking @@ -164,7 +264,13 @@ public function cleanup(): void $id = $entry->getFilename(); if (!preg_match('/^[a-f0-9]{48}$/', $id)) continue; if ($entry->isLink()) { - @unlink($entry->getPathname()); + $path = $entry->getPathname(); + if (!@unlink($path) && is_link($path)) { + throw new RuntimeException('Unable to remove upload storage symlink during cleanup.'); + } + if (file_exists($path) || is_link($path)) { + throw new RuntimeException('Upload storage symlink remains after cleanup.'); + } continue; } if (!$entry->isDir()) continue; diff --git a/bin/sign-release.php b/bin/sign-release.php index 645c0b78..56e71fd4 100644 --- a/bin/sign-release.php +++ b/bin/sign-release.php @@ -9,7 +9,16 @@ fwrite(STDERR, "Usage: php bin/sign-release.php \n"); exit(1); } -$keyHex = trim((string) file_get_contents($argv[1])); +$keyFile = @file_get_contents($argv[1]); +if ($keyFile === false) { + fwrite(STDERR, "Unable to read Ed25519 private key file.\n"); + exit(1); +} +$keyHex = trim($keyFile); +if ($keyHex === '') { + fwrite(STDERR, "Invalid Ed25519 private key.\n"); + exit(1); +} $key = ctype_xdigit($keyHex) ? hex2bin($keyHex) : false; if ($key === false || strlen($key) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { fwrite(STDERR, "Invalid Ed25519 private key.\n"); @@ -29,8 +38,16 @@ }; foreach ($roots as $rootEntry) { $absolute = $root . DIRECTORY_SEPARATOR . $rootEntry; - if (is_file($absolute)) { - if (!$exclude($rootEntry)) $files[$rootEntry] = hash_file('sha256', $absolute); + if (is_file($absolute) && !is_link($absolute)) { + if (!$exclude($rootEntry)) { + $hash = hash_file('sha256', $absolute); + if ($hash === false) { + sodium_memzero($key); + fwrite(STDERR, "Unable to hash release file: {$rootEntry}\n"); + exit(1); + } + $files[$rootEntry] = $hash; + } continue; } if (!is_dir($absolute)) continue; @@ -62,8 +79,26 @@ fwrite(STDERR, "Unable to create release directory.\n"); exit(1); } -file_put_contents($releaseDir . '/manifest.json', $payload, LOCK_EX); -file_put_contents($releaseDir . '/manifest.sig', base64_encode(sodium_crypto_sign_detached($payload, $key)) . PHP_EOL, LOCK_EX); -file_put_contents($releaseDir . '/public-key.hex', bin2hex(sodium_crypto_sign_publickey_from_secretkey($key)) . PHP_EOL, LOCK_EX); +$signature = base64_encode(sodium_crypto_sign_detached($payload, $key)) . PHP_EOL; +$publicKeyHex = bin2hex(sodium_crypto_sign_publickey_from_secretkey($key)) . PHP_EOL; +$writeArtifact = static function (string $path, string $contents): void { + $written = file_put_contents($path, $contents, LOCK_EX); + if ($written !== strlen($contents)) { + throw new RuntimeException('Unable to write release signature artifacts.'); + } + $verified = file_get_contents($path); + if ($verified === false || !hash_equals($contents, $verified)) { + throw new RuntimeException('Unable to verify written release signature artifacts.'); + } +}; +try { + $writeArtifact($releaseDir . '/manifest.json', $payload); + $writeArtifact($releaseDir . '/manifest.sig', $signature); + $writeArtifact($releaseDir . '/public-key.hex', $publicKeyHex); +} catch (\Throwable) { + sodium_memzero($key); + fwrite(STDERR, "Unable to write complete release signature artifacts.\n"); + exit(1); +} sodium_memzero($key); echo "V14 active-source release manifest signed successfully.\n"; diff --git a/bin/source-audit.php b/bin/source-audit.php index bdc7b45b..460a8f66 100644 --- a/bin/source-audit.php +++ b/bin/source-audit.php @@ -8,6 +8,7 @@ 'private-key-markers' => ['/-----BEGIN PRIVATE KEY-----/','/-----BEGIN RSA PRIVATE KEY-----/','/-----BEGIN EC PRIVATE KEY-----/'], ]; $findings=[]; +$self = str_replace('\\', '/', __FILE__); $it=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root,FilesystemIterator::SKIP_DOTS)); -foreach($it as $file){if(!$file->isFile())continue;$p=str_replace('\\','/',$file->getPathname());if(str_contains($p,'/vendor/'))continue;if(str_contains($p,'/archive/legacy/'))continue;if($p===__FILE__)continue;if(str_contains($p,'/tests/'))continue;if(!preg_match('/\.(php|js|html|css|json|md|yml|yaml)$/i',$p))continue;$c=@file_get_contents($p);if($c===false)continue;foreach($checks as $category=>$needles){foreach($needles as $needle){if(preg_match($needle,$c) && $p!==__FILE__){$findings[]=$category.':'.$p.':'.$needle;}}}} +foreach($it as $file){if(!$file->isFile())continue;$p=str_replace('\\','/',$file->getPathname());if(str_contains($p,'/vendor/'))continue;if(str_contains($p,'/archive/legacy/'))continue;if($p===$self)continue;if(str_contains($p,'/tests/'))continue;if(!preg_match('/\.(php|js|html|css|json|md|yml|yaml)$/i',$p))continue;$c=@file_get_contents($p);if($c===false)continue;foreach($checks as $category=>$needles){foreach($needles as $needle){if(preg_match($needle,$c) && $p!==$self){$findings[]=$category.':'.$p.':'.$needle;}}}} if($findings){echo implode(PHP_EOL,$findings).PHP_EOL;exit(1);}echo "Source audit passed.\n"; diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..21fc6465 --- /dev/null +++ b/composer.lock @@ -0,0 +1,4524 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "f2622f51e7c70b03ba304b54c3e59058", + "packages": [], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.5.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-05T13:12:12+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0 || ^1.1.2", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "symfony/polyfill-mbstring": "^1.33", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2026-09-29T22:11:39+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.27", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", + "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.12.0", + "keradus/cli-executor": "^2.3", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3", + "symfony/polyfill-php85": "^1.41", + "symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7", + "symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-09-22T09:54:55+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.3.1", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-10-08T21:09:11+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.57", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/36b75bf56a8535623f5b3e24b27ea1823759da1a", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.57" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:47:54+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/21f1bc2296f7ff6043241a8dcbc09559b3732820", + "reference": "21f1bc2296f7ff6043241a8dcbc09559b3732820", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T18:45:38+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", + "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-06T20:05:19+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T06:31:10+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "reference": "e5247f4a5e0797d232ad8e8e6803aeb341843c83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-08T16:56:12+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "reference": "9bc35d074ad16a58c21cd52b18c5cacc4f4c3981", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-03T15:15:30+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docs/API-ERROR-CONTRACT.md b/docs/API-ERROR-CONTRACT.md index 77b99234..2b30d2da 100644 --- a/docs/API-ERROR-CONTRACT.md +++ b/docs/API-ERROR-CONTRACT.md @@ -1,50 +1,63 @@ # API Error Contract Audit -**Status:** source audit baseline; not a claim that every scenario has been exercised at runtime. -**Scope:** `public/api.php`, `app/Api/Request.php`, `app/Api/HttpException.php`, `app/Security/WebSecurity.php`, and `app/Api/PackageController.php`. +**Status:** source-reviewed contract with HTTP integration coverage on the release-reconciliation branch. CI for the exact candidate commit must pass before this is treated as release evidence. +**Scope:** `public/api.php`, `app/Api/Request.php`, `app/Api/HttpException.php`, `app/Security/WebSecurity.php`, `app/Api/PackageController.php`, archive/package storage and restore exception types. -## Current observable contract +## Public response rules -| Condition / action | Current response status | Public response | Audit note | +Unexpected configuration, filesystem, permission, storage, or programming errors must reach the central API error boundary. They return a generic HTTP 500 JSON response without exception messages, stack traces, absolute paths, credential values, or package identifiers. Expected user/domain failures may return 4xx only where the controller catches an explicit typed exception or a documented validation error. + +| Condition | Expected status | Public response | Implementation / verification | | --- | ---: | --- | --- | -| Unknown `action` | 404 | `Unknown action.` | Explicit action allowlist and route fallback. | -| Known action with a non-POST method | 405 | `Method not allowed.` | All API actions require POST; response includes `Allow: POST`. | -| Wrong or missing media type | 415 | `Unsupported media type.` | JSON actions require `application/json`; upload chunks require `application/octet-stream`; the token-free CSRF bootstrap action does not parse a body. | -| Invalid JSON / non-array JSON | 400 | `Invalid JSON request.` | Implemented by `Request::json()`. | -| Request JSON body exceeds configured parser limit | 413 | `Request body is too large.` | Checks declared and actual body length. | -| CSRF token or same-origin validation failure | 403 | `Request validation failed.` or `Request origin is not allowed.` | Implemented with `HttpException`. | -| HTTPS required but request is not HTTPS | 503 | `Service is not configured for this connection.` | Only when `SPK_REQUIRE_HTTPS=1`. | -| Login rate limit exceeded | 429 | `Too many login attempts.` | Controller sends response directly. | -| Archive password attempt rate limit | 429 | `Too many archive password attempts.` | Controller sends response directly. | -| Package restore rate limit | 429 | `Too many failed attempts.` | Controller sends response directly. | -| Unhandled API exception | 500 | `Internal server error.` | API boundary logs exception class only. | -| Invalid registration input | 422 | `Invalid account details.` | Input validation failures are distinguished from storage failures. | -| Duplicate account registration | 409 | `Unable to create account.` | Duplicate account is a typed conflict; response avoids echoing the username. | -| Unexpected account-store failure | 500 | `Internal server error.` | Unexpected failures reach the API boundary, which logs the exception class only. | -| Malformed login username | 401 | `Unable to sign in.` | Invalid usernames take the dummy password-verification path and do not become 500 responses. | -| Active upload quota or upload-init rate limit | 429 | `Active upload quota exceeded.` or `Too many upload initialization attempts.` | Per-owner reservation limits and per-IP initialization throttling. | -| Upload initialization input/storage exception | 422 | `Unable to initialize upload.` | Some non-quota storage failures still need typed classification. | -| Upload chunk exception | 409 | `Upload chunk rejected.` | Broad catch currently also maps unexpected failures to 409. | -| Upload finalization exception | 422 | `Unable to finalize upload.` | Broad catch currently also maps unexpected failures to 422. | -| Archive processing failure | 422 | `Unable to open archive with the supplied password.` | Broad catch; does not distinguish invalid input from server/storage failures. | -| Archive job ownership/read failure | 404 | `Archive job was not found.` | Intentionally hides existence/ownership, but broad catch can hide internal failures too. | -| Package build exception | 422 | `Unable to build package.` | Broad catch currently also maps unexpected failures to 422. | -| Package restore exception | 422 | `Unable to open package.` | Failed restore output cleanup was added; error categories remain collapsed. | -| Access token / expiry / revoke / delete exception | 404 | Generic package/action-specific message | Broad catches hide ownership/existence but can also hide internal failures. | - -## Confirmed follow-up work - -1. **Separate expected domain failures from unexpected failures.** Avoid catching every `Throwable` and returning 4xx for disk, permission, programming, or storage failures. Keep public messages generic and log a safe event identifier plus exception class; never log secrets, exception messages, or traces that may contain sensitive data. -2. **Document and enforce HTTP methods.** The API currently dispatches primarily by `action`; this audit did not find a central per-action HTTP-method contract. Decide and test allowed methods before claiming method-mismatch coverage. -3. **Decide content-type policy.** The JSON parser validates body shape but this audit did not confirm a consistent `Content-Type: application/json` requirement for every JSON action. -4. **Exercise failures with integration tests.** Source-text regression tests are useful guards, but do not prove the real HTTP status, response body, filesystem cleanup, or log behavior under a live PHP server. -5. **Preserve privacy on package ownership checks.** Where 404 is intentional to avoid revealing package existence, maintain that behavior for not-found and unauthorized resources while ensuring genuine internal failures are not silently mislabeled as 404. - -## Verification record - -- Source inspection: completed against the repository files listed above. -- Live HTTP integration tests: **NOT RUN** by this document. -- Windows/XAMPP and real-browser tests: **NOT RUN** unless separate evidence is linked in the release QA matrix. -- Dependency validation/audit: blocked until a real root `composer.lock` is committed and Composer checks pass. - -Update this document only when the code and regression evidence support the changed contract. +| Unknown action | 404 | `Unknown action.` | Explicit dispatch allowlist. | +| Known action with non-POST method | 405 | `Method not allowed.` | API request policy; includes `Allow: POST`. Live HTTP test checks the header. | +| Wrong or missing content type | 415 | `Unsupported media type.` | Request policy requires JSON for JSON endpoints and octet-stream for chunk endpoints. | +| Invalid JSON / non-array JSON | 400 | `Invalid JSON request.` | `Request::json()`. | +| Oversized JSON body | 413 | `Request body is too large.` | Declared and actual body size are bounded. | +| CSRF / same-origin rejection | 403 | Generic request-validation message | `WebSecurity` and `HttpException`. | +| HTTPS required but unavailable | 503 | `Service is not configured for this connection.` | Only when `SPK_REQUIRE_HTTPS=1`. | +| Unauthenticated private-package management request | 401 | `Authentication required.` | Typed `AuthenticationRequiredException`; HTTP integration test checks unauthenticated package listing. | +| Unknown or unavailable package/job | 404 | Generic “not available” response | Typed `PackageNotAvailableException` / `JobNotAvailableException`; intentionally avoids exposing ownership/existence. | +| Expired/revoked package or upload | 404 / 410 | Generic unavailable/expired response | Expiry/revocation checks precede access; upload store retains its documented 410 for expired uploads. | +| Invalid registration input | 422 | `Invalid account details.` | Validation failure is distinct from unexpected account-store failure. | +| Duplicate account registration | 409 | `Unable to create account.` | Typed conflict; username is not echoed. | +| Invalid upload size/checksum/empty chunk | 422 | Upload input error | Typed `UploadException`. | +| Upload offset conflict / incomplete upload | 409 | Upload state error | Typed `UploadException`. | +| Upload/quota rate limits | 429 | Rate-limit message | Rate limiter and quota exception. | +| Upload filesystem/storage failure | 500 | `Internal server error.` | Unexpected `RuntimeException` is rethrown to API boundary, not mapped to 4xx. | +| Missing archive job | 404 | `Archive job was not found.` | `JobNotAvailableException`. | +| Archive step state conflict | 409 | `Archive step is no longer available.` | `ArchiveStepConflictException`. | +| Malformed archive, invalid path, bad password, archive limits | 422 | Generic archive-open failure | `ArchiveProcessingException`; hostile archive names are rejected. | +| Archive read/write/rollback/cleanup failure | 500 | `Internal server error.` | Unexpected storage errors are rethrown; rollback and private-stage cleanup errors are no longer silently swallowed. | +| Package build input validation | 422 | `Unable to build package.` | Explicit validation exception; missing job is 404. | +| Package build/catalog/storage/runtime failure | 500 | `Internal server error.` | Not mislabeled as a bad request; incomplete artifacts are cleaned with post-cleanup verification. Plaintext source job is preserved if build fails before commit. | +| Missing package during restore | 404 | `Package not available.` | Typed `PackageNotAvailableException`. | +| Invalid credentials, damaged package, invalid manifest / ZIP path | 422 | `Unable to open package.` | Typed `PackageRestoreRejectedException`; restore failures invoke centralized plaintext cleanup. | +| Restore destination, catalog I/O, archive extraction or cleanup failure | 500 | `Internal server error.` | Typed `PackageStorageException` or unexpected exception reaches the API boundary. Partial restore output must be removed before returning. | +| Access-token / expiry / revoke / delete for unavailable package | 404 | Generic `Package not available.` | Typed unavailable-package response preserves privacy. | +| Package-management storage failure | 500 | `Internal server error.` | Storage/program errors are not converted to 404 by broad catches. | +| Unhandled API exception | 500 | `Internal server error.` | Central boundary logs only safe exception-class metadata; public response omits exception details. | + +## Cleanup and data-preservation rules + +1. A failed package build must not remove the plaintext source job unless package creation and catalog creation have committed. +2. A failed restore must not yield a restore token. All owned partial plaintext output is removed, and cleanup failure itself is raised as a storage failure. +3. Archive extraction only removes the destination directory it created after checking the recorded directory identity; cleanup does not follow a replaced root symlink. +4. Archive rollback attempts all known reverse moves. A failed rollback or remaining staging path is a server/storage error rather than an ordinary invalid-password response. +5. A pre-existing package archive or restore destination must not be overwritten. +6. Public 404 responses may hide missing versus unauthorized resources. They must not hide a disk, permission, catalog I/O, or programming error. + +## Integration evidence + +`tests/ApiHttpIntegrationTest.php` includes live HTTP checks for configuration initialization failure privacy, the `Allow: POST` header, unauthenticated package listing, missing archive job/package response, catalog initialization failure privacy, and a real encrypted package restore-storage fault where the filesystem enforces the permission change. The restore fault test explicitly reports a skip on hosts that do not enforce the temporary directory-mode change. + +`tests/V14PackageArchiveTest.php` covers round trip, malformed non-ZIP rejection, path traversal rejection, cleanup, and preservation of a pre-existing extraction destination. `tests/PackageCatalogListingTest.php` rejects corrupt catalog records and symlink records. `tests/ResumableUploadCleanupTest.php` checks direct cleanup, nested symlink safety and invalid identifiers. `tests/PackageAuthorizationTest.php`, `tests/PackageBuildCleanupTest.php`, and `tests/RestoreFailureCleanupTest.php` guard the typed error and cleanup contracts. + +Automated test results are evidence only for the exact commit and environment linked by GitHub Actions. Windows/XAMPP, real-browser, accessibility and shared-hosting filesystem behavior still require manual validation; this document does not claim those manual tests have passed. + +## Remaining audit work + +- Validate the strict `ResumableUploadStore::remove()` locking, expiry marking, directory-identity and post-cleanup checks on Windows/non-POSIX filesystems. The Linux test covers direct removal and a nested symlink; cross-platform execution is still NOT RUN. +- Test logging behavior under all major internal failure paths and verify that no secret or raw exception message is written. +- Review the complete exception taxonomy and each endpoint's actual response mapping on the exact release candidate. +- Re-run all regression/security workflows on the latest head after any further source or documentation change. diff --git a/docs/FORMAT.md b/docs/FORMAT.md index f79a5bfd..ddd2d42c 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -60,6 +60,12 @@ Each file is encrypted through libsodium secretstream. The package stores a smal The reader performs all package, manifest, blob, hash, Merkle, and path preflight checks before writing. Directories are created in depth order, followed by file restoration into temporary files. Existing destination directories and existing child paths are rejected. +## Metadata visibility and offline guessing + +The V14 header and completion record are readable. They expose the format/version/schema identifiers, package ID, creation timestamp, KDF and cryptographic profile, recovery-slot presence, and integrity-binding values. The encrypted manifest hides filenames and directory relationships, but the archive structure and per-blob ciphertext lengths can reveal the number of file blobs, approximate file sizes, and total package size. + +Anyone who obtains a package can test password-and-pattern guesses offline against the wrapped key slot. Argon2id makes each attempt more expensive but does not prevent offline guessing; server-side rate limits do not help once a copy has been taken. Use a long, unique password and a separate non-reused pattern. Package encryption also does not establish the sender's identity: use a trusted transfer channel and verify checksums or signatures out of band when authenticity matters. + ## Limits The V14 descriptor defines maximum header size, manifest size, node count, file size, package plaintext size, blob count, and path depth. The outer `.spkg14` ZIP transport allows the V14 plaintext ceiling plus bounded authentication/format overhead. These are parser and resource-safety boundaries, not encryption parameters. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 019cb76c..eae5c44b 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -23,3 +23,18 @@ An optional independent recovery key can wrap the same root key in a separate sl ## No implicit conversion There is intentionally no automatic V13-to-V14 conversion inside the V14 reader. Format migration must be an explicit, testable workflow so a parser never guesses the intended security profile. + +## Stable single-file format and v1.0.0 compatibility policy + +The planned stable binary format is a separate, version-independent format. Its writer/reader candidate is still isolated for review and is not the active/default server writer yet. + +The compatibility policy is **read old formats explicitly; do not silently convert or reinterpret them**: + +- Keep the V14 server reader available in v1.0.0 for existing `.spkg14` packages; remove or deprecate it only through a separately announced, compatibility-reviewed release. +- Keep the browser V13 reader as a separate boundary for existing `.spk13` packages; do not make its support depend on the server package-format choice. +- Add the stable-format reader as a separate parser selected only by the stable format's exact fixed marker. Never try another parser as an automatic fallback after a parse/authentication failure. +- Keep new stable-format writing behind its own reviewed integration gate. Changing `VERSION` must not rewrite any package or change how old package bytes are interpreted. + +A dedicated in-place converter is **not currently available**. It is not required merely to keep old packages readable because the legacy readers remain separate. If/when a conversion tool is added, it must restore the source package to a private temporary location, verify the restored files, write a new stable package to a distinct no-overwrite destination, verify the new package by restoring it to a second temporary location, and only then report success. It must preserve the original package and clean only temporary paths it can prove it owns. Never convert by changing a version field or overwriting the original package. + +Until stable-format integration and compatibility tests are approved, do not promise that v1.0.0 can create or migrate to the stable format in the production UI/CLI. diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index fb33138c..06260598 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -27,4 +27,4 @@ Use the [reproducible release QA matrix](RELEASE-QA.md) to record automated CI e ### Known release prerequisites -A production release should include a real committed `composer.lock`, an independently trusted Ed25519 release public key, and the completed adversarial/fuzzing gates documented in the security roadmap. The release workflow currently fails closed when `composer.lock` is absent; do not bypass this gate with a fabricated lockfile. +A Composer-generated `composer.lock` is now present on the reconciliation candidate branch and the workflow that refreshes it fails before opening a PR when dependency auditing fails. Strict validation and dependency audit must still pass on the exact release candidate. Production publication also requires an independently trusted Ed25519 release public key, completed adversarial/fuzzing gates, Windows/XAMPP and real-browser QA, and explicit release approval. Do not bypass a failing lockfile or audit gate. The stable binary format remains isolated in PR #38; the production writer has not been switched. diff --git a/docs/RELEASE-QA.md b/docs/RELEASE-QA.md index b2d7d4c5..f4fc05bc 100644 --- a/docs/RELEASE-QA.md +++ b/docs/RELEASE-QA.md @@ -1,6 +1,6 @@ # Reproducible Release QA Matrix -This checklist separates automated checks from manual deployment and browser checks. A checkbox is not evidence of a pass: record the run URL, environment, date, and outcome when the test is actually performed. +This checklist separates automated checks from manual deployment and browser checks. A checkbox is not evidence of a pass: record the run URL, environment, date, commit and outcome when the test actually runs. ## Result vocabulary @@ -9,82 +9,87 @@ This checklist separates automated checks from manual deployment and browser che - **NOT RUN** — no result has been collected. Keep this status until evidence exists. - **BLOCKED** — the procedure could not run because a prerequisite was unavailable; record the blocker. -Do not put real user data, passwords, patterns, recovery keys, plaintext, or production secrets in evidence. +Never place real user data, passwords, patterns, recovery keys, plaintext, or production secrets in evidence. ## Automated checks -For a local checkout with PHP 8.2+ and the required extensions: +For a clean checkout with PHP 8.2+ and required extensions: -| Check | Procedure | Expected result | Result / evidence | +| Check | Procedure | Expected result | Candidate status | | --- | --- | --- | --- | -| Composer manifest | `composer validate --strict --no-check-publish` | Manifest validates strictly | NOT RUN — record CI run | -| Dependency audit | `composer audit --no-interaction` | No unreviewed advisories | BLOCKED until a real `composer.lock` is committed | -| PHP syntax | `find app bin public tests -type f -name '*.php' -print0 | xargs -0 -n1 php -l` | Every PHP file parses | NOT RUN — record CI run | -| JavaScript syntax | `find public/client -type f -name '*.js' -print0 | xargs -0 -n1 node --check` | Every JavaScript file parses | NOT RUN — record CI run | -| Runtime self-check | `php bin/self-check.php` | Required runtime capabilities pass | NOT RUN — record CI run | -| Source audit | `php bin/source-audit.php` | Source audit exits successfully | NOT RUN — record CI run | -| Full security suite | `php bin/security-test.php --full` | Security suite exits successfully | NOT RUN — record CI run | -| PHP regression tests | `for test in tests/*.php; do php "$test" || exit 1; done` | Every PHP test exits successfully | NOT RUN — record CI run | -| Browser V13 round-trip | `node tests/BrowserV13RoundTripTest.mjs` | Compatibility test passes | NOT RUN — record CI run | - -The CI workflow may run some of these checks automatically. Link the actual Actions run rather than copying a previous result or marking this table PASS by assumption. The current endpoint response mapping and known error-contract gaps are tracked in [API Error Contract Audit](API-ERROR-CONTRACT.md); use that audit to add concrete integration scenarios rather than treating source-text checks as end-to-end proof. +| Composer manifest and lock | `composer validate --strict --no-check-publish` | Manifest and committed lockfile agree | NOT RUN for a final release candidate; CI must validate the exact head | +| Dependency audit | `composer audit --no-interaction` | No unreviewed advisories | NOT RUN for the final candidate; a Composer-generated `composer.lock` is now present on the reconciliation branch | +| PHP syntax | `find app bin public tests -type f -name '*.php' -print0 \| xargs -0 -n1 php -l` | Every PHP file parses | Run linked CI for the exact head | +| JavaScript syntax | `find public/client -type f -name '*.js' -print0 \| xargs -0 -n1 node --check` | Every JavaScript file parses | Run linked CI for the exact head | +| Runtime self-check | `php bin/self-check.php` | Required runtime capabilities pass | Run linked CI for the exact head | +| Source audit | `php bin/source-audit.php` | Source audit exits successfully | Run linked CI for the exact head | +| Full security suite | `php bin/security-test.php --full` | Full security/regression suite exits successfully | Run linked CI for the exact head | +| PHP regression suite | `for test in tests/*.php; do php "$test" || exit 1; done` | Every PHP test exits successfully | Run linked CI for the exact head | +| Windows smoke | CI workflow `windows-smoke` job | Composer lock validation/audit, PHP syntax, runtime self-check, source audit, V14 round-trip/restore/archive safety, browser V13 round-trip, and external-trust-root signer test pass on a Windows runner | Run linked CI for the exact head | +| Browser V13 round-trip | `node tests/BrowserV13RoundTripTest.mjs` | Compatibility regression passes | Run linked CI for the exact head | +| Stable binary format | `php tests/StablePackageFormatTest.php` | Fixture, adversarial and round-trip assertions pass | Require the PHP 8.2/8.3 and Windows stable-format jobs on the exact PR #38 head; local XAMPP/shared-hosting behavior remains NOT RUN and this format is not the production/default writer | +| Stable-format KDF budget | Benchmark the fixed default (3 operations, 256 MiB) and maximum accepted KDF cost under supported PHP, Windows, XAMPP/shared hosting, and concurrent failed unlocks | No unexpected process termination or host memory exhaustion; latency fits the deployment budget | NOT RUN on a real target host; do not change the binary KDF profile without a reviewed compatibility/security decision | +| Catalog-list integrity | `php tests/PackageCatalogListingTest.php` | Corrupt or symlinked catalog records are rejected, not silently skipped | Run linked CI for the exact head | +| Upload cleanup | `php tests/ResumableUploadCleanupTest.php` | Owned files are removed, nested symlinks are not followed, failures are surfaced | Linux coverage added; Windows/shared-hosting confirmation NOT RUN | +| Signed release artifact | Build and verify with an independently distributed trusted public key | Signature and hashes verify independently of the build tree | NOT RUN; no v1.0.0 release artifact has been signed and published | + +A Composer-generated lockfile was reused from the readiness work because the current `composer.json` has the same blob SHA as that candidate's manifest. The lockfile is committed on the reconciliation branch; strict validation and dependency audit still must pass in CI on the exact candidate. Do not mark the current candidate PASS based on an older Actions run. ## Windows / XAMPP deployment -Record the Windows version, XAMPP version, PHP version, enabled extensions, and tested commit. +Record the Windows and XAMPP versions, PHP version, enabled extensions, commit, and test evidence. -| Scenario | Procedure / expected result | Result / evidence | +| Scenario | Expected result | Result | | --- | --- | --- | -| Fresh setup | Follow the deployment guide from a clean checkout; setup completes without manual source edits | NOT RUN | -| Runtime requirements | Confirm PHP 8.2+, Sodium, and Zip are available; self-check reports required capabilities | NOT RUN | -| Storage permissions | Create, read, and remove a disposable test artifact using the configured storage location; no broader permissions are needed | NOT RUN | -| Restart behavior | Restart Apache and repeat the health/self-check procedure; configuration remains usable | NOT RUN | -| Missing extension | Disable a required extension in a disposable test environment; startup/check fails clearly without leaking secrets | NOT RUN | -| Disk or permission failure | Use a disposable unwritable/full test destination where safely reproducible; operation fails cleanly and reports no false success | NOT RUN | +| Fresh setup from a clean checkout | Setup completes without source edits | NOT RUN | +| Runtime requirements | PHP 8.2+, Sodium and Zip are present; self-check is clear | NOT RUN | +| Storage permissions | Disposable create/read/remove succeeds without broad permissions | NOT RUN | +| Restart behavior | Apache restart does not invalidate configuration or normal operation | NOT RUN | +| Missing extension | A required extension causes a clear, generic failure without secrets leaking | NOT RUN | +| Disk/permission failure | Failed operations report no false success and clean owned temporary data | NOT RUN | +| Stable-format round trip | Candidate writer and reader operate correctly on Windows filesystems | NOT RUN for local Windows/XAMPP | +| Stable-format KDF resource budget | KDF unlock cost and peak memory remain within the actual host limits, including concurrent invalid attempts | NOT RUN; GitHub Windows CI is not a substitute for the target shared-hosting budget | ## Server package build and restore -Use disposable fixtures only. Never use production data or real credentials. +Use disposable fixtures only. Never use production data or credentials. -| Scenario | Expected result | Result / evidence | +| Scenario | Expected result | Result | | --- | --- | --- | -| Password + pattern | A package created with the intended credential flow restores the exact fixture | NOT RUN | -| Independent recovery key | The documented recovery flow restores the fixture independently of the normal credential path | NOT RUN | -| Wrong password / pattern | Restore fails safely; no plaintext is emitted | NOT RUN | -| Wrong recovery key | Recovery fails safely; no plaintext is emitted | NOT RUN | -| Empty file and nested directories | Restored structure and file contents match the fixture | NOT RUN | -| Unicode filenames | Unicode names and contents survive the round trip on the tested filesystem | NOT RUN | -| File size boundaries | Valid files within configured limits work; over-limit files are rejected before unsafe resource use | NOT RUN | -| Existing destination collision | Restore refuses to overwrite existing files or directories | NOT RUN | -| Malformed or truncated package | Restore rejects the input and cleans temporary artifacts | NOT RUN | -| Tampered header, blob, or manifest | Integrity verification rejects modified data before restored output is accepted | NOT RUN | -| Interrupted write / extraction | Operation reports failure and removes incomplete temporary output where safe | NOT RUN | +| Password and pattern | Exact fixture round trip | NOT RUN manually | +| Recovery-key flow | Recovery independently restores the fixture | NOT RUN manually | +| Wrong password/pattern or recovery key | Restore fails safely; no plaintext or secret is returned | Automated tests exist; re-run on final candidate | +| Empty files and nested directories | Structure and content match source | Automated coverage exists; manual cross-platform confirmation NOT RUN | +| Unicode filenames | Documented normalization/fallback rules hold on the tested filesystem | Linux regression coverage exists; Windows confirmation NOT RUN | +| Size and count boundaries | Over-limit input is rejected before unsafe resource use | Automated coverage exists; re-run on final candidate | +| Existing destination | Existing files/directories remain untouched | Automated no-overwrite tests exist; re-run on final candidate | +| Malformed/truncated/tampered package | Rejected; no incomplete plaintext output remains | Automated regression coverage exists; re-run on final candidate | +| Interrupted extraction/cleanup failure | Remaining owned temporary data is detected and cleanup failure is surfaced | Automated tests exist; cross-platform confirmation NOT RUN | -## Browser compatibility package +## Browser compatibility -Run in real browsers; JavaScript syntax checks alone are not browser compatibility evidence. +Run in real browsers; JavaScript syntax tests alone are not browser compatibility evidence. Record the browser name/version, OS, commit and console errors. -Record browser name/version, operating system, commit, and console errors. - -| Scenario | Expected result | Result / evidence | +| Scenario | Expected result | Result | | --- | --- | --- | -| Browser V13 build and restore | Browser package round-trips a disposable fixture | NOT RUN | -| Format boundary | Browser V13 package is not accepted as a server V14 package, and vice versa unless explicitly documented | NOT RUN | -| Wrong credentials and tampering | Operation fails without exposing plaintext or secret values | NOT RUN | -| Keyboard-only navigation | Controls can be reached and used with a keyboard; focus remains visible | NOT RUN | -| Responsive layout | Main and browser compatibility views remain usable at narrow and wide viewport sizes | NOT RUN | +| Browser V13 build and restore | Browser package round-trips a disposable fixture | Automated regression exists; real-browser test NOT RUN | +| V13/V14 format boundary | Formats are not silently reinterpreted | NOT RUN manually | +| Wrong credentials and tampering | No plaintext or secrets leak | Automated regression exists; real-browser test NOT RUN | +| Keyboard navigation | All controls work and visible focus is retained | NOT RUN | +| Responsive layout | Views remain usable at narrow and wide viewports | NOT RUN | | Reduced motion | Reduced-motion preference is respected | NOT RUN | -| Error states | Failure messages are understandable and do not expose stack traces, filesystem paths, or secrets | NOT RUN | +| Error states | Messages are understandable without stack traces, paths or secrets | NOT RUN | -## Release evidence +## Release evidence required -Before marking a release ready, attach or link: +Before release approval, attach: -- CI workflow run(s) for the exact candidate commit. -- Dependency validation and audit output from the committed lockfile. +- CI runs for the exact release-candidate commit. +- Strict Composer validation and dependency audit from the committed lockfile. - Windows/XAMPP manual results. -- Browser manual results for supported browsers. -- Reproducible steps and sanitized logs for any FAIL or BLOCKED result. -- A release note listing any untested platforms, known limitations, and outstanding blockers. +- Real-browser compatibility and accessibility results. +- Signed release artifact and independent trusted public key/checksum verification. +- Sanitized reproduction/evidence for every FAIL or BLOCKED result. +- Release notes that describe untested platforms, limitations and outstanding blockers. -**Current status:** this document is a test plan, not a claim that the listed manual scenarios have passed. The missing root `composer.lock` remains a release blocker until a genuine lockfile is generated with Composer, reviewed, and committed. +**Current decision:** this document is a test plan, not a claim that the listed manual scenarios have passed. This branch remains an integration/reconciliation draft, not a release candidate. PR #21 remains open and must not be merged without explicit approval. Windows/XAMPP and real-browser validation, independent security review and signed release-artifact verification remain release blockers. Do not change the default writer or create/publish a v1.0.0 tag or release until the explicit release gates are satisfied. diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index ed4d742b..85f26e9d 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -40,6 +40,14 @@ V14 considers an attacker who can: - A universal guarantee against every side channel or implementation flaw. - Treating the V13 browser vault and V14 server engine as cryptographically identical. +## Metadata leakage and offline guessing + +V14 encrypts filenames and the manifest, but the format is not metadata-free. Its readable header/completion records expose the format/version/schema identifiers, package ID, creation timestamp, KDF/cryptographic profile, recovery-slot presence, and integrity-binding values. ZIP structure and ciphertext blob lengths can reveal file counts, approximate per-file sizes, and total package size. These properties are not plaintext-content disclosures, but they can still be sensitive. + +A storage-only attacker who obtains a package can test password-and-pattern guesses offline against the wrapped root key. Argon2id increases the cost of each attempt; it does not make weak credentials safe or provide server-side rate limiting once the package is copied. Passwords and patterns should be long, unique, and not reused. A lost password/pattern or recovery key is not recoverable by the application. + +Authenticated encryption does not identify the sender. Anyone with the required credentials may be able to produce a valid package, and a valid ciphertext alone is not proof of publisher identity. Use a trusted transport channel and verify checksums/signatures out of band when sender identity or substitution resistance matters. Release verification must use an independently trusted public key, not only a key bundled inside the release artifact. + ## Main controls - Argon2id13 credential derivation diff --git a/public/api.php b/public/api.php index f5f1ac97..96a99d3d 100644 --- a/public/api.php +++ b/public/api.php @@ -10,6 +10,7 @@ use SecurePackage\Archive\ArchivePolicy; use SecurePackage\Archive\ArchiveWorkflow; use SecurePackage\Security\AccountStore; +use SecurePackage\Security\AuthenticationRequiredException; use SecurePackage\Security\PackageAccessTokenStore; use SecurePackage\Security\RateLimiter; use SecurePackage\Security\WebSecurity; @@ -18,8 +19,8 @@ use SecurePackage\Storage\JobStore; use SecurePackage\Storage\ResumableUploadStore; -$config = require dirname(__DIR__) . '/config/config.php'; try { + $config = require dirname(__DIR__) . '/config/config.php'; WebSecurity::startSession(); WebSecurity::applyHeaders(); } catch (HttpException $exception) { @@ -37,6 +38,7 @@ JsonResponse::send(['ok' => false, 'error' => $exception->publicMessage], $exception->statusCode); } +try { $jobs = new JobStore($config['storage']['temp'] . DIRECTORY_SEPARATOR . 'jobs'); $policy = new ArchivePolicy( $config['limits']['max_archive_entries'], @@ -54,7 +56,6 @@ $audit = new AuditLogger($config['storage']['audit'] . DIRECTORY_SEPARATOR . 'events.jsonl'); $controller = new PackageController($config, $jobs, $limiter, $archives, $uploads, $catalog, $accessTokens, $accounts, $audit, new \SecurePackage\Security\AuthorizationService()); -try { switch ($action) { case 'me': $controller->me(); case 'register': $controller->register(); @@ -77,6 +78,8 @@ } } catch (HttpException $exception) { JsonResponse::send(['ok' => false, 'error' => $exception->publicMessage], $exception->statusCode); +} catch (AuthenticationRequiredException) { + JsonResponse::send(['ok' => false, 'error' => 'Authentication required.'], 401); } catch (Throwable $exception) { // Log only the exception class: messages and traces can contain paths or sensitive input. error_log('[Secure Package] Unhandled API exception: ' . get_class($exception)); diff --git a/tests/ApiHttpIntegrationTest.php b/tests/ApiHttpIntegrationTest.php index 1c2e1e87..45b8d009 100644 --- a/tests/ApiHttpIntegrationTest.php +++ b/tests/ApiHttpIntegrationTest.php @@ -3,6 +3,10 @@ require dirname(__DIR__) . '/bootstrap.php'; +use SecurePackage\Project\PackageCatalog; +use SecurePackage\Project\PackageId; +use SecurePackage\Project\V14PackageBuilder; + $sourceRoot = dirname(__DIR__); $base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'spk-api-http-' . bin2hex(random_bytes(8)); $fixture = $base . DIRECTORY_SEPARATOR . 'fixture'; @@ -105,7 +109,38 @@ } $expect(404, $request('api.php?action=unknown-action'), 'Unknown action'); - $expect(405, $request('api.php?action=login', 'GET'), 'GET login method'); + + // Configuration loading must fail inside the JSON error boundary without leaking details. + $configFile = $fixture . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.php'; + $configOriginal = file_get_contents($configFile); + if (!is_string($configOriginal)) { + throw new RuntimeException('Unable to read isolated config fixture.'); + } + try { + $configSentinel = 'CONFIG_PRIVATE_SENTINEL_' . bin2hex(random_bytes(8)); + $brokenConfig = " 'invalid', 'archive_id' => 'invalid', 'password' => ''], JSON_THROW_ON_ERROR) + ); + $expect(404, $missingJob, 'Missing archive job'); + + $missingPackage = $request( + 'api.php?action=access-token', + 'POST', + array_merge($common, ['X-CSRF-Token: ' . $csrf]), + json_encode(['package_id' => str_repeat('f', 48)], JSON_THROW_ON_ERROR) + ); + $expect(404, $missingPackage, 'Missing package access token'); + $storageRoot = $fixture . DIRECTORY_SEPARATOR . 'storage'; $catalogDir = $storageRoot . DIRECTORY_SEPARATOR . 'catalog'; $packagesDir = $storageRoot . DIRECTORY_SEPARATOR . 'packages'; + + // A catalog initialization failure is a storage fault, not a missing-package 404. + $catalogBackup = $storageRoot . DIRECTORY_SEPARATOR . 'catalog-backup'; + if (!is_dir($catalogDir) || !rename($catalogDir, $catalogBackup)) { + throw new RuntimeException('Unable to prepare catalog initialization failure fixture.'); + } + try { + if (file_put_contents($catalogDir, 'not-a-directory') === false) { + throw new RuntimeException('Unable to prepare catalog initialization failure fixture.'); + } + $catalogFailure = $request( + 'api.php?action=packages', + 'POST', + array_merge($common, ['X-CSRF-Token: ' . $csrf]), + '{}' + ); + $expect(500, $catalogFailure, 'Package catalog initialization failure'); + if (!str_contains($catalogFailure['body'], 'Internal server error.') + || str_contains($catalogFailure['body'], 'RuntimeException') + || str_contains($catalogFailure['body'], 'catalog') + || str_contains($catalogFailure['body'], $fixture)) { + throw new RuntimeException('Catalog initialization failure exposed implementation details.'); + } + } finally { + if (is_file($catalogDir) || is_link($catalogDir)) @unlink($catalogDir); + if (is_dir($catalogBackup) && !@rename($catalogBackup, $catalogDir)) { + throw new RuntimeException('Unable to restore catalog directory after fault injection.'); + } + } + + $packageId = str_repeat('a', 48); if (!is_dir($catalogDir) && !mkdir($catalogDir, 0700, true) && !is_dir($catalogDir)) { throw new RuntimeException('Unable to prepare inventory catalog fixture.'); } @@ -194,6 +279,64 @@ throw new RuntimeException('Encrypted package inventory omitted or misreported safe package metadata.'); } + // Use a real encrypted package to reach the restore directory creation path. + $restorePackageId = PackageId::generate(); + $restoreSource = $base . DIRECTORY_SEPARATOR . 'restore-source'; + $restorePackageDir = $packagesDir . DIRECTORY_SEPARATOR . $restorePackageId; + if (!mkdir($restoreSource, 0700, true) + || file_put_contents($restoreSource . DIRECTORY_SEPARATOR . 'restore-fixture.txt', "restore storage test\n") === false) { + throw new RuntimeException('Unable to prepare restore source fixture.'); + } + $restorePassword = 'Strong!Password2026#'; + $restorePattern = 'Az!71_xYp#42Qw9'; + try { + (new V14PackageBuilder())->build($restoreSource, $restorePackageDir, $restorePassword, $restorePattern, $restorePackageId, true); + } finally { + sodium_memzero($restorePassword); + sodium_memzero($restorePattern); + } + (new PackageCatalog($catalogDir))->create( + $restorePackageId, + 'account:' . $registeredData['account_id'], + 'restore-failure-fixture' + ); + + // Keep job/token subdirectories intact, but make only the temporary root unwritable. + // This lets API service construction succeed and exercises the actual restore-output write. + $tempPath = $storageRoot . DIRECTORY_SEPARATOR . 'temp'; + $tempMode = is_dir($tempPath) ? (fileperms($tempPath) & 0777) : false; + $blockedRestoreTest = is_int($tempMode) && @chmod($tempPath, 0500); + clearstatcache(true, $tempPath); + if ($blockedRestoreTest && !is_writable($tempPath)) { + try { + $restoreFailure = $request( + 'api.php?action=decrypt', + 'POST', + array_merge($common, ['X-CSRF-Token: ' . $csrf]), + json_encode([ + 'package_id' => $restorePackageId, + 'password' => 'Strong!Password2026#', + 'pattern' => 'Az!71_xYp#42Qw9', + ], JSON_THROW_ON_ERROR) + ); + $expect(500, $restoreFailure, 'Restore destination storage failure'); + if (!str_contains($restoreFailure['body'], 'Internal server error.') + || str_contains($restoreFailure['body'], 'PackageStorageException') + || str_contains($restoreFailure['body'], 'restore') + || str_contains($restoreFailure['body'], $fixture) + || str_contains($restoreFailure['body'], $restorePackageId)) { + throw new RuntimeException('Restore storage failure exposed implementation details.'); + } + } finally { + if (!@chmod($tempPath, $tempMode)) { + throw new RuntimeException('Unable to restore temporary storage permissions.'); + } + } + } else { + if (is_int($tempMode)) @chmod($tempPath, $tempMode); + echo "Restore destination permission-failure scenario skipped (host does not enforce directory mode changes).\n"; + } + $accountFile = $fixture . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'accounts' . DIRECTORY_SEPARATOR . 'users.json'; if (!is_file($accountFile) || file_put_contents($accountFile, '{broken-account-store') === false) { throw new RuntimeException('Unable to prepare internal-error response fixture.'); diff --git a/tests/ArchiveWorkflowDepthTest.php b/tests/ArchiveWorkflowDepthTest.php index 458e729f..432e7e95 100644 --- a/tests/ArchiveWorkflowDepthTest.php +++ b/tests/ArchiveWorkflowDepthTest.php @@ -5,6 +5,7 @@ use SecurePackage\Archive\ArchivePolicy; use SecurePackage\Archive\ArchiveWorkflow; +use SecurePackage\Archive\ArchiveProcessingException; use SecurePackage\Storage\JobStore; if (!extension_loaded('zip') || !class_exists(ZipArchive::class)) { @@ -44,7 +45,7 @@ $rejected = false; try { $workflow->process($job['job_id'], $archiveId, ''); - } catch (RuntimeException $error) { + } catch (ArchiveProcessingException $error) { $rejected = str_contains($error->getMessage(), 'Unable to extract archive.'); } if (!$rejected) { diff --git a/tests/ArchiveWorkflowPolicyIntegrationTest.php b/tests/ArchiveWorkflowPolicyIntegrationTest.php index 9224e873..34064c87 100644 --- a/tests/ArchiveWorkflowPolicyIntegrationTest.php +++ b/tests/ArchiveWorkflowPolicyIntegrationTest.php @@ -5,6 +5,7 @@ use SecurePackage\Archive\ArchivePolicy; use SecurePackage\Archive\ArchiveWorkflow; +use SecurePackage\Archive\ArchiveProcessingException; use SecurePackage\Storage\JobStore; if (!extension_loaded('zip') || !class_exists(ZipArchive::class)) { @@ -36,7 +37,7 @@ $rejected = false; try { $workflow->process($job['job_id'], $archiveId, ''); - } catch (RuntimeException $error) { + } catch (ArchiveProcessingException $error) { $rejected = str_contains($error->getMessage(), 'Unable to extract archive.'); } if (!$rejected) { diff --git a/tests/ArchiveWorkflowRollbackTest.php b/tests/ArchiveWorkflowRollbackTest.php index 0d3940cd..eac860c9 100644 --- a/tests/ArchiveWorkflowRollbackTest.php +++ b/tests/ArchiveWorkflowRollbackTest.php @@ -5,6 +5,7 @@ use SecurePackage\Archive\ArchivePolicy; use SecurePackage\Archive\ArchiveWorkflow; +use SecurePackage\Archive\ArchiveProcessingException; use SecurePackage\Storage\JobStore; if (!extension_loaded('zip') || !class_exists(ZipArchive::class)) { @@ -39,7 +40,7 @@ try { $workflow->process($jobId, $archiveId, ''); throw new RuntimeException('Expected archive output collision was not rejected.'); - } catch (RuntimeException $exception) { + } catch (ArchiveProcessingException $exception) { if (!str_contains($exception->getMessage(), 'Unable to extract archive')) { throw $exception; } diff --git a/tests/JobStoreIntegrityTest.php b/tests/JobStoreIntegrityTest.php new file mode 100644 index 00000000..dbd7957c --- /dev/null +++ b/tests/JobStoreIntegrityTest.php @@ -0,0 +1,107 @@ +getPathname()); + } + @rmdir($path); +}; + +try { + $store = new JobStore($root); + $job = $store->create('owner-token'); + $jobPath = $job['path']; + $id = $job['id']; + + if (!is_dir($jobPath . DIRECTORY_SEPARATOR . 'source') + || !is_dir($jobPath . DIRECTORY_SEPARATOR . 'archives')) { + throw new RuntimeException('Job creation did not initialize required directories.'); + } + if (($store->readState($id)['owner_hash'] ?? null) !== hash('sha256', 'owner-token')) { + throw new RuntimeException('Created job owner state is inconsistent.'); + } + + $store->writeState($id, [ + 'status' => 'processing', + 'created_at' => 1, + 'owner_hash' => hash('sha256', 'owner-token'), + ]); + if (($store->readState($id)['status'] ?? null) !== 'processing') { + throw new RuntimeException('Job state update did not commit the complete replacement.'); + } + + $outsideFile = $outside . DIRECTORY_SEPARATOR . 'preserve.txt'; + file_put_contents($outsideFile, 'outside data must survive'); + $nestedLink = $jobPath . DIRECTORY_SEPARATOR . 'source' . DIRECTORY_SEPARATOR . 'outside-link'; + if (@symlink($outside, $nestedLink)) { + $store->remove($id); + if (file_exists($jobPath) || is_link($jobPath)) { + throw new RuntimeException('Job cleanup left owned storage behind.'); + } + if (!is_file($outsideFile) || file_get_contents($outsideFile) !== 'outside data must survive') { + throw new RuntimeException('Job cleanup followed a nested symlink.'); + } + } else { + $store->remove($id); + if (file_exists($jobPath) || is_link($jobPath)) { + throw new RuntimeException('Job cleanup failed without symlink support.'); + } + } + + $missingRejected = false; + try { + $store->readState($id); + } catch (JobNotAvailableException) { + $missingRejected = true; + } + if (!$missingRejected) { + throw new RuntimeException('Removed job remained available.'); + } + + $corrupt = $store->create('corrupt-owner'); + if (file_put_contents($corrupt['path'] . DIRECTORY_SEPARATOR . 'state.json', '{"broken":') === false) { + throw new RuntimeException('Unable to prepare corrupt job state fixture.'); + } + $corruptRejected = false; + try { + $store->readState($corrupt['id']); + } catch (RuntimeException $exception) { + $corruptRejected = str_contains($exception->getMessage(), 'corrupt') + || str_contains($exception->getMessage(), 'identity'); + } + if (!$corruptRejected) { + throw new RuntimeException('Corrupt job state was silently accepted.'); + } + + $invalidIdentifierRejected = false; + try { + $store->remove('../outside'); + } catch (RuntimeException) { + $invalidIdentifierRejected = true; + } + if (!$invalidIdentifierRejected) { + throw new RuntimeException('Invalid job identifier was accepted for cleanup.'); + } + + echo "Job store integrity tests passed.\n"; +} finally { + $removeTree($base); +} diff --git a/tests/PackageAccessTokenStoreTest.php b/tests/PackageAccessTokenStoreTest.php index 30bac488..ef43678a 100644 --- a/tests/PackageAccessTokenStoreTest.php +++ b/tests/PackageAccessTokenStoreTest.php @@ -45,6 +45,60 @@ throw new RuntimeException('Malformed token was accepted.'); } + $revoked = $store->issue('package-to-revoke', 'account:owner-c', false); + $unrelated = $store->issue('unrelated-package', 'account:owner-c', false); + $store->revokeForPackage('package-to-revoke'); + + $revokedRejected = false; + try { + $store->consume($revoked); + } catch (RuntimeException) { + $revokedRejected = true; + } + if (!$revokedRejected) { + throw new RuntimeException('Package-scoped revocation left the target token usable.'); + } + if (($store->consume($unrelated)['package_id'] ?? null) !== 'unrelated-package') { + throw new RuntimeException('Package-scoped revocation removed an unrelated token.'); + } + + // Corrupt token records must not be silently skipped during revocation, since + // the store cannot prove that an unreadable record belongs to another package. + $corruptDirectory = $directory . DIRECTORY_SEPARATOR . 'corrupt'; + if (!mkdir($corruptDirectory, 0700, true)) { + throw new RuntimeException('Unable to create corrupt token-store fixture.'); + } + try { + $corruptStore = new PackageAccessTokenStore($corruptDirectory, 60); + $corruptToken = $corruptStore->issue('package-corrupt-record', 'account:owner-d', false); + $corruptPath = $corruptDirectory . DIRECTORY_SEPARATOR . hash('sha256', $corruptToken) . '.json'; + if (file_put_contents($corruptPath, '{"package_id":') === false) { + throw new RuntimeException('Unable to corrupt token record fixture.'); + } + $corruptionDetected = false; + try { + $corruptStore->revokeForPackage('package-corrupt-record'); + } catch (RuntimeException $exception) { + $corruptionDetected = str_contains($exception->getMessage(), 'corrupt') + || str_contains($exception->getMessage(), 'invalid'); + } + if (!$corruptionDetected) { + throw new RuntimeException('Token revocation silently ignored a corrupt record.'); + } + } finally { + foreach (glob($corruptDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + if (is_link($file) || is_file($file)) @unlink($file); + elseif (is_dir($file)) { + foreach (glob($file . DIRECTORY_SEPARATOR . '*') ?: [] as $nested) { + if (is_link($nested) || is_file($nested)) @unlink($nested); + } + @rmdir($file); + } + } + @rmdir($corruptDirectory); + } + + echo "Package access token regression tests passed.\n"; } finally { foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { diff --git a/tests/PackageAuthorizationTest.php b/tests/PackageAuthorizationTest.php index d1b3f178..91966967 100644 --- a/tests/PackageAuthorizationTest.php +++ b/tests/PackageAuthorizationTest.php @@ -2,14 +2,15 @@ declare(strict_types=1); require dirname(__DIR__) . '/bootstrap.php'; use SecurePackage\Project\PackageCatalog; +use SecurePackage\Project\PackageNotAvailableException; $root = sys_get_temp_dir() . '/secure-package-v5-catalog-' . bin2hex(random_bytes(5)); $c = new PackageCatalog($root); $id = bin2hex(random_bytes(24)); $c->create($id, 'account:owner-a', 'demo'); $c->assertOwner($id, 'account:owner-a'); -try { $c->assertOwner($id, 'account:owner-b'); throw new RuntimeException('Unauthorized owner was accepted.'); } catch (RuntimeException $e) { if ($e->getMessage() !== 'Package does not exist.') throw $e; } +try { $c->assertOwner($id, 'account:owner-b'); throw new RuntimeException('Unauthorized owner was accepted.'); } catch (PackageNotAvailableException) {} $c->revoke($id, 'account:owner-a'); -try { $c->assertAvailable($id); throw new RuntimeException('Revoked package was accepted as available.'); } catch (RuntimeException $e) { if ($e->getMessage() !== 'Package is revoked.') throw $e; } +try { $c->assertAvailable($id); throw new RuntimeException('Revoked package was accepted as available.'); } catch (PackageNotAvailableException) {} echo "Package authorization tests passed.\n"; diff --git a/tests/PackageBuildCleanupTest.php b/tests/PackageBuildCleanupTest.php index f881c6d7..bd9786a7 100644 --- a/tests/PackageBuildCleanupTest.php +++ b/tests/PackageBuildCleanupTest.php @@ -18,7 +18,12 @@ } $cleanupHelper = substr($controller, $cleanupHelperStart, $removePathStart - $cleanupHelperStart); -$failureStart = strpos($build, '} catch (\\Throwable) {'); +$failureStart = strpos($build, '} catch (\\Throwable $exception) {'); +$cleanupFailedBuildStart = strpos($controller, 'private function cleanupFailedBuild('); +$cleanupFailedBuildEnd = strpos($controller, 'private function removeJobDirectory(', $cleanupFailedBuildStart === false ? 0 : $cleanupFailedBuildStart); +$cleanupFailedBuild = $cleanupFailedBuildStart !== false && $cleanupFailedBuildEnd !== false + ? substr($controller, $cleanupFailedBuildStart, $cleanupFailedBuildEnd - $cleanupFailedBuildStart) + : ''; $failurePath = $failureStart === false ? '' : substr($build, $failureStart); $checks = [ 'Input validation precedes the encryption build' => @@ -30,11 +35,13 @@ && strpos($build, '$this->removeJobDirectory($jobId);') < strpos($build, "'ok' => true"), 'Failed builds preserve the plaintext source job for recovery' => $failureStart !== false - && !str_contains($failurePath, '$this->removeJobDirectory($jobId);'), + && !str_contains($failurePath, '$this->removeJobDirectory($jobId);') + && !str_contains($cleanupFailedBuild, '$this->removeJobDirectory($jobId);'), 'Incomplete encrypted artifacts are cleaned only before catalog creation' => - str_contains($failurePath, 'if (!$packageCreated)') - && str_contains($failurePath, '$this->removePath($portableArchive)') - && str_contains($failurePath, '$this->removePath($packageDirectory)'), + str_contains($failurePath, '$this->cleanupFailedBuild($portableArchive, $packageDirectory, $packageCreated)') + && str_contains($cleanupFailedBuild, 'if ($packageCreated)') + && str_contains($cleanupFailedBuild, '$this->removePath($path)') + && str_contains($cleanupFailedBuild, 'if (file_exists($path) || is_link($path)'), 'Cleanup verifies the job path no longer exists' => str_contains($cleanupHelper, 'if (file_exists($path) || is_link($path)'), ]; diff --git a/tests/PackageCatalogListingTest.php b/tests/PackageCatalogListingTest.php new file mode 100644 index 00000000..0d45f04c --- /dev/null +++ b/tests/PackageCatalogListingTest.php @@ -0,0 +1,79 @@ +create($firstId, $owner, 'First package'); + usleep(1000); + $catalog->create($secondId, $owner, 'Second package'); + $catalog->create($otherId, $other, 'Other owner package'); + + $listed = $catalog->listForOwner($owner); + if (count($listed) !== 2 + || !hash_equals(hash('sha256', $owner), (string) $listed[0]['owner_hash']) + || !hash_equals(hash('sha256', $owner), (string) $listed[1]['owner_hash'])) { + throw new RuntimeException('Package catalog listing did not return only valid records for the requested owner.'); + } + + $corruptId = PackageId::generate(); + $corruptPath = $root . DIRECTORY_SEPARATOR . $corruptId . '.json'; + if (file_put_contents($corruptPath, '{"package_id":') === false) { + throw new RuntimeException('Unable to create corrupt catalog record fixture.'); + } + $corruptRejected = false; + try { + $catalog->listForOwner($owner); + } catch (RuntimeException $exception) { + $corruptRejected = str_contains($exception->getMessage(), 'corrupt'); + } + if (!$corruptRejected) { + throw new RuntimeException('Catalog listing silently skipped a corrupt record.'); + } + @unlink($corruptPath); + + $validRecordPath = $root . DIRECTORY_SEPARATOR . $firstId . '.json'; + $symlinkId = PackageId::generate(); + $symlinkPath = $root . DIRECTORY_SEPARATOR . $symlinkId . '.json'; + if (@symlink($validRecordPath, $symlinkPath)) { + $symlinkRejected = false; + try { + $catalog->listForOwner($owner); + } catch (RuntimeException $exception) { + $symlinkRejected = str_contains($exception->getMessage(), 'non-regular'); + } + if (!$symlinkRejected) { + throw new RuntimeException('Catalog listing followed or silently ignored a record symlink.'); + } + @unlink($symlinkPath); + } + + echo "Package catalog listing integrity tests passed.\n"; +} finally { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + if ($item->isLink() || !$item->isDir()) { + @unlink($item->getPathname()); + } else { + @rmdir($item->getPathname()); + } + } + @rmdir($root); +} diff --git a/tests/PackageCatalogMutationTest.php b/tests/PackageCatalogMutationTest.php index 1e002338..aa969b04 100644 --- a/tests/PackageCatalogMutationTest.php +++ b/tests/PackageCatalogMutationTest.php @@ -4,6 +4,7 @@ require dirname(__DIR__) . '/bootstrap.php'; use SecurePackage\Project\PackageCatalog; +use SecurePackage\Project\PackageNotAvailableException; $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'spk-package-catalog-' . bin2hex(random_bytes(8)); if (!mkdir($directory, 0700, true)) { @@ -103,6 +104,28 @@ throw new RuntimeException('Concurrent catalog record owner hash is inconsistent.'); } + $deleteId = str_repeat('c', 48); + $catalog->create($deleteId, $owner, 'Delete fixture'); + $wrongDeleteRejected = false; + try { + $catalog->delete($deleteId, 'account:owner-b'); + } catch (PackageNotAvailableException) { + $wrongDeleteRejected = true; + } + if (!$wrongDeleteRejected || !hash_equals(hash('sha256', $owner), $catalog->get($deleteId)['owner_hash'])) { + throw new RuntimeException('Unauthorized package deletion modified the catalog record.'); + } + $catalog->delete($deleteId, $owner); + $deleteMissing = false; + try { + $catalog->get($deleteId); + } catch (PackageNotAvailableException) { + $deleteMissing = true; + } + if (!$deleteMissing || file_exists($directory . DIRECTORY_SEPARATOR . $deleteId . '.json')) { + throw new RuntimeException('Package catalog deletion left its record behind.'); + } + echo "Package catalog mutation regression tests passed.\n"; } finally { $iterator = new RecursiveIteratorIterator( diff --git a/tests/PackageCatalogTest.php b/tests/PackageCatalogTest.php index 613f70ca..e12d285a 100644 --- a/tests/PackageCatalogTest.php +++ b/tests/PackageCatalogTest.php @@ -5,14 +5,59 @@ use SecurePackage\Project\PackageCatalog; use SecurePackage\Project\PackageId; +use SecurePackage\Project\PackageNotAvailableException; -$root = sys_get_temp_dir() . '/securepkg-catalog-test-' . bin2hex(random_bytes(6)); +$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'securepkg-catalog-test-' . bin2hex(random_bytes(6)); $id = PackageId::generate(); $catalog = new PackageCatalog($root); -$catalog->create($id, 'account:a', 'demo'); -$catalog->setExpiry($id, 'account:a', time() + 3600); -try { $catalog->setExpiry($id, 'account:b', time() + 3600); throw new RuntimeException('Cross-owner catalog access accepted.'); } catch (Throwable) {} -$catalog->revoke($id, 'account:a'); -$r = $catalog->get($id); -if ($r['revoked_at'] === null) throw new RuntimeException('Package was not revoked.'); -echo "Package catalog tests passed.\n"; + +$removeTree = static function (string $path) use (&$removeTree): void { + if (is_link($path) || is_file($path)) { + @unlink($path); + return; + } + if (!is_dir($path)) return; + foreach (new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS) as $entry) { + $removeTree($entry->getPathname()); + } + @rmdir($path); +}; + +try { + $catalog->create($id, 'account:a', 'demo'); + $catalog->setExpiry($id, 'account:a', time() + 3600); + + $wrongOwnerFailure = null; + try { + $catalog->assertOwner($id, 'account:b'); + } catch (PackageNotAvailableException $exception) { + $wrongOwnerFailure = $exception; + } + if (!$wrongOwnerFailure instanceof PackageNotAvailableException) { + throw new RuntimeException('Cross-owner catalog access was accepted.'); + } + + $missingFailure = null; + try { + $catalog->assertOwner(PackageId::generate(), 'account:a'); + } catch (PackageNotAvailableException $exception) { + $missingFailure = $exception; + } + if (!$missingFailure instanceof PackageNotAvailableException) { + throw new RuntimeException('Missing package did not use the privacy-preserving unavailable response.'); + } + if (get_class($wrongOwnerFailure) !== get_class($missingFailure) + || $wrongOwnerFailure->getMessage() !== $missingFailure->getMessage()) { + throw new RuntimeException('Missing and unauthorized package lookups expose different failure details.'); + } + + $catalog->revoke($id, 'account:a'); + $record = $catalog->get($id); + if ($record['revoked_at'] === null) { + throw new RuntimeException('Package was not revoked.'); + } + + echo "Package catalog tests passed.\n"; +} finally { + $removeTree($root); +} diff --git a/tests/PackageControllerPathCleanupTest.php b/tests/PackageControllerPathCleanupTest.php new file mode 100644 index 00000000..6153b8c5 --- /dev/null +++ b/tests/PackageControllerPathCleanupTest.php @@ -0,0 +1,71 @@ +newInstanceWithoutConstructor(); + $remove = (new ReflectionClass(PackageController::class))->getMethod('removePath'); + + $nested = $owned . DIRECTORY_SEPARATOR . 'nested'; + if (!mkdir($nested, 0700, true) + || file_put_contents($nested . DIRECTORY_SEPARATOR . 'payload.txt', 'remove me') === false) { + throw new RuntimeException('Unable to create nested cleanup fixture.'); + } + $outsideFile = $outside . DIRECTORY_SEPARATOR . 'keep.txt'; + file_put_contents($outsideFile, 'preserve external data'); + + $nestedLink = $nested . DIRECTORY_SEPARATOR . 'outside-link'; + if (@symlink($outside, $nestedLink)) { + $remove->invoke($controller, $owned); + if (file_exists($owned) || is_link($owned)) { + throw new RuntimeException('Owned cleanup left the root directory behind.'); + } + if (!is_file($outsideFile) || file_get_contents($outsideFile) !== 'preserve external data') { + throw new RuntimeException('Owned cleanup followed a nested symlink.'); + } + } else { + $remove->invoke($controller, $owned); + if (file_exists($owned) || is_link($owned)) { + throw new RuntimeException('Owned cleanup failed without symlink support.'); + } + } + + $externalLink = $base . DIRECTORY_SEPARATOR . 'external-root-link'; + if (@symlink($outside, $externalLink)) { + $remove->invoke($controller, $externalLink); + if (is_link($externalLink) || !is_file($outsideFile)) { + throw new RuntimeException('Cleanup followed a symlink supplied as its root.'); + } + } + + // Absence is idempotent; no failure is needed when the previously owned path is gone. + $remove->invoke($controller, $owned); + + echo "Controller path cleanup tests passed.\n"; +} finally { + foreach ([$owned, $outside, $base] as $path) { + if (is_link($path) || is_file($path)) { + @unlink($path); + } elseif (is_dir($path)) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + if ($item->isLink() || !$item->isDir()) @unlink($item->getPathname()); + else @rmdir($item->getPathname()); + } + @rmdir($path); + } + } +} diff --git a/tests/ReleaseSignerExternalTrustTest.php b/tests/ReleaseSignerExternalTrustTest.php index addeb873..8764c6dd 100644 --- a/tests/ReleaseSignerExternalTrustTest.php +++ b/tests/ReleaseSignerExternalTrustTest.php @@ -25,7 +25,7 @@ throw new RuntimeException('Unable to prepare release-signing fixture.'); } } - foreach (['bootstrap.php', 'composer.json', 'VERSION'] as $file) { + foreach (['bootstrap.php', 'composer.json', 'composer.lock', 'VERSION'] as $file) { if (!copy($sourceRoot . DIRECTORY_SEPARATOR . $file, $fixture . DIRECTORY_SEPARATOR . $file)) { throw new RuntimeException('Unable to copy release-signing fixture.'); } diff --git a/tests/RestoreFailureCleanupTest.php b/tests/RestoreFailureCleanupTest.php index 345593e0..45a11ec3 100644 --- a/tests/RestoreFailureCleanupTest.php +++ b/tests/RestoreFailureCleanupTest.php @@ -14,12 +14,15 @@ $checks = [ 'Restore output path is initialized before restore work' => strpos($decrypt, '$outputDir = null;') !== false, - 'Failed decrypt/restore paths remove temporary plaintext output' => - strpos($decrypt, 'if (is_string($outputDir)) $this->removePath($outputDir);') !== false, - 'Restore output cleanup happens before the error response' => - strpos($decrypt, '$this->removePath($outputDir);') < strpos($decrypt, "Unable to open package."), - 'Temporary package extraction cleanup remains in place' => - strpos($decrypt, 'if ($temporaryPackageDir !== null) $this->removePath($temporaryPackageDir);') !== false, + 'Failed decrypt/restore paths invoke verified temporary plaintext cleanup' => + str_contains($decrypt, '$this->cleanupFailedRestore($outputDir, $password, $pattern, $recoveryKey);'), + 'Restore cleanup verifies deletion and clears secrets even if cleanup fails' => + str_contains($controller, 'private function cleanupFailedRestore(') + && str_contains($controller, 'if (file_exists($outputDir) || is_link($outputDir))') + && str_contains($controller, 'sodium_memzero($password);'), + 'Temporary package extraction cleanup is verified' => + str_contains($decrypt, 'if ($temporaryPackageDir !== null) {') + && str_contains($decrypt, 'Unable to clean temporary package extraction.'), ]; foreach ($checks as $description => $passed) { diff --git a/tests/ResumableUploadCleanupTest.php b/tests/ResumableUploadCleanupTest.php index 3fc672ad..1f9eb975 100644 --- a/tests/ResumableUploadCleanupTest.php +++ b/tests/ResumableUploadCleanupTest.php @@ -35,6 +35,40 @@ } } + $direct = $store->initialize(10, 'owner-direct-cleanup'); + $directDir = $root . DIRECTORY_SEPARATOR . $direct['id']; + if (!mkdir($directDir . DIRECTORY_SEPARATOR . 'nested', 0700, true) + || file_put_contents($directDir . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'inside.txt', 'remove me') === false) { + throw new RuntimeException('Unable to prepare nested upload cleanup fixture.'); + } + $externalKeep = $outside . DIRECTORY_SEPARATOR . 'survive-direct-remove.txt'; + file_put_contents($externalKeep, 'outside upload storage'); + $nestedLink = $directDir . DIRECTORY_SEPARATOR . 'nested' . DIRECTORY_SEPARATOR . 'external-link'; + if (@symlink($outside, $nestedLink)) { + $store->remove($direct['id']); + if (file_exists($directDir) || is_link($directDir)) { + throw new RuntimeException('Direct upload removal left owned storage behind.'); + } + if (!is_file($externalKeep) || file_get_contents($externalKeep) !== 'outside upload storage') { + throw new RuntimeException('Direct upload removal followed a nested symlink.'); + } + } else { + $store->remove($direct['id']); + if (file_exists($directDir) || is_link($directDir)) { + throw new RuntimeException('Direct upload removal failed without a symlink fixture.'); + } + } + + $invalidIdRejected = false; + try { + $store->remove('../outside'); + } catch (RuntimeException) { + $invalidIdRejected = true; + } + if (!$invalidIdRejected) { + throw new RuntimeException('Invalid upload cleanup identifier was silently ignored.'); + } + echo "Resumable upload cleanup tests passed.\n"; } finally { foreach ([$root, $outside] as $directory) { diff --git a/tests/SecurityDiagnosticsTest.php b/tests/SecurityDiagnosticsTest.php index ffcbd404..5a144916 100644 --- a/tests/SecurityDiagnosticsTest.php +++ b/tests/SecurityDiagnosticsTest.php @@ -1,24 +1,33 @@ extract($corruptArchive, $corruptDestination); + throw new RuntimeException('Non-ZIP package archive was accepted.'); + } catch (\SecurePackage\Project\PackageRestoreRejectedException) { + if (file_exists($corruptDestination) || is_link($corruptDestination)) { + throw new RuntimeException('Rejected non-ZIP archive left an extraction directory behind.'); + } + } + + $unsafeArchive = $root . '/unsafe-path.spkg14'; + $malicious = new ZipArchive(); + if ($malicious->open($unsafeArchive, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true + || !$malicious->addFromString('../escape.txt', 'must not escape') + || !$malicious->close()) { + throw new RuntimeException('Unable to create unsafe-path archive fixture.'); + } + $unsafeDestination = $root . '/unsafe-restore'; + try { + $service->extract($unsafeArchive, $unsafeDestination); + throw new RuntimeException('Archive path traversal was accepted.'); + } catch (\SecurePackage\Project\PackageRestoreRejectedException) { + if (file_exists($unsafeDestination) || is_link($unsafeDestination) + || file_exists($root . '/escape.txt')) { + throw new RuntimeException('Rejected traversal archive left output behind.'); + } + } + + // A pre-existing restore directory is never modified, even if its name collides with + // the caller's requested output path. + $existingDestination = $root . '/existing-restore'; + mkdir($existingDestination, 0700, true); + file_put_contents($existingDestination . '/keep.txt', 'preserve existing output'); + try { + $service->extract($archive, $existingDestination); + throw new RuntimeException('Existing extraction destination was overwritten.'); + } catch (\SecurePackage\Project\PackageStorageException) { + if (file_get_contents($existingDestination . '/keep.txt') !== 'preserve existing output') { + throw new RuntimeException('Existing extraction destination was modified.'); + } + } + + echo "V14 package archive round-trip and rejection tests passed.\n"; } finally { if (is_dir($root)) { $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); diff --git a/tests/V14PackageServiceCleanupTest.php b/tests/V14PackageServiceCleanupTest.php new file mode 100644 index 00000000..3ab236ab --- /dev/null +++ b/tests/V14PackageServiceCleanupTest.php @@ -0,0 +1,59 @@ +getPathname()); + } + @rmdir($path); +}; + +$password = 'Strong!Password2026#'; +$pattern = 'Az!71_xYp#42Qw9'; + +try { + $service = new V14PackageService(new V14PackageBuilder(), new FileStore($storage)); + $failedAsExpected = false; + try { + $service->build( + $root . DIRECTORY_SEPARATOR . 'source-does-not-exist', + 'cleanup-test', + $password, + $pattern, + false + ); + } catch (RuntimeException) { + $failedAsExpected = true; + } + + if (!$failedAsExpected) { + throw new RuntimeException('A V14 build with a missing source directory was accepted.'); + } + $remaining = iterator_to_array(new FilesystemIterator($storage, FilesystemIterator::SKIP_DOTS)); + if ($remaining !== []) { + throw new RuntimeException('Failed V14 package build left artifacts in storage.'); + } + + echo "V14 package service cleanup regression test passed.\n"; +} finally { + sodium_memzero($password); + sodium_memzero($pattern); + $removeTree($root); +} diff --git a/tests/V14RestoreSafetyTest.php b/tests/V14RestoreSafetyTest.php index 8e4643e2..0ebeffec 100644 --- a/tests/V14RestoreSafetyTest.php +++ b/tests/V14RestoreSafetyTest.php @@ -5,6 +5,9 @@ use SecurePackage\Project\V14PackageBuilder; use SecurePackage\Project\V14PackageReader; +use SecurePackage\Project\PackageRestoreRejectedException; +use SecurePackage\Project\PackageStorageException; +use SecurePackage\Crypto\V14Stream; $root = sys_get_temp_dir() . '/securepkg-v14-restore-safe-' . bin2hex(random_bytes(6)); $source = $root . '/source'; @@ -24,17 +27,36 @@ if ($expected === false || $actual === false || !hash_equals($expected, $actual)) throw new RuntimeException('Restore round-trip failed.'); if (file_get_contents($restore . '/docs/deep/zero.bin') !== '') throw new RuntimeException('Empty file restore failed.'); - $rejectedExistingDestination = false; + $wrongCredentialRestore = $root . '/wrong-credential-restore'; + try { + (new V14PackageReader())->restore($package, $wrongCredentialRestore, 'V14-Other-Strong-Password-2026!', $pattern); + throw new RuntimeException('Wrong credentials were accepted.'); + } catch (PackageRestoreRejectedException) { + // Invalid credentials are expected input/package failures, not server errors. + } + if (file_exists($wrongCredentialRestore)) throw new RuntimeException('Wrong credentials left a restore destination behind.'); + + $storageFailureTyped = false; + try { + V14Stream::decryptFile($root . '/missing-stream.bin', $root . '/missing-output.bin', str_repeat('k', 32), '', 0); + } catch (PackageStorageException) { + $storageFailureTyped = true; + } + if (!$storageFailureTyped) throw new RuntimeException('Missing encrypted stream was not classified as a storage failure.'); + + $existingContentHash = hash_file('sha256', $restore . '/docs/deep/readme.txt'); + $existingDestinationRejected = false; try { (new V14PackageReader())->restore($package, $restore, $password, $pattern); - } catch (Throwable) { - $rejectedExistingDestination = true; + } catch (PackageStorageException) { + $existingDestinationRejected = true; } - if (!$rejectedExistingDestination) { - throw new RuntimeException('Existing destination was accepted.'); + if (!$existingDestinationRejected) { + throw new RuntimeException('Existing destination was accepted instead of rejected as a storage conflict.'); } $afterRejectedRestore = hash_file('sha256', $restore . '/docs/deep/readme.txt'); - if ($afterRejectedRestore === false || !hash_equals($expected, $afterRejectedRestore)) { + if ($existingContentHash === false || $afterRejectedRestore === false + || !hash_equals($existingContentHash, $afterRejectedRestore)) { throw new RuntimeException('Existing destination content changed after rejected restore.'); } diff --git a/tests/VersionMetadataConsistencyTest.php b/tests/VersionMetadataConsistencyTest.php new file mode 100644 index 00000000..1604546a --- /dev/null +++ b/tests/VersionMetadataConsistencyTest.php @@ -0,0 +1,24 @@ +