diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3d68e..6499d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - refactor: deduplicate internals, centralize exception messages in `Foxy\Exception\Message`, and normalize their wording. - feat: add the `native` asset manager that installs frontend dependencies from the npm registry in PHP, without a JavaScript manager. - feat!: audit the `native` manager, make every manager auditable, select the manager on first use, and speed up CVE lookups and installs. +- feat!: keep installed native packages between runs, report native installs by verbosity, and drop the `$defaultBin` command parameter. ## 0.3.0 October 7, 2026 diff --git a/UPGRADE.md b/UPGRADE.md index 277b913..c1c1579 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,123 +1,79 @@ # Upgrading Foxy -## Upgrading from 0.3.x to 0.4.x +This file lists, for each release, the changes that need an action from you or that change what you see when Foxy +runs. Features that need neither are described in the [documentation](docs/index.md) and the [changelog](CHANGELOG.md). -Foxy 0.4 makes every manager auditable, resolves the frontend manager on first use, moves the asset rollback into the -solver, and removes utilities that Composer `^2.10` made redundant. Projects that only configure Foxy through -`composer.json` need no change beyond the constraint; authors of custom managers, audit parsers, or CVE resolvers must -follow the sections below. +## Upgrading from 0.3.x to 0.4.x -### Composer constraint +### Update the constraint ```bash composer require php-forge/foxy:^0.4 --with-all-dependencies ``` -`symfony/console` `^8.0` is accepted beside `^7.4`; PHP 8.3 projects keep resolving `7.4`. - -### Every manager audits - -`Foxy\Audit\AuditableAssetManagerInterface` and `Foxy\Asset\AbstractAuditableAssetManager` were removed. -`AssetManagerInterface` now declares `audit(bool $noDev): AuditProcessResult` and -`getAuditParser(): AuditParserInterface`, and `AbstractAssetManager` carries the process-based implementation that the -former base class held (`getAuditCommand()`, `getAuditEnvironment()`, `validateAuditConfiguration()`). Custom managers -that extended `AbstractAuditableAssetManager` extend `AbstractAssetManager` instead and add `getAuditParser()`; direct -`AssetManagerInterface` implementations must add both methods, throwing a `RuntimeException` from `audit()` when they -cannot audit. `Foxy\Audit\AuditParserFactory` was removed: the runner asks the manager for its parser. -`Foxy\Audit\Parser\BunAuditParser` now extends the new `AbstractBulkAdvisoryParser`, shared with the native manager's -`NativeAuditParser`; the message case `Message::AUDIT_BUN_PACKAGE_ADVISORIES_REQUIRED` is now -`AUDIT_BULK_PACKAGE_ADVISORIES_REQUIRED`, and `AUDIT_PARSER_UNSUPPORTED_MANAGER` and `FOXY_AUDIT_UNSUPPORTED_MANAGER` -were removed. - -The `native` manager now supports `composer foxy:audit` through the registry's bulk advisories endpoint; `--no-dev` -is rejected for it, as for Deno, because the native manager installs development dependencies together with the -others. `Foxy\Asset\NativeManager` takes the `NpmRegistryInterface` as its last constructor argument, and -`NpmRegistryInterface` gained `getAdvisories(array $versions): string` and -`prefetchTarballs(array $packages, string $directory): void`; -custom registries must implement both. The registry now uses the HTTP downloader of Composer's loop, so the -parallel downloads need no configuration. - -### CVE lookups resolve in batches - -`Foxy\Audit\CveResolverInterface::resolve(string): CveResolution` was replaced by -`resolveMany(array $ghsaIds): array`, which returns a `CveResolution` or a `Throwable` per identifier. -`GitHubAdvisoryCveResolver` issues its requests concurrently through Composer's HTTP downloader and therefore needs the -async-enabled downloader (`Composer::getLoop()->getHttpDownloader()`), which `composer foxy:audit` already uses. -`CveEnricher` keeps its constructor and `enrich()` signature. - -### Manager selection timing - -Foxy no longer probes manager executables when Composer loads the plugin. The manager is resolved when the first -install, update, or `composer foxy:audit` needs it, so Composer commands that do none of these (`about`, `show`, -`validate`, ...) never run ` --version`. Selection errors (an unknown `manager`, several native lockfiles, a -lockfile whose executable is missing, or no manager at all) are reported by the first command that needs the manager -instead of by every Composer command; `composer foxy:audit` reports them with exit status `2`. - -### Manager-scoped configuration - -`Foxy\Config\Config::setResolvedManager()` was removed. Probing or validating a manager no longer changes the shared -`Config`; `Config::forManager(string $manager): static` returns a copy whose `manager-*` map entries resolve for that -manager, and `AbstractManifestAssetManager::getManagerConfig()` (protected) gives each manager its own view, built -on every call. -Custom managers that called `setResolvedManager()` remove the call and read `manager-*` keys through -`getManagerConfig()`. - -### Default commands in `AbstractAssetManager` - -`getInstallCommand()`, `getUpdateCommand()`, and `getVersionCommand()` are no longer abstract. They default to -` install`, ` update`, and ` --version`, where the binary comes from the new protected -`getDefaultBinary(): string` hook, which returns `getName()`. Custom managers whose executable differs from their name -override `getDefaultBinary()`; managers that implement the three methods keep working unchanged. - -### Asset rollback belongs to the solver - -`AssetManagerInterface::setFallback()` was removed, together with the fallback constructor argument, the protected -`$fallback` property, and `restoreAfterFailure()` of `AbstractAssetManager`; its constructor is now -`(IOInterface $io, Config $config, ProcessExecutor $executor, Filesystem $fs, VersionConverterInterface|null -$versionConverter = null)`, so a custom manager that passed a fallback must drop that argument. -`Foxy\Solver\Solver` takes the asset fallback as its last constructor argument and restores the manifest when the -dependency merge or the manager run throws or returns a non-zero code, before the `foxy.post-solve` event, as the -managers did; a listener of that event that throws no longer triggers an asset restore, only the Composer one. - -### Composer rollback events - -The rollback `Installer` is now built with a private event dispatcher that has no listeners and runs no scripts, so -command and autoload events of the rollback never reach the project's scripts or other plugins. Package events -(`pre-package-install`, `post-package-update`, ...) are still dispatched by Composer's shared installation manager -during a rollback, so root scripts and plugin listeners bound to those events run; projects that must not run them -during a rollback should guard those scripts. - -### Removed utilities - -`Foxy\Util\LockerUtil` was removed; the Composer fallback reads the lock through `Composer\Factory::getLockFile()`. -`Foxy\Util\ComposerUtil::getInitEventName()` was removed; use `Composer\Installer\InstallerEvents::PRE_OPERATIONS_EXEC`. -`Foxy\Util\PackageUtil::convertLockAlias()` and the `$convertAliases` parameter of `loadLockPackages()` were removed. -`Foxy\Json\JsonFile::getIndent()`, `Foxy\Json\JsonFormatter::getIndent()`, and the constant -`JsonFormatter::INDENT_REGEX` were removed. +Foxy 0.4 accepts `symfony/console` `^7.4 || ^8.0`. Console 8 requires PHP 8.4, so a project on PHP 8.3 keeps 7.4; +`--with-all-dependencies` lets Composer move the shared Symfony packages when the project allows it. -## Upgrading from 0.2.x to 0.3.x +### The manager is selected on first use + +Foxy no longer runs ` --version` each time Composer loads the plugin. The manager is selected when an +install, an update, or `composer foxy:audit` needs it: + +- Composer commands that need no manager (`about`, `show`, `validate`, ...) run without probing one and never fail + because a manager is missing or unsupported. +- An unknown `manager` value, several recognized lockfiles, a lockfile whose manager is not installed, or no manager at + all are reported by the first command that needs the manager; `composer foxy:audit` reports them with exit status + `2`. + +A CI job that relied on any Composer command failing early because of a missing manager must run `composer install` +or `composer foxy:audit` to keep that check. + +### Native manager -Foxy 0.3 raises the runtime baseline and aligns its documented integration target with the PHP 8.3 development line. +- `composer foxy:audit` audits the `native` manager through the registry's bulk advisories endpoint + (`POST /-/npm/v1/security/advisories/bulk`). `--no-dev` is rejected, as for Deno, because the native manager + installs the development dependencies together with the others. A private registry must implement that endpoint + (Verdaccio: enable its `audit` middleware); otherwise the audit fails with status `2`. +- The install directory gains a hidden `.foxy-installed.json` file that records what Foxy installed. The first install + after the upgrade extracts every registry package once and writes it; later installs keep the registry packages + whose directory and recorded entry still match `foxy.lock`, copy the `file:` packages again, and report only what + changed, printing `Nothing to install, update or remove` when nothing did. Run Composer with `-v` to list the kept + and refreshed packages and `-vv` to follow the registry traffic. Delete the file to extract every registry package + again. + +### Rollbacks + +- The `package.json` restore that follows a failed dependency merge or manager run now happens before the + `foxy.post-solve` event. A `post-solve` listener that throws no longer triggers it; only the Composer rollback runs. +- The Composer rollback runs on a private event dispatcher: command and autoload events never reach the project's + scripts or other plugins. Package events (`pre-package-install`, `post-package-update`, ...) are still dispatched by + Composer's installation manager and do reach them, where 0.3 skipped them. A root script bound to a package event + that must not run during a rollback has to guard itself. + +### PHP API + +The classes that custom managers, audit parsers, and CVE resolvers extend changed in this release. A project that +configures Foxy through `composer.json` is not affected. One change binds silently: +`AbstractAssetManager::buildCommand()` and `buildUnconfiguredCommand()` lost their first parameter, the default binary, +so a subclass that still passes it has the binary bound as the action. Drop that argument and return a custom binary +from `getDefaultBinary()`. + +## Upgrading from 0.2.x to 0.3.x ### Runtime requirements -Before updating, ensure the environment provides: +Before updating, make sure the environment provides: -- PHP 8.3 or later. +- PHP 8.3 or later. PHP 8.1 and 8.2 are not supported. - Composer 2.10.2 or later. -- One supported frontend manager for automatic manager execution or explicit security audits: Bun `^1.4.0`, Deno `^2.9.7`, npm `>=10.9.8`, pnpm `^11.23.0`, or Yarn `^4.18.0`. -- For npm, use a Node.js version supported by the selected npm release. -- For pnpm, Node.js `>=22.13.0`. -- For Yarn, Node.js `>=18.12.0` on a release that still receives security updates. +- One supported frontend manager when Foxy runs it or audits with it: Bun `^1.4.0`, Deno `^2.9.7`, npm `>=10.9.8`, + pnpm `^11.23.0`, or Yarn `^4.18.0`. npm needs a Node.js version its release supports, pnpm needs Node.js + `>=22.13.0`, and Yarn needs Node.js `>=18.12.0` on a release that still receives security updates. -PHP 8.1 and 8.2 are not supported by Foxy 0.3. -The Ctype and Mbstring extensions are no longer package requirements. -Earlier frontend manager versions are not supported. The `manager-version` setting can narrow the built-in constraint -for a project, but it cannot widen the supported range or opt an earlier manager version back in. +Earlier manager versions are rejected. The `manager-version` setting can narrow the built-in constraint for a +project but cannot widen it. The Ctype and Mbstring extensions are no longer required. -### Composer constraint and plugin authorization - -Update the package constraint and authorize the plugin: +### Update the constraint and authorize the plugin ```bash composer config allow-plugins.php-forge/foxy true @@ -139,13 +95,13 @@ The equivalent `composer.json` configuration is: } ``` -Library authors that keep Foxy in `require-dev` should update that constraint to `^0.3` as well. +A library that keeps Foxy in `require-dev` updates that constraint to `^0.3` as well. -### Frontend manager selection +### Choose the manager -When manager execution is enabled, Foxy can select a manager automatically from one recognized native lockfile or an -available executable. Multiple recognized lockfiles require explicit selection. For predictable upgrades and CI runs, -configure the manager and commit its native lockfile: +Foxy selects the manager from a single recognized lockfile or from an available executable. Several recognized +lockfiles require an explicit choice. For predictable upgrades and CI runs, configure the manager and commit its +lockfile: ```json { @@ -157,135 +113,61 @@ configure the manager and commit its native lockfile: } ``` -Remove stale lockfiles from other managers before the first Composer operation with Foxy 0.3. - -#### npm migration - -Ensure npm is at least 10.9.8 and regenerate the installation state: - -```bash -npm --version -npm install -``` - -The reported npm version must satisfy `>=10.9.8`. npm 10.9.8 requires Node.js `^18.17.0 || >=20.5.0`; later npm -releases may require a newer Node.js version. Review the [npm documentation](https://docs.npmjs.com/cli/) for the -selected release and commit any `package-lock.json` changes. - -#### pnpm 11 migration - -pnpm 11 requires Node.js 22 or later. Upgrade Node.js first, update to the latest pnpm 11 release, apply the official -configuration codemod, and reinstall: - -```bash -pnpm self-update 11 -pnpm --version -pnpx codemod run pnpm-v10-to-v11 -pnpm install -``` - -The reported pnpm version must satisfy `^11.23.0`. Review the codemod result and manual follow-ups in the -[pnpm v10 to v11 migration guide](https://pnpm.io/migration), then commit `pnpm-lock.yaml` and configuration changes. - -#### Bun lockfile migration - -Upgrade Bun and confirm that the result satisfies `^1.4.0`. Foxy recognizes only the text-based `bun.lock` file, so -convert a legacy `bun.lockb` without changing the resolved dependency versions: - -```bash -bun upgrade -bun --version -bun install --save-text-lockfile --frozen-lockfile --lockfile-only -``` - -Verify and commit `bun.lock`, then delete `bun.lockb`. Bun projects that previously depended on `yarn.lock` for -automatic selection must generate `bun.lock` before running Composer or configure `config.foxy.manager` explicitly as -`bun`. - -#### Yarn 4 migration - -Yarn Classic 1.x, Yarn 2.x, and Yarn 3.x are not supported. Install and enable Corepack, select the latest Yarn 4 -release, reinstall, and commit the resulting `packageManager`, `yarn.lock`, and Yarn configuration changes: - -```bash -npm install --global corepack -corepack enable -yarn set version 4.x -yarn --version -yarn install -``` - -The reported Yarn version must satisfy `^4.18.0`. The -[current Yarn installation guide](https://yarnpkg.com/getting-started/install) documents the Corepack setup. -Projects migrating from Yarn Classic must also convert `.npmrc` or `.yarnrc` settings to `.yarnrc.yml` and review -renamed or removed commands. Projects upgrading from Yarn 2 or 3 should review the -[Yarn 4 release notes](https://yarnpkg.com/blog/release/4.0) before committing the regenerated installation artifacts. -The [Yarn migration guide](https://yarnpkg.com/migration/guide) documents the configuration and command changes. - -### Custom manager implementations - -Custom `AssetManagerInterface` implementations must add `getVersionConstraint(): string` and return their hard -supported version range as a Composer constraint. Remove implementations and calls of the obsolete -`isValidForUpdate()` method. Custom managers that support `composer foxy:audit` must extend -`AbstractAuditableAssetManager` and implement `getAuditCommand(bool $noDev): string`. Foxy currently normalizes only -the report schemas and manager names of its five built-in managers: Bun, Deno, npm, pnpm, and Yarn. Arbitrary custom -managers are not supported by `composer foxy:audit`. `AbstractAssetManager` subclasses and direct -`AssetManagerInterface` implementations that do not implement Foxy's auditable manager contract remain usable for -asset solving, but the audit command rejects them because they do not support security audits. - -`AbstractAssetManager` subclasses inherit the simplified update eligibility based on installation state and the -`setUpdatable()` flag, concrete-version enforcement before every manager command, and version detection in the -configured `root-package-json-dir`; they must also provide `getVersionConstraint()`. Direct `AssetManagerInterface` -implementations must enforce the returned constraint in their own `validate()` and `run()` implementations. +Remove stale lockfiles of other managers before the first Composer operation with Foxy 0.3, then bring the manager +to a supported version: + +- **npm**: `npm --version` must satisfy `>=10.9.8`; run `npm install` and commit the `package-lock.json` changes. npm + 10.9.8 requires Node.js `^18.17.0 || >=20.5.0`; see the [npm documentation](https://docs.npmjs.com/cli/) for the + release in use. +- **pnpm**: pnpm 11 requires Node.js 22. Upgrade Node.js, then run `pnpm self-update 11`, + `pnpx codemod run pnpm-v10-to-v11`, and `pnpm install`; `pnpm --version` must satisfy `^11.23.0`. Review the codemod + result with the [pnpm migration guide](https://pnpm.io/migration) and commit `pnpm-lock.yaml` and the configuration + changes. +- **Bun**: run `bun upgrade`; `bun --version` must satisfy `^1.4.0`. Foxy recognizes only the text lockfile, so convert + a legacy `bun.lockb` without changing the resolved versions with + `bun install --save-text-lockfile --frozen-lockfile --lockfile-only`, commit `bun.lock`, and delete `bun.lockb`. A + project that relied on `yarn.lock` for Bun's automatic selection generates `bun.lock` or sets `manager` to `bun`. +- **Yarn**: Yarn 1, 2, and 3 are not supported. Install Corepack (`npm install --global corepack`, `corepack enable`), + run `yarn set version 4.x` and `yarn install`; `yarn --version` must satisfy `^4.18.0`. Commit the `packageManager` + field, `yarn.lock`, and the Yarn configuration. The [installation guide](https://yarnpkg.com/getting-started/install), + the [migration guide](https://yarnpkg.com/migration/guide), and the + [Yarn 4 release notes](https://yarnpkg.com/blog/release/4.0) document the configuration and command changes, + including the move from `.npmrc` or `.yarnrc` to `.yarnrc.yml`. ### Embedded package metadata and paths -Foxy 0.3 copies only package metadata used for runtime dependency resolution and compatibility checks into its local -frontend package representations. It no longer propagates lifecycle `scripts`, `devDependencies`, executable -definitions, entry points, or unrelated publishing metadata from an installed Composer dependency. - -Library authors should move runtime requirements into `dependencies`, `optionalDependencies`, or `peerDependencies`. -Publish prebuilt assets with the Composer package, or require consuming applications to configure intentional build -steps in their own `package.json`. - -An embedded manifest selected through `root-package-json-dir` must resolve inside its Composer package installation -directory. A value that traverses or resolves outside that boundary now raises a `RuntimeException`. Foxy also -regenerates local `file:` references relative to the consuming `package.json`; review custom-root projects after the -first update and commit the resulting manifest changes. - -Foxy recursively resets `composer-asset-dir`, so 0.3 validates ownership before deletion. It rejects filesystem, -project, and vendor roots; directories that contain either project or vendor root; and symbolic-link asset directories. -An existing, non-empty custom directory must contain Foxy's `.foxy-managed` marker. Before upgrading a custom path, -verify that it contains only generated Foxy data, remove its contents, and let the first Foxy 0.3 run recreate and mark -the directory. New and empty custom directories are marked automatically. - -### Paths and fallbacks - -Review these settings when the project does not keep `package.json` at its Composer root: - -- `root-package-json-dir` controls the project package path and manager working directory. -- A library can use its own `root-package-json-dir` to locate an embedded package definition. -- Mock packages use `/php-forge/composer-asset/` unless `composer-asset-dir` overrides it. - -Asset and Composer fallbacks remain enabled by default. Projects that disabled either fallback should confirm that -retaining partial state after a manager error is still intended. - -Asset rollback runs when package merging or manager execution fails. Composer-state rollback now runs after every solve -exception and every non-zero manager result, including negative results. If Composer restoration succeeds, Foxy -rethrows the original solve error. If restoration also fails, Foxy reports the rollback error with the original failure -as its previous exception. - -Composer rollback no longer runs project scripts. A non-zero result from the rollback installer is now reported as a -`RuntimeException` instead of being treated as a successful restoration. Applications that catch manager failures -should allow for this explicit rollback failure. - -Composer lock and vendor snapshots are captured at Composer's `pre-operations-exec` event. Foxy does not snapshot the -pre-command contents of the root `composer.json`, which Composer may already have changed during `composer require` or -`composer remove`. The fallback is therefore not a fully atomic Composer transaction for those commands. Inspect and, -when necessary, revert the root manifest after a failed operation. - -Setting `enabled=false` now bypasses manager discovery, fallback snapshots, package merging, and manager execution. -Setting `run-asset-manager=false` retains package merging but skips manager binary probing, version validation, -execution, and npm cleanup of existing `node_modules/@composer-asset/*` installations. The new explicit -`composer foxy:audit` command still validates and invokes the selected manager in this mode; it never installs, updates, -or repairs dependencies. +Foxy copies into its local package representation only the metadata it needs to resolve dependencies and check +compatibility. Lifecycle `scripts`, `devDependencies`, executable definitions, entry points, and publishing metadata of +an installed Composer dependency are no longer propagated. A library moves its runtime requirements to +`dependencies`, `optionalDependencies`, or `peerDependencies`, ships prebuilt assets with the Composer package, or +asks the consuming application to declare its own build steps in `package.json`. + +A `root-package-json-dir` of a library must resolve inside that library's installation directory; a value that leaves +it is rejected with a `RuntimeException`. Foxy regenerates the local `file:` references relative to the consuming +`package.json`: review a project with a custom root after the first update and commit the resulting manifest changes. + +Foxy resets `composer-asset-dir` recursively and therefore checks that it owns the directory. Filesystem, project, +and vendor roots, directories containing either, and symbolic links are rejected. An existing, non-empty custom +directory must contain Foxy's `.foxy-managed` marker: before upgrading, verify that the directory holds only generated +Foxy data, empty it, and let the first Foxy 0.3 run recreate and mark it. A new or empty directory is marked +automatically. + +### Fallbacks and disabled modes + +Both fallbacks stay enabled by default; a project that disabled one should confirm that keeping partial state after a +manager error is still intended. + +The asset rollback runs when package merging or the manager fails. The Composer rollback runs after every solve +exception and every non-zero manager result. When the Composer restore succeeds, Foxy rethrows the original error; +when it fails too, Foxy reports the rollback error with the original failure as its previous exception. The rollback +does not run project scripts, and a non-zero result of the rollback installer is reported instead of being treated as a +success. + +The rollback snapshot of `composer.lock` and the vendor directory is taken at Composer's `pre-operations-exec` event. +The root `composer.json` is not part of it, and `composer require` or `composer remove` may already have changed it: +inspect and, when necessary, revert the root manifest after a failed operation. + +`enabled: false` bypasses manager discovery, fallback snapshots, package merging, and manager execution. +`run-asset-manager: false` keeps package merging but skips manager probing, version validation, execution, and the +npm cleanup of `node_modules/@composer-asset/*`. `composer foxy:audit` still validates and invokes the selected +manager in that mode; it never installs, updates, or repairs dependencies. diff --git a/docs/config.md b/docs/config.md index b00659c..30f86ce 100644 --- a/docs/config.md +++ b/docs/config.md @@ -286,8 +286,16 @@ Foxy then performs the installation itself, in PHP: `cache-read-only` settings; every cached tarball is verified again before use. - It writes `foxy.lock`, which records the root requirements and the requirements of every local package (regular and optional ones apart), and the selected version, tarball URL, and integrity of every installed package. `composer install` with a lock that - matches the current manifests reinstalls exactly those tarballs without a single metadata request; + matches the current manifests installs exactly those tarballs without a single metadata request; `composer update`, or a lock that no longer matches, resolves again and rewrites the lock. Commit `foxy.lock`. +- It records what it installed in `.foxy-installed.json` inside the install directory, the counterpart of npm's + `node_modules/.package-lock.json`: the `foxy.lock` entry of every package left in place. A registry package whose + directory exists and whose entry matches the lock is kept untouched, so a warm install extracts nothing; a package + whose entry differs or whose directory is missing is extracted again. `file:` packages are always copied again, + since their content can change without a version change. The file is removed at the start of each installation and + written at its end, so an interrupted installation leaves none behind. A missing or malformed file only forces every + registry package to be extracted again, as happens once on the first install that creates it; deleting it is always + safe. Like every dot-entry, the pruning leaves it alone. Do not commit it. - It removes entries of the install directory that are not part of the installation (dot-entries such as `.bin` are left alone). @@ -317,14 +325,19 @@ followed by a name of letters, digits, `.`, `_`, and `-`, up to 214 characters); path is built from it. Scoped packages keep the npm layout (`vendor/npm-asset/@popperjs/core`), not asset-packagist's `popperjs--core` form. -The native manager requires the PHP `zlib` extension and prints `Installing`, `Updating`, and `Removing` lines like -the other managers. Its registry requests go through Composer's HTTP layer: proxies, `cafile`, `disable-tls`, and the -credentials stored in `auth.json` for the registry host all apply, so a private registry can be configured with -`registry-url` plus Composer authentication. Credentials are matched against the request's `host:port` exactly as -written in `registry-url`: `localhost` and `127.0.0.1` are not aliases of each other, and the `auth.json` key must -include the port when it is not the scheme's default (`npm.example.com:4873`). A plain `http://` registry also needs -Composer's `config.secure-http` set to `false`. A registry that answers 401 or 403 fails the request with Composer's -error text, as any other registry failure does: +The native manager requires the PHP `zlib` extension. Its output follows Composer's verbosity levels: at normal +verbosity it prints only changes (`Installing`, `Updating`, and `Removing` lines), or +`Nothing to install, update or remove`; `-v` adds a `Keeping` line per kept package and a `Refreshing` line per +unchanged `file:` package copied again; `-vv` adds the registry traffic (metadata requests, tarball downloads, +prefetches, and cache hits); `-vvv` adds Composer's own HTTP log. + +Its registry requests go through Composer's HTTP layer: proxies, `cafile`, `disable-tls`, and the credentials stored +in `auth.json` for the registry host all apply, so a private registry can be configured with `registry-url` plus +Composer authentication. Credentials are matched against the request's `host:port` exactly as written in +`registry-url`: `localhost` and `127.0.0.1` are not aliases of each other, and the `auth.json` key must include the +port when it is not the scheme's default (`npm.example.com:4873`). A plain `http://` registry also needs Composer's +`config.secure-http` set to `false`. A registry that answers 401 or 403 fails the request with Composer's error text, +as any other registry failure does: ```json { diff --git a/src/Asset/AbstractAssetManager.php b/src/Asset/AbstractAssetManager.php index 86394e7..1aa3998 100644 --- a/src/Asset/AbstractAssetManager.php +++ b/src/Asset/AbstractAssetManager.php @@ -147,30 +147,30 @@ public function validate(): void } /** - * Build the command with binary and command options. + * Returns the manager command run by the configured or default binary, followed by the `manager-options` and + * `manager-{action}-options` values. * - * @param string $defaultBin The default binary of command if option isn't defined. - * @param string $action The command action to retrieve the options in config. - * @param array|string $command The command. + * @param string $action The action whose `manager-{action}-options` value is appended. + * @param array|string $command The command arguments, joined with spaces. */ - protected function buildCommand(string $defaultBin, string $action, array|string $command): string + protected function buildCommand(string $action, array|string $command): string { $gOptions = trim((string) $this->getManagerConfig()->get('manager-options', '')); $options = trim((string) $this->getManagerConfig()->get("manager-{$action}-options", '')); - return $this->buildUnconfiguredCommand($defaultBin, $command) + return $this->buildUnconfiguredCommand($command) . ($gOptions === '' ? '' : " {$gOptions}") . ($options === '' ? '' : " {$options}"); } /** - * Build a manager command without inheriting install and update options. + * Returns the manager command run by the configured or default binary, without the install and update options. * - * @param array|string $command The command. + * @param array|string $command The command arguments, joined with spaces. */ - protected function buildUnconfiguredCommand(string $defaultBin, array|string $command): string + protected function buildUnconfiguredCommand(array|string $command): string { - return sprintf('%s %s', $this->getManagerBinary($defaultBin), implode(' ', (array) $command)); + return sprintf('%s %s', $this->getManagerBinary(), implode(' ', (array) $command)); } /** @@ -197,7 +197,7 @@ protected function getDefaultBinary(): string */ protected function getInstallCommand(): string { - return $this->buildCommand($this->getDefaultBinary(), 'install', 'install'); + return $this->buildCommand('install', 'install'); } /** @@ -206,7 +206,7 @@ protected function getInstallCommand(): string */ protected function getUpdateCommand(): string { - return $this->buildCommand($this->getDefaultBinary(), 'update', 'update'); + return $this->buildCommand('update', 'update'); } protected function getVersion(): string|null @@ -233,7 +233,7 @@ protected function getVersion(): string|null */ protected function getVersionCommand(): string { - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), '--version'); + return $this->buildUnconfiguredCommand('--version'); } /** @@ -293,10 +293,10 @@ private function executeManagerCommand(string $command, string|null $workingDire return $this->executor->execute($command, $outputHandler, $workingDirectory); } - private function getManagerBinary(string $defaultBin): string + private function getManagerBinary(): string { /** @var string $bin */ - $bin = $this->getManagerConfig()->get('manager-bin', $defaultBin); + $bin = $this->getManagerConfig()->get('manager-bin', $this->getDefaultBinary()); return Platform::isWindows() ? str_replace('/', '\\', $bin) : $bin; } diff --git a/src/Asset/BunManager.php b/src/Asset/BunManager.php index 8673872..820b26d 100644 --- a/src/Asset/BunManager.php +++ b/src/Asset/BunManager.php @@ -73,7 +73,7 @@ protected function getAuditCommand(bool $noDev): string $command[] = '--prod'; } - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command); + return $this->buildUnconfiguredCommand($command); } /** diff --git a/src/Asset/DenoManager.php b/src/Asset/DenoManager.php index 0bbca30..40474ff 100644 --- a/src/Asset/DenoManager.php +++ b/src/Asset/DenoManager.php @@ -98,7 +98,7 @@ protected function actionWhenComposerDependenciesAreMerged( protected function getAuditCommand(bool $noDev): string { - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), ['audit', '--level=low']); + return $this->buildUnconfiguredCommand(['audit', '--level=low']); } protected function getAuditEnvironment(): array @@ -116,7 +116,7 @@ protected function getDefaultBinary(): string protected function getUpdateCommand(): string { - return $this->buildCommand($this->getDefaultBinary(), 'update', ['update', '--lockfile-only', '--recursive']) + return $this->buildCommand('update', ['update', '--lockfile-only', '--recursive']) . ' && ' . $this->getInstallCommand(); } diff --git a/src/Asset/NpmManager.php b/src/Asset/NpmManager.php index 37f3511..0716244 100644 --- a/src/Asset/NpmManager.php +++ b/src/Asset/NpmManager.php @@ -71,7 +71,7 @@ protected function getAuditCommand(bool $noDev): string $command = [...$command, $noDev ? '--omit=dev' : '--include=dev', '--include=optional', '--include=peer']; - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command); + return $this->buildUnconfiguredCommand($command); } /** diff --git a/src/Asset/PnpmManager.php b/src/Asset/PnpmManager.php index e5f6b6d..935a407 100644 --- a/src/Asset/PnpmManager.php +++ b/src/Asset/PnpmManager.php @@ -51,6 +51,6 @@ protected function getAuditCommand(bool $noDev): string ProcessExecutor::escape('--config.auditConfig={ignoreGhsas:[]}'), ]; - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command); + return $this->buildUnconfiguredCommand($command); } } diff --git a/src/Asset/YarnManager.php b/src/Asset/YarnManager.php index 22ffa26..63634ea 100644 --- a/src/Asset/YarnManager.php +++ b/src/Asset/YarnManager.php @@ -52,7 +52,7 @@ protected function getAuditCommand(bool $noDev): string $command = [...$command, '--environment', 'production']; } - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command); + return $this->buildUnconfiguredCommand($command); } protected function getAuditEnvironment(): array @@ -62,6 +62,6 @@ protected function getAuditEnvironment(): array protected function getUpdateCommand(): string { - return $this->buildCommand($this->getDefaultBinary(), 'update', 'up'); + return $this->buildCommand('update', 'up'); } } diff --git a/src/Native/InstalledState.php b/src/Native/InstalledState.php new file mode 100644 index 0000000..5867b91 --- /dev/null +++ b/src/Native/InstalledState.php @@ -0,0 +1,134 @@ +The installed state file "%s" is malformed and was ignored.'; + + /** + * @param string $path State file path. + * @param IOInterface $io Output for the verbose notice of a malformed file. + */ + public function __construct(private string $path, private IOInterface $io) {} + + /** + * Returns the recorded lock entries keyed by package name, in the shape of {@see ResolvedPackage::toLock()}. + * + * A missing file reads as empty. A file that is not valid JSON, has no `packages` map, or holds an entry of + * another shape also reads as empty, with a verbose warning. + * + * Keys are package names; PHP stores a numeric name such as `123` as an `int` key. + * + * @return array> + */ + public function read(): array + { + if (!is_file($this->path)) { + return []; + } + + $entries = $this->decode()['packages'] ?? null; + + if (!is_array($entries)) { + return $this->malformed(); + } + + $state = []; + + foreach ($entries as $name => $entry) { + if (!self::isEntry($entry)) { + return $this->malformed(); + } + + $state[$name] = $entry; + } + + return $state; + } + + /** + * Writes the state file as `{"packages": {...}}`, with the names sorted byte-wise. + * + * @param array> $entries Lock entries keyed by package name. + */ + public function write(array $entries): void + { + ksort($entries, SORT_STRING); + + (new JsonFile($this->path))->write(['packages' => (object) $entries]); + } + + /** + * Returns the decoded state file, or `null` when it is not valid JSON. + */ + private function decode(): mixed + { + try { + return (new ComposerJsonFile($this->path))->read(); + } catch (ParsingException) { + return null; + } + } + + /** + * Returns whether a value is a lock entry: string `version`, `resolved`, and `integrity`, or `version` and `file`. + * + * @phpstan-assert-if-true array $entry + */ + private static function isEntry(mixed $entry): bool + { + if (!is_array($entry)) { + return false; + } + + $keys = array_keys($entry); + + if ($keys !== ['version', 'resolved', 'integrity'] && $keys !== ['version', 'file']) { + return false; + } + + foreach ($entry as $value) { + if (!is_string($value)) { + return false; + } + } + + return true; + } + + /** + * Writes the verbose warning of a malformed file and returns the empty state. + * + * @return array{} + */ + private function malformed(): array + { + $this->io->writeError(sprintf(self::MALFORMED, $this->path), true, IOInterface::VERBOSE); + + return []; + } +} diff --git a/src/Native/NativeInstaller.php b/src/Native/NativeInstaller.php index 6032910..7f1aedb 100644 --- a/src/Native/NativeInstaller.php +++ b/src/Native/NativeInstaller.php @@ -37,7 +37,7 @@ * Registry packages are resolved (or read from a fresh `foxy.lock`), downloaded, and extracted into * `/.foxy-tmp` before being moved into place; `file:` packages are copied from their local * directory. Top-level entries of the install directory outside the install set are removed, except dot-entries such - * as `.bin`. + * as `.bin`. A registry package already in place with its recorded lock entry is kept; see {@see InstalledState}. * * @phpstan-import-type Fingerprint from LockFile */ @@ -50,10 +50,14 @@ private const string EXTRACTION_SUFFIX = '/package'; private const string FILE_PROTOCOL = 'file:'; private const string OPTIONAL_SKIPPED = 'The optional dependency "%s" (%s) was skipped: %s'; + /** + * Name of the installed state file inside the install directory; see {@see InstalledState}. + */ + private const string STATE_FILE = '.foxy-installed.json'; private const string TEMPORARY_DIRECTORY = '.foxy-tmp'; /** - * @param IOInterface $io Output that receives one line per installed or removed package. + * @param IOInterface $io Output that receives one line per changed package, and per kept package at verbose level. * @param Filesystem $fs Filesystem used to copy, move, and remove package directories. * @param NpmRegistryInterface $registry Registry that provides the tarballs. * @param DependencyResolver $resolver Resolver of the registry requirements. @@ -215,32 +219,72 @@ private static function entries(string $directory): array } /** - * Installs the packages into the install directory and removes the entries outside the install set. + * Installs the packages into the install directory, removes the entries outside the install set, and records the + * install set in the installed state file. + * + * A registry package whose directory exists and whose lock entry equals the recorded one is kept as is; every other + * registry package is extracted again, and every `file:` package is copied again since its content may change + * without a version change. The state file is removed before any change and written after the pruning, so an + * interrupted installation leaves no state and the next one extracts every registry package. * - * The tarballs of the registry packages are first prefetched in parallel into the registry's cache; each package is - * then fetched, verified, and extracted in turn, so a failed prefetch surfaces as the error of its own fetch. + * The tarballs of the registry packages to extract are first prefetched in parallel into the registry's cache; each + * package is then fetched, verified, and extracted in turn, so a failed prefetch surfaces as the error of its own + * fetch. * * A symbolic link or a file standing in for the `@scope` directory of a package is removed first, so the package is * never written into the link target and a stale file cannot abort the installation. * + * Changes are written at normal verbosity, or `Nothing to install, update or remove` when there is none; kept + * packages and refreshed local copies are written at verbose level. + * * @param array $packages Packages in installation order. * * @throws RuntimeException if a download, an extraction, or a local copy fails, or a directory cannot be removed. */ private function installPackages(string $manifestPath, string $installDirectory, array $packages): void { - $temporary = $installDirectory . '/' . self::TEMPORARY_DIRECTORY; + $temporary = "{$installDirectory}/" . self::TEMPORARY_DIRECTORY; + $statePath = "{$installDirectory}/" . self::STATE_FILE; + + $stateFile = new InstalledState($statePath, $this->io); + + $state = $stateFile->read(); + $this->remove($statePath); $this->remove($temporary); $this->fs->ensureDirectoryExists($temporary); + $changed = false; + $entries = []; $installed = []; $downloads = []; + $kept = []; foreach ($packages as $package) { - if (!$package->isLocal()) { - $downloads[] = $package; + $target = "{$installDirectory}/{$package->name}"; + $scope = dirname($target); + $entry = $package->toLock(); + $installed[] = $package->name; + $entries[$package->name] = $entry; + + if ($scope !== $installDirectory && (is_link($scope) || (file_exists($scope) && !is_dir($scope)))) { + $this->io->write(' - Removing ' . basename($scope)); + $this->remove($scope); + + $changed = true; + } + + if ($package->isLocal()) { + continue; + } + + if (($state[$package->name] ?? null) === $entry && is_dir($target) && !is_link($target)) { + $kept[$package->name] = true; + + continue; } + + $downloads[] = $package; } if ([] !== $downloads) { @@ -249,20 +293,33 @@ private function installPackages(string $manifestPath, string $installDirectory, foreach ($packages as $package) { $target = "{$installDirectory}/{$package->name}"; - $scope = dirname($target); - $installed[] = $package->name; + $recorded = $state[$package->name] ?? null; + $previous = $recorded['version'] ?? $package->version; + $label = $previous === $package->version + ? " - Installing {$package->name} ({$package->version})" + : " - Updating {$package->name} ({$previous} => {$package->version})"; - if ($scope !== $installDirectory && (is_link($scope) || (file_exists($scope) && !is_dir($scope)))) { - $this->io->write(' - Removing ' . basename($scope)); - $this->remove($scope); + if (isset($kept[$package->name])) { + $this->io->write(" - Keeping {$package->name} ({$package->version})", true, IOInterface::VERBOSE); + + continue; } $this->remove($target); if (null !== $package->path) { - $this->io->write( - " - Installing {$package->name} ({$package->version}): Copying from {$package->path}", - ); + if ($recorded === $entries[$package->name]) { + $this->io->write( + " - Refreshing {$package->name} ({$package->version}): Copying from {$package->path}", + true, + IOInterface::VERBOSE, + ); + } else { + $this->io->write("{$label}: Copying from {$package->path}"); + + $changed = true; + } + $source = $this->localDirectory($manifestPath, $package->path); if (!$this->fs->copy($source, $target)) { @@ -274,8 +331,9 @@ private function installPackages(string $manifestPath, string $installDirectory, continue; } - $this->io->write(" - Installing {$package->name} ({$package->version}): Extracting archive"); + $this->io->write("{$label}: Extracting archive"); + $changed = true; $extracted = $temporary . self::EXTRACTION_SUFFIX; $this->extractor->extract( @@ -287,8 +345,14 @@ private function installPackages(string $manifestPath, string $installDirectory, $this->fs->rename($extracted, $target); } - $this->prune($installDirectory, $installed); + $changed = $this->prune($installDirectory, $installed) || $changed; + $this->remove($temporary); + $stateFile->write($entries); + + if (!$changed) { + $this->io->write('Nothing to install, update or remove'); + } } private function invalidSpecs(string $field, string $manifestPath): RuntimeException @@ -339,9 +403,13 @@ private function lockedPackages(LockFile $lock, array $fingerprint): array|null * @param list $installed Installed package names. * * @throws RuntimeException if an entry cannot be removed. + * + * @return bool Whether a package entry was removed. */ - private function prune(string $installDirectory, array $installed): void + private function prune(string $installDirectory, array $installed): bool { + $removed = false; + foreach (self::entries($installDirectory) as $entry) { if (str_starts_with($entry, '.')) { continue; @@ -350,19 +418,21 @@ private function prune(string $installDirectory, array $installed): void $path = "{$installDirectory}/{$entry}"; if (!str_starts_with($entry, '@') || is_link($path) || !is_dir($path)) { - $this->pruneEntry($installDirectory, $entry, $installed); + $removed = $this->pruneEntry($installDirectory, $entry, $installed) || $removed; continue; } foreach (self::entries($path) as $child) { - $this->pruneEntry($installDirectory, "{$entry}/{$child}", $installed); + $removed = $this->pruneEntry($installDirectory, "{$entry}/{$child}", $installed) || $removed; } if ([] === self::entries($path)) { $this->remove($path); } } + + return $removed; } /** @@ -371,15 +441,19 @@ private function prune(string $installDirectory, array $installed): void * @param list $installed Installed package names. * * @throws RuntimeException if the entry cannot be removed. + * + * @return bool Whether the entry was removed. */ - private function pruneEntry(string $installDirectory, string $name, array $installed): void + private function pruneEntry(string $installDirectory, string $name, array $installed): bool { if (in_array($name, $installed, true)) { - return; + return false; } $this->io->write(" - Removing {$name}"); $this->remove("{$installDirectory}/{$name}"); + + return true; } /** diff --git a/src/Native/NpmRegistry.php b/src/Native/NpmRegistry.php index 86a5406..23e76b3 100644 --- a/src/Native/NpmRegistry.php +++ b/src/Native/NpmRegistry.php @@ -9,7 +9,7 @@ use Composer\Downloader\TransportException; use Composer\IO\IOInterface; use Composer\Util\Http\Response; -use Composer\Util\HttpDownloader; +use Composer\Util\{HttpDownloader, Url}; use Foxy\Exception\{Message, RuntimeException}; use Throwable; @@ -39,7 +39,12 @@ * * Requests answered with HTTP 429 are retried up to three times, waiting the `Retry-After` seconds (capped at 60) * or 1, 2, and 4 seconds. Tarballs are cached and verified against their Subresource Integrity value on every cache - * hit and every download; a prefetch fills the cache with parallel downloads and no retry. + * hit and every download; a prefetch fills the cache with parallel downloads and no retry. Every metadata request, + * tarball download, prefetch, and cache hit is written at very verbose level (`-vv`). + * + * URLs written to the output or put in an exception message are masked with {@see Url::sanitize()}: an + * `access_token` query value and a userinfo password become `***`, and a long or token-like user name keeps its first + * three characters. */ final readonly class NpmRegistry implements NpmRegistryInterface { @@ -59,7 +64,7 @@ * @param HttpDownloader $httpDownloader Downloader that performs the requests; {@see prefetchTarballs()} needs the * async-enabled instance returned by `Composer::getLoop()->getHttpDownloader()`. * @param Cache $cache Tarball cache. - * @param IOInterface $io Output for the retry and failed prefetch notices. + * @param IOInterface $io Output for the retry and failed prefetch notices and the very verbose registry traffic. * @param string $url Registry base URL; a trailing slash is ignored. * @param (Closure(int): void)|null $delay Replacement for `sleep()` during the 429 back-off, or `null` to sleep. */ @@ -91,19 +96,22 @@ public function fetchTarball(ResolvedPackage $package, string $directory): strin if ($this->cache->copyTo($key, $file)) { if ($this->matchesIntegrity($file, $integrity)) { + $this->trace("Loading {$package->name} ({$package->version}) from cache"); + return $file; } $this->cache->remove($key); } + $this->trace("Downloading {$package->name} ({$package->version}) from " . Url::sanitize($resolved)); $this->send($resolved, fn(): Response => $this->httpDownloader->copy($resolved, $file)); if (!$this->matchesIntegrity($file, $integrity)) { unlink($file); throw new RuntimeException( - Message::NATIVE_INTEGRITY_MISMATCH->getMessage($resolved), + Message::NATIVE_INTEGRITY_MISMATCH->getMessage(Url::sanitize($resolved)), ); } @@ -154,10 +162,12 @@ public function getMetadata(string $name): PackageMetadata { $url = $this->registryUrl() . '/' . str_replace('/', '%2F', $name); + $this->trace("Fetching metadata for {$name} (" . Url::sanitize($url) . ')'); + $response = $this->send( $url, fn(): Response => $this->httpDownloader->get($url, ['http' => ['header' => [self::METADATA_ACCEPT]]]), - Message::NATIVE_PACKAGE_NOT_FOUND->getMessage($name, $this->registryUrl()), + Message::NATIVE_PACKAGE_NOT_FOUND->getMessage($name, Url::sanitize($this->registryUrl())), ); try { @@ -206,6 +216,7 @@ public function prefetchTarballs(array $packages, string $directory): void // The URL hash keeps `@scope/pkg` and `scope-pkg` apart while their downloads run side by side. $file = $this->tarballFile($package, $directory, '-' . sha1($resolved) . '.prefetch'); + $this->trace("Prefetching {$package->name} ({$package->version}) from " . Url::sanitize($resolved)); $this->httpDownloader->addCopy($resolved, $file)->then( function () use ($package, $key, $file, $resolved): void { try { @@ -214,7 +225,7 @@ function () use ($package, $key, $file, $resolved): void { } else { $this->reportPrefetchFailure( $package, - Message::NATIVE_INTEGRITY_MISMATCH->getMessage($resolved), + Message::NATIVE_INTEGRITY_MISMATCH->getMessage(Url::sanitize($resolved)), ); } } catch (Throwable $exception) { @@ -312,7 +323,7 @@ private function retryDelay(TransportException $exception, int $retry): int /** * Runs a request, retrying HTTP 429 responses, and converts transport failures into Foxy exceptions. * - * @param string $url Requested URL, used in messages. + * @param string $url Requested URL; messages carry its {@see Url::sanitize()} copy. * @param Closure(): Response $request Request to run. * @param string|null $notFound Message thrown for an HTTP 404 response, or `null` to treat it as a failure. * @@ -337,14 +348,21 @@ private function send(string $url, Closure $request, string|null $notFound = nul if (429 !== $status || self::RETRIES === $retry) { throw new RuntimeException( - Message::NATIVE_REGISTRY_REQUEST_FAILED->getMessage($url, $exception->getMessage()), + Message::NATIVE_REGISTRY_REQUEST_FAILED->getMessage( + Url::sanitize($url), + $exception->getMessage(), + ), ); } $seconds = $this->retryDelay($exception, $retry); $this->io->writeError( - sprintf('Retrying "%s" after a 429 response (%d s)', $url, $seconds), + sprintf( + 'Retrying "%s" after a 429 response (%d s)', + Url::sanitize($url), + $seconds, + ), ); ($this->delay ?? sleep(...))($seconds); @@ -370,4 +388,12 @@ private function tarballKey(ResolvedPackage $package): string { return "tarball/{$package->name}/{$package->version}-" . sha1($package->resolved ?? '') . '.tgz'; } + + /** + * Writes a registry traffic line at very verbose level (`-vv`). + */ + private function trace(string $message): void + { + $this->io->writeError($message, true, IOInterface::VERY_VERBOSE); + } } diff --git a/tests/Asset/AbstractAssetManagerTest.php b/tests/Asset/AbstractAssetManagerTest.php index f4752dc..575245e 100644 --- a/tests/Asset/AbstractAssetManagerTest.php +++ b/tests/Asset/AbstractAssetManagerTest.php @@ -342,7 +342,7 @@ public function testBuildCommandNormalizesWindowsBinaryPath(): void self::assertSame( 'C:\\tools\\inspectable.exe install', - $this->createManager()->buildCommandForTest('inspectable.exe', 'install', 'install'), + $this->createManager()->buildCommandForTest('install', 'install'), 'Windows path separators must be normalized.', ); } @@ -359,7 +359,7 @@ public function testBuildCommandUsesTrimmedGlobalAndActionOptions(): void self::assertSame( str_replace('/', DIRECTORY_SEPARATOR, 'custom/bin') . ' install --global --local', - $this->createManager()->buildCommandForTest('inspectable', 'install', 'install'), + $this->createManager()->buildCommandForTest('install', 'install'), 'Options must be trimmed and ordered.', ); } @@ -512,7 +512,7 @@ public function testManagersSharingConfigurationReadTheirOwnEntries(): void self::assertSame( 'bin-a install', - $inspectable->buildCommandForTest('inspectable', 'install', 'install'), + $inspectable->buildCommandForTest('install', 'install'), 'First manager must keep its own entry after the second probe.', ); diff --git a/tests/Fixtures/Asset/CustomBinaryAssetManager.php b/tests/Fixtures/Asset/CustomBinaryAssetManager.php index 6ce9a70..b784430 100644 --- a/tests/Fixtures/Asset/CustomBinaryAssetManager.php +++ b/tests/Fixtures/Asset/CustomBinaryAssetManager.php @@ -47,7 +47,7 @@ public function getVersionConstraint(): string protected function getAuditCommand(bool $noDev): string { - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), 'audit'); + return $this->buildUnconfiguredCommand('audit'); } protected function getDefaultBinary(): string diff --git a/tests/Fixtures/Asset/InspectableAssetManager.php b/tests/Fixtures/Asset/InspectableAssetManager.php index 478f319..894bc7a 100644 --- a/tests/Fixtures/Asset/InspectableAssetManager.php +++ b/tests/Fixtures/Asset/InspectableAssetManager.php @@ -33,9 +33,9 @@ final class InspectableAssetManager extends AbstractAssetManager private string|null $versionOutput = null; - public function buildCommandForTest(string $defaultBin, string $action, array|string $command): string + public function buildCommandForTest(string $action, array|string $command): string { - return $this->buildCommand($defaultBin, $action, $command); + return $this->buildCommand($action, $command); } public function disableVersionConverterForTest(): void @@ -151,7 +151,7 @@ protected function actionWhenComposerDependenciesAreMerged( protected function getAuditCommand(bool $noDev): string { - return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $noDev ? ['audit', '--prod'] : ['audit']); + return $this->buildUnconfiguredCommand($noDev ? ['audit', '--prod'] : ['audit']); } protected function getAuditEnvironment(): array diff --git a/tests/Native/InstalledStateTest.php b/tests/Native/InstalledStateTest.php new file mode 100644 index 0000000..eb0d9ff --- /dev/null +++ b/tests/Native/InstalledStateTest.php @@ -0,0 +1,171 @@ + '1.0.0', 'file' => './vendor/acme/theme']; + private const array REGISTRY = [ + 'version' => '1.3.0', + 'resolved' => 'https://registry.test/left-pad/-/left-pad-1.3.0.tgz', + 'integrity' => 'sha512-AAAA', + ]; + + private string $path = ''; + private string $root = ''; + + public function testReadHidesMalformedWarningAtNormalVerbosity(): void + { + $io = new BufferIO(); + + file_put_contents($this->path, '{'); + + self::assertSame( + [], + (new InstalledState($this->path, $io))->read(), + 'A malformed file must read as empty.', + ); + self::assertSame( + '', + $io->getOutput(), + 'The warning must be verbose only.', + ); + } + + #[DataProviderExternal(InstalledStateProvider::class, 'malformedFiles')] + public function testReadIgnoresMalformedFile(string $contents): void + { + $io = new BufferIO('', OutputInterface::VERBOSITY_VERBOSE); + + file_put_contents($this->path, $contents); + + self::assertSame( + [], + (new InstalledState($this->path, $io))->read(), + 'A malformed file must read as empty.', + ); + self::assertSame( + "The installed state file \"{$this->path}\" is malformed and was ignored." . PHP_EOL, + $io->getOutput(), + 'The warning must name the file.', + ); + } + + public function testReadReturnsEmptyStateForMissingFile(): void + { + $io = new BufferIO('', OutputInterface::VERBOSITY_VERBOSE); + + self::assertSame( + [], + (new InstalledState($this->path, $io))->read(), + 'A missing file must read as empty.', + ); + self::assertSame( + '', + $io->getOutput(), + 'A missing file is not malformed.', + ); + } + + public function testReadReturnsWrittenEntries(): void + { + $io = new BufferIO('', OutputInterface::VERBOSITY_VERBOSE); + $state = new InstalledState($this->path, $io); + + $state->write(['left-pad' => self::REGISTRY, '@acme/theme' => self::LOCAL]); + + self::assertSame( + ['@acme/theme' => self::LOCAL, 'left-pad' => self::REGISTRY], + $state->read(), + 'Both entry shapes must be read back in name order.', + ); + self::assertSame( + '', + $io->getOutput(), + 'A well-formed file must not be reported.', + ); + } + + public function testWriteRecordsEmptyStateAsObject(): void + { + (new InstalledState($this->path, new BufferIO()))->write([]); + + self::assertStringEqualsFile( + $this->path, + <<<'JSON' + { + "packages": {} + } + + JSON, + 'An empty state must be written as an empty object.', + ); + } + + public function testWriteSortsPackagesByName(): void + { + (new InstalledState($this->path, new BufferIO()))->write( + ['left-pad' => self::REGISTRY, '@acme/theme' => self::LOCAL], + ); + + self::assertStringEqualsFile( + $this->path, + <<<'JSON' + { + "packages": { + "@acme/theme": { + "version": "1.0.0", + "file": "./vendor/acme/theme" + }, + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.test/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-AAAA" + } + } + } + + JSON, + 'Names must be sorted byte-wise.', + ); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->root = sys_get_temp_dir() . '/' . uniqid('foxy_installed_state_test_', true); + $this->path = "{$this->root}/.foxy-installed.json"; + + mkdir($this->root, 0o777, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + + (new Filesystem())->removeDirectory($this->root); + } +} diff --git a/tests/Native/NativeInstallerTest.php b/tests/Native/NativeInstallerTest.php index 537cef1..a6f24be 100644 --- a/tests/Native/NativeInstallerTest.php +++ b/tests/Native/NativeInstallerTest.php @@ -28,6 +28,7 @@ use function json_encode; use function mkdir; use function rename; +use function sprintf; use function str_replace; use function strlen; use function substr; @@ -50,6 +51,7 @@ final class NativeInstallerTest extends TestCase private const string ALPHA_PATH = './vendor/php-forge/composer-asset/acme/alpha'; private const string LOCAL_NAME = '@composer-asset/acme--theme'; private const string LOCAL_PATH = './vendor/php-forge/composer-asset/acme/theme'; + private const string NOTHING = 'Nothing to install, update or remove'; private const array README = [ 'This file locks the frontend dependencies installed by the Foxy native manager.', 'Do not edit it manually; composer install and composer update regenerate it.', @@ -266,6 +268,93 @@ public function testInstallDropsRootOptionalLocalPackageWithUnavailableDependenc ); } + public function testInstallExtractsAgainPackageReplacedBySymbolicLink(): void + { + $this->writeJson('package.json', ['dependencies' => ['left-pad' => '*']]); + $this->install($this->registry()); + $this->writeFile('external/index.js', 'external'); + $this->removePath('node_modules/left-pad'); + + if (!@symlink("{$this->root}/external", "{$this->root}/node_modules/left-pad")) { + self::markTestSkipped('Symbolic links are not available.'); + } + + $this->install($this->registry()); + + self::assertSame( + [' - Installing left-pad (1.3.0): Extracting archive'], + $this->output, + 'A link must not count as the installed directory.', + ); + self::assertFalse( + is_link("{$this->root}/node_modules/left-pad"), + 'The link must be replaced by a directory.', + ); + self::assertStringEqualsFile( + "{$this->root}/external/index.js", + 'external', + 'The link target must stay intact.', + ); + } + + public function testInstallExtractsAgainPackageWhoseDirectoryIsMissing(): void + { + $registry = $this->registry(); + + $this->writeJson('package.json', ['dependencies' => ['bootstrap' => '^5.3']]); + $this->install($registry); + $this->removePath('node_modules/bootstrap'); + $this->install($registry); + + self::assertSame( + [' - Installing bootstrap (5.3.8): Extracting archive'], + $this->output, + 'Only the missing package must be extracted.', + ); + self::assertSame( + ['bootstrap'], + $registry->prefetched[1]['names'], + 'Only the missing package must be prefetched.', + ); + } + + public function testInstallExtractsEverythingAgainWhenStateIsMalformed(): void + { + $this->writeJson('package.json', ['dependencies' => ['left-pad' => '*']]); + $this->install($this->registry()); + $this->writeFile('node_modules/.foxy-installed.json', '{'); + + $io = $this->io(IOInterface::VERBOSE); + + $io + ->expects(self::once()) + ->method('writeError') + ->with( + 'The installed state file "' . "{$this->root}/node_modules/.foxy-installed.json" + . '" is malformed and was ignored.', + true, + IOInterface::VERBOSE, + ); + + $this->installer($this->registry(), $io)->install( + "{$this->root}/package.json", + "{$this->root}/foxy.lock", + "{$this->root}/node_modules", + false, + ); + + self::assertSame( + [' - Installing left-pad (1.3.0): Extracting archive'], + $this->output, + 'A malformed state must keep nothing.', + ); + self::assertSame( + ['packages' => ['left-pad' => $this->lockEntry('left-pad', '1.3.0')]], + $this->state(), + 'The malformed state must be replaced.', + ); + } + public function testInstallExtractsResolvedPackagesIntoNodeModules(): void { $registry = $this->registry(); @@ -362,6 +451,63 @@ public function testInstallKeepsSymlinkedInstallDirectory(): void ); } + public function testInstallKeepsUnchangedRegistryPackages(): void + { + $registry = $this->registry(); + + $this->writeJson( + 'package.json', + ['dependencies' => ['bootstrap' => '^5.3'], 'devDependencies' => ['left-pad' => '*']], + ); + $this->install($registry); + $this->writeFile('node_modules/left-pad/marker.txt', 'marker'); + $this->install($registry); + + self::assertSame( + [self::NOTHING], + $this->output, + 'Kept packages must stay silent at normal verbosity.', + ); + + $this->install($registry, false, IOInterface::VERBOSE); + + self::assertSame( + [ + ' - Keeping @popperjs/core (2.11.8)', + ' - Keeping bootstrap (5.3.8)', + ' - Keeping left-pad (1.3.0)', + self::NOTHING, + ], + $this->output, + 'Each kept package must be listed at verbose level.', + ); + self::assertCount( + 3, + $registry->fetched, + 'Kept tarballs must not be fetched.', + ); + self::assertCount( + 1, + $registry->prefetched, + 'Nothing must be prefetched when every package is kept.', + ); + self::assertFileExists( + "{$this->root}/node_modules/left-pad/marker.txt", + 'A kept directory must not be replaced.', + ); + self::assertSame( + [ + 'packages' => [ + '@popperjs/core' => $this->lockEntry('@popperjs/core', '2.11.8'), + 'bootstrap' => $this->lockEntry('bootstrap', '5.3.8'), + 'left-pad' => $this->lockEntry('left-pad', '1.3.0'), + ], + ], + $this->state(), + 'The state must still list the kept packages.', + ); + } + public function testInstallLetsLocalOptionalDependencyOverrideMandatoryOne(): void { $this->writeJson('package.json', ['dependencies' => [self::LOCAL_NAME => 'file:' . self::LOCAL_PATH]]); @@ -475,21 +621,20 @@ public function testInstallPrefetchesRegistryPackagesOnceBeforeFetching(): void ); } - public function testInstallPrefetchesUnchangedPackagesAgainFromFreshLock(): void + #[DataProviderExternal(NativeInstallerProvider::class, 'staleEntries')] + public function testInstallPrunesEntriesOnWarmInstall(string $file, string $line): void { $registry = $this->registry(); $this->writeJson('package.json', ['dependencies' => ['left-pad' => '*']]); $this->install($registry); + $this->writeFile("node_modules/{$file}", 'stale'); $this->install($registry); self::assertSame( - [ - ['names' => ['left-pad'], 'directory' => "{$this->root}/node_modules/.foxy-tmp", 'fetchedBefore' => 0], - ['names' => ['left-pad'], 'directory' => "{$this->root}/node_modules/.foxy-tmp", 'fetchedBefore' => 1], - ], - $registry->prefetched, - 'A reinstall from the lock must prefetch the locked packages again.', + [$line], + $this->output, + 'A removal alone must count as a change.', ); } @@ -534,6 +679,33 @@ public function testInstallPrunesEntriesOutsideTheInstallSet(): void } } + public function testInstallRecordsInstallSetInStateFile(): void + { + $this->writeJson( + 'package.json', + ['dependencies' => ['left-pad' => '*', self::LOCAL_NAME => 'file:' . self::LOCAL_PATH]], + ); + $this->writeLocal(['version' => '1.0.0']); + $this->writeFile('node_modules/stale/index.js', 'stale'); + $this->install($this->registry()); + + self::assertSame( + [ + 'packages' => [ + self::LOCAL_NAME => ['version' => '1.0.0', 'file' => self::LOCAL_PATH], + 'left-pad' => $this->lockEntry('left-pad', '1.3.0'), + ], + ], + $this->state(), + 'The state must hold the lock entry of every installed package.', + ); + self::assertSame( + $this->lock()['packages'], + $this->state()['packages'], + 'The state entries must match the lock entries.', + ); + } + public function testInstallRemovesDanglingSymbolicLinks(): void { $this->writeJson('package.json', ['dependencies' => ['left-pad' => '*']]); @@ -567,6 +739,33 @@ public function testInstallRemovesDanglingSymbolicLinks(): void ); } + public function testInstallRemovesStateFileBeforeChangingPackages(): void + { + $this->writeJson('package.json', ['dependencies' => ['left-pad' => '*']]); + $this->install($this->registry()); + $this->removePath('node_modules/left-pad'); + + try { + $this->install(new InMemoryRegistry()); + + self::fail('Expected the missing tarball to abort the installation.'); + } catch (RuntimeException $exception) { + self::assertSame( + Message::NATIVE_REGISTRY_REQUEST_FAILED->getMessage( + $this->lockEntry('left-pad', '1.3.0')['resolved'], + 'not registered', + ), + $exception->getMessage(), + 'The failed fetch must abort the installation.', + ); + } + + self::assertFileDoesNotExist( + "{$this->root}/node_modules/.foxy-installed.json", + 'An interrupted installation must leave no state.', + ); + } + public function testInstallRemovesSymlinkedScopesWithoutVisitingTheirTargets(): void { $this->writeJson('package.json', ['dependencies' => ['left-pad' => '*']]); @@ -620,6 +819,26 @@ public function testInstallReplacesScopeFileOfInstalledPackage(): void ); } + public function testInstallReplacesScopeFileOfRefreshedLocalPackage(): void + { + $this->writeJson('package.json', ['dependencies' => [self::LOCAL_NAME => 'file:' . self::LOCAL_PATH]]); + $this->writeLocal(['version' => '1.0.0']); + $this->install($this->registry()); + $this->removePath('node_modules/@composer-asset'); + $this->writeFile('node_modules/@composer-asset', 'stale'); + $this->install($this->registry()); + + self::assertSame( + [' - Removing @composer-asset'], + $this->output, + 'The scope removal must count as a change.', + ); + self::assertFileExists( + "{$this->root}/node_modules/" . self::LOCAL_NAME . '/package.json', + 'The local package must be copied into a real scope.', + ); + } + public function testInstallReplacesSymlinkedScopeOfInstalledPackage(): void { $this->writeJson('package.json', ['dependencies' => ['@popperjs/core' => '^2.11']]); @@ -652,6 +871,71 @@ public function testInstallReplacesSymlinkedScopeOfInstalledPackage(): void ); } + public function testInstallReportsLocalCopiesByRecordedVersion(): void + { + $registry = $this->registry(); + $copy = '@composer-asset/acme--theme (%s): Copying from ' . self::LOCAL_PATH; + + $this->writeJson('package.json', ['dependencies' => [self::LOCAL_NAME => 'file:' . self::LOCAL_PATH]]); + $this->writeLocal(['version' => '1.0.0']); + $this->install($registry); + + self::assertSame( + [' - Installing ' . sprintf($copy, '1.0.0')], + $this->output, + 'A local package without a recorded entry must be reported as installed.', + ); + + $this->writeLocal(['version' => '1.1.0']); + $this->install($registry); + + self::assertSame( + [' - Updating ' . sprintf($copy, '1.0.0 => 1.1.0')], + $this->output, + 'A new local version must be reported as an update.', + ); + + $this->writeFile(self::LOCAL_PATH . '/index.js', 'changed'); + $this->install($registry); + + self::assertSame( + [self::NOTHING], + $this->output, + 'A refreshed copy must stay silent at normal verbosity.', + ); + + $this->install($registry, false, IOInterface::VERBOSE); + + self::assertSame( + [' - Refreshing ' . sprintf($copy, '1.1.0'), self::NOTHING], + $this->output, + 'A refreshed copy must be listed at verbose level.', + ); + self::assertStringEqualsFile( + "{$this->root}/node_modules/" . self::LOCAL_NAME . '/index.js', + 'changed', + 'An unchanged version must still be copied again.', + ); + } + + public function testInstallReportsLocalPackageInstalledWhenRecordedEntryDiffers(): void + { + $registry = $this->registry(); + + $this->writeJson('package.json', ['dependencies' => ['theme' => 'file:./theme']]); + $this->writeJson('theme/package.json', ['version' => '1.0.0']); + $this->install($registry); + $this->writeJson('package.json', ['dependencies' => ['theme' => 'file:./themes/theme']]); + $this->writeJson('themes/theme/package.json', ['version' => '1.0.0']); + $this->install($registry); + + self::assertSame( + [' - Installing theme (1.0.0): Copying from ./themes/theme'], + $this->output, + 'A moved source of the same version must be reported as installed.', + ); + } + public function testInstallResolvesAgainWhenDroppedOptionalLocalVersionChanges(): void { $registry = $this->registry(); @@ -959,9 +1243,9 @@ public function testInstallReusesFreshLockWithoutMetadataRequests(): void 'A fresh lock must not request metadata.', ); self::assertCount( - 2 * $fetched, + $fetched, $registry->fetched, - 'Locked tarballs must be fetched again.', + 'Kept tarballs must not be fetched again.', ); self::assertStringEqualsFile( "{$this->root}/foxy.lock", @@ -1078,9 +1362,9 @@ public function testInstallTreatsMissingManifestAsEmpty(): void 'The modules directory must be created.', ); self::assertSame( - [], + [self::NOTHING], $this->output, - 'Nothing must be reported.', + 'Only the no-change line must be reported.', ); } @@ -1098,6 +1382,25 @@ public function testInstallUpdateOverwritesMalformedLock(): void ); } + public function testInstallUpdatesPackageWhoseRecordedVersionDiffers(): void + { + $registry = $this->registry(); + + $this->writeJson('package.json', ['dependencies' => ['bootstrap' => '5.2.0']]); + $this->install($registry); + $this->writeJson('package.json', ['dependencies' => ['bootstrap' => '^5.3']]); + $this->install($registry); + + self::assertSame( + [ + ' - Installing @popperjs/core (2.11.8): Extracting archive', + ' - Updating bootstrap (5.2.0 => 5.3.8): Extracting archive', + ], + $this->output, + 'A recorded version must turn the extraction into an update.', + ); + } + public function testInstallUsesAbsoluteFileValue(): void { $directory = "{$this->root}/elsewhere/theme"; @@ -1405,9 +1708,12 @@ private function compactLock(): string return $compact; } - private function install(InMemoryRegistry $registry, bool $update = false): void - { - $this->installer($registry, $this->io())->install( + private function install( + InMemoryRegistry $registry, + bool $update = false, + int $verbosity = IOInterface::NORMAL, + ): void { + $this->installer($registry, $this->io($verbosity))->install( "{$this->root}/package.json", "{$this->root}/foxy.lock", "{$this->root}/node_modules", @@ -1481,9 +1787,10 @@ private function installWithOptionalWarning(string $name, string $spec): void } /** - * Returns an output mock that records the written lines into {@see $output}. + * Returns an output mock that records into {@see $output} the lines visible at the given verbosity, as Composer + * filters them, and checks that every line ends with a newline. */ - private function io(): IOInterface&MockObject + private function io(int $verbosity = IOInterface::NORMAL): IOInterface&MockObject { $this->output = []; @@ -1492,8 +1799,16 @@ private function io(): IOInterface&MockObject $io ->method('write') ->willReturnCallback( - function (string $message): void { - $this->output[] = $message; + function ( + string $message, + bool $newline = true, + int $level = IOInterface::NORMAL, + ) use ($verbosity): void { + self::assertTrue($newline, 'Every line must end with a newline.'); + + if ($level <= $verbosity) { + $this->output[] = $message; + } }, ); @@ -1600,6 +1915,27 @@ private function registry(): InMemoryRegistry return $registry; } + /** + * Removes a file, a directory, or a link below the test root. + */ + private function removePath(string $path): void + { + (new Filesystem())->remove("{$this->root}/{$path}"); + } + + /** + * @return array + */ + private function state(): array + { + return json_decode( + (string) file_get_contents("{$this->root}/node_modules/.foxy-installed.json"), + true, + 512, + JSON_THROW_ON_ERROR, + ); + } + private static function tarballUrl(string $name, string $version): string { return "https://registry.test/{$name}/-/" . str_replace(['@', '/'], ['', '-'], $name) . "-{$version}.tgz"; diff --git a/tests/Native/NpmRegistryTest.php b/tests/Native/NpmRegistryTest.php index 19f7438..f852656 100644 --- a/tests/Native/NpmRegistryTest.php +++ b/tests/Native/NpmRegistryTest.php @@ -48,6 +48,8 @@ final class NpmRegistryTest extends TestCase private const string LEFT_PAD_URL = 'https://registry.test/left-pad/-/left-pad-1.3.0.tgz'; private const string METADATA_URL = 'https://registry.test/@scope%2Fpkg'; private const string TARBALL_URL = 'https://registry.test/@scope/pkg/-/pkg-1.0.0.tgz'; + private const string TOKEN_TARBALL_URL = self::TARBALL_URL . '?access_token=abc'; + private const string TOKEN_TARBALL_URL_REDACTED = self::TARBALL_URL . '?access_token=***'; private string $cacheDir = ''; @@ -100,6 +102,20 @@ public function testFetchTarballDownloadsAndCachesOnColdCache(): void ); } + public function testFetchTarballRedactsAccessTokenAtVeryVerbose(): void + { + $io = self::veryVerboseIo(); + + $this->registry($this->tarballDownloader(NpmRegistryProvider::TARBALL, self::TOKEN_TARBALL_URL), io: $io) + ->fetchTarball($this->package(resolved: self::TOKEN_TARBALL_URL), $this->directory); + + self::assertSame( + 'Downloading @scope/pkg (1.0.0) from ' . self::TOKEN_TARBALL_URL_REDACTED . PHP_EOL, + $io->getOutput(), + 'The access token must be masked.', + ); + } + public function testFetchTarballReplacesCorruptedCacheEntry(): void { $this->seedCache('corrupted'); @@ -119,6 +135,49 @@ public function testFetchTarballReplacesCorruptedCacheEntry(): void ); } + public function testFetchTarballReportsCacheHitAtVeryVerbose(): void + { + $io = self::veryVerboseIo(); + + $this->seedCache(NpmRegistryProvider::TARBALL); + $this->registry($this->idleDownloader(), io: $io)->fetchTarball($this->package(), $this->directory); + + self::assertSame( + 'Loading @scope/pkg (1.0.0) from cache' . PHP_EOL, + $io->getOutput(), + 'The cache hit must be traced.', + ); + } + + public function testFetchTarballReportsDownloadAfterCorruptedCacheHit(): void + { + $io = self::veryVerboseIo(); + + $this->seedCache('corrupted'); + $this->registry($this->tarballDownloader(NpmRegistryProvider::TARBALL), io: $io) + ->fetchTarball($this->package(), $this->directory); + + self::assertSame( + 'Downloading @scope/pkg (1.0.0) from ' . self::TARBALL_URL . PHP_EOL, + $io->getOutput(), + 'A rejected cache copy must be traced as a download only.', + ); + } + + public function testFetchTarballReportsDownloadAtVeryVerbose(): void + { + $io = self::veryVerboseIo(); + + $this->registry($this->tarballDownloader(NpmRegistryProvider::TARBALL), io: $io) + ->fetchTarball($this->package(), $this->directory); + + self::assertSame( + 'Downloading @scope/pkg (1.0.0) from ' . self::TARBALL_URL . PHP_EOL, + $io->getOutput(), + 'The download must be traced with its URL.', + ); + } + public function testFetchTarballRetriesTooManyRequests(): void { $calls = 0; @@ -244,6 +303,62 @@ static function () use (&$calls, $exception): Response { ); } + #[DataProviderExternal(NpmRegistryProvider::class, 'credentialedRegistryUrls')] + public function testGetMetadataRedactsCredentialsAtVeryVerbose(string $url, string $redacted): void + { + $io = self::veryVerboseIo(); + + $downloader = $this->createMock(HttpDownloader::class); + + $downloader + ->expects(self::once()) + ->method('get') + ->with("{$url}/left-pad", self::ACCEPT) + ->willReturn($this->metadataResponse()); + + $this->registry($downloader, io: $io, url: $url)->getMetadata('left-pad'); + + self::assertSame( + "Fetching metadata for left-pad ({$redacted}/left-pad)" . PHP_EOL, + $io->getOutput(), + 'Credentials must be masked in the trace only.', + ); + } + + public function testGetMetadataRedactsCredentialsInRetryNotice(): void + { + $downloader = $this->metadataDownloader( + self::transportException(429), + 'https://user:secret@registry.test/@scope%2Fpkg', + ); + + $this->registry($downloader, url: 'https://user:secret@registry.test')->getMetadata('@scope/pkg'); + + self::assertSame( + 'Retrying "https://user:***@registry.test/@scope%2Fpkg" after a 429 response (1 s)' . PHP_EOL, + $this->io->getOutput(), + 'The password must be masked.', + ); + } + + public function testGetMetadataReportsRequestAtVeryVerbose(): void + { + $io = self::veryVerboseIo(); + $downloader = $this->createMock(HttpDownloader::class); + + $downloader + ->method('get') + ->willReturn($this->metadataResponse()); + + $this->registry($downloader, io: $io)->getMetadata('@scope/pkg'); + + self::assertSame( + 'Fetching metadata for @scope/pkg (' . self::METADATA_URL . ')' . PHP_EOL, + $io->getOutput(), + 'The request must be traced with its URL.', + ); + } + public function testGetMetadataRequestsEscapedScopedNameWithAcceptHeader(): void { $downloader = $this->createMock(HttpDownloader::class); @@ -510,6 +625,42 @@ public function testPrefetchTarballsKeepsCollidingNamesApart(): void ); } + public function testPrefetchTarballsRedactsAccessTokenAtVeryVerbose(): void + { + $io = self::veryVerboseIo(); + + $this->registry($this->prefetchDownloader(1, self::serve('corrupted')), io: $io) + ->prefetchTarballs([$this->package(resolved: self::TOKEN_TARBALL_URL)], $this->directory); + + self::assertSame( + self::TOKEN_TARBALL_URL, + $this->copies[0][0], + 'The download must use the raw URL.', + ); + self::assertSame( + 'Prefetching @scope/pkg (1.0.0) from ' . self::TOKEN_TARBALL_URL_REDACTED . PHP_EOL + . 'Prefetching the tarball of "@scope/pkg" (1.0.0) failed: ' + . 'The tarball "' . self::TOKEN_TARBALL_URL_REDACTED . '" failed its integrity check.' . PHP_EOL, + $io->getOutput(), + 'The access token must be masked in the trace and the notice.', + ); + } + + public function testPrefetchTarballsReportsQueuedDownloadsAtVeryVerbose(): void + { + $io = self::veryVerboseIo(); + + $this->seedCache(NpmRegistryProvider::TARBALL); + $this->registry($this->prefetchDownloader(1, self::serve(self::LEFT_PAD_TARBALL)), io: $io) + ->prefetchTarballs([$this->package(), $this->leftPad()], $this->directory); + + self::assertSame( + 'Prefetching left-pad (1.3.0) from ' . self::LEFT_PAD_URL . PHP_EOL, + $io->getOutput(), + 'Only the queued download must be traced.', + ); + } + public function testPrefetchTarballsRequestsNothingForCachedPackages(): void { $this->seedCache(NpmRegistryProvider::TARBALL); @@ -642,6 +793,18 @@ public function testThrowRuntimeExceptionForRejectedIntegrity(string $integrity) ); } + public function testThrowRuntimeExceptionForRejectedIntegrityOfTokenTarballUrl(): void + { + $registry = $this->registry($this->tarballDownloader('corrupted', self::TOKEN_TARBALL_URL)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The tarball "' . self::TOKEN_TARBALL_URL_REDACTED . '" failed its integrity check.', + ); + + $registry->fetchTarball($this->package(resolved: self::TOKEN_TARBALL_URL), $this->directory); + } + public function testThrowRuntimeExceptionWhenAdvisoriesEndpointIsMissing(): void { $downloader = $this->createMock(HttpDownloader::class); @@ -659,6 +822,24 @@ public function testThrowRuntimeExceptionWhenAdvisoriesEndpointIsMissing(): void $this->registry($downloader)->getAdvisories(['lodash' => ['4.17.15']]); } + public function testThrowRuntimeExceptionWhenCredentialedRegistryRequestFails(): void + { + $downloader = $this->createMock(HttpDownloader::class); + + $downloader + ->expects(self::once()) + ->method('get') + ->with('https://user:secret@registry.test/@scope%2Fpkg', self::ACCEPT) + ->willThrowException(self::transportException(500, [], 'Internal Server Error')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The registry request "https://user:***@registry.test/@scope%2Fpkg" failed: Internal Server Error', + ); + + $this->registry($downloader, url: 'https://user:secret@registry.test')->getMetadata('@scope/pkg'); + } + public function testThrowRuntimeExceptionWhenDownloadFailsAfterCorruptedCacheHit(): void { $this->seedCache('corrupted'); @@ -740,6 +921,23 @@ public function testThrowRuntimeExceptionWhenPackageIsNotFound(): void $this->registry($downloader)->getMetadata('@scope/pkg'); } + public function testThrowRuntimeExceptionWhenPackageIsNotFoundInCredentialedRegistry(): void + { + $downloader = $this->createMock(HttpDownloader::class); + + $downloader + ->expects(self::once()) + ->method('get') + ->willThrowException(self::transportException(404, [], 'Not Found')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'The package "@scope/pkg" was not found in the registry "https://user:***@registry.test".', + ); + + $this->registry($downloader, url: 'https://user:secret@registry.test/')->getMetadata('@scope/pkg'); + } + public function testThrowRuntimeExceptionWhenTarballIsNotFound(): void { $downloader = $this->createMock(HttpDownloader::class); @@ -885,8 +1083,10 @@ private function leftPadCacheEntry(): string /** * Returns a downloader whose first metadata request fails with the exception and whose second one succeeds. */ - private function metadataDownloader(TransportException $exception): HttpDownloader&MockObject - { + private function metadataDownloader( + TransportException $exception, + string $url = self::METADATA_URL, + ): HttpDownloader&MockObject { $calls = 0; $response = $this->metadataResponse(); $downloader = $this->createMock(HttpDownloader::class); @@ -894,7 +1094,7 @@ private function metadataDownloader(TransportException $exception): HttpDownload $downloader ->expects(self::exactly(2)) ->method('get') - ->with(self::METADATA_URL, self::ACCEPT) + ->with($url, self::ACCEPT) ->willReturnCallback( static function () use (&$calls, $exception, $response): Response { if (0 === $calls++) { @@ -919,12 +1119,12 @@ private function metadataResponse(): Response ); } - private function package(string|null $integrity = null): ResolvedPackage + private function package(string|null $integrity = null, string $resolved = self::TARBALL_URL): ResolvedPackage { return new ResolvedPackage( '@scope/pkg', '1.0.0', - self::TARBALL_URL, + $resolved, $integrity ?? 'sha512-' . base64_encode(hash('sha512', NpmRegistryProvider::TARBALL, true)), ); } @@ -963,12 +1163,13 @@ private function registry( HttpDownloader $downloader, Cache|null $cache = null, IOInterface|null $io = null, + string $url = 'https://registry.test/', ): NpmRegistry { return new NpmRegistry( $downloader, $cache ?? $this->cache(), $io ?? $this->io, - 'https://registry.test/', + $url, function (int $seconds): void { $this->delays[] = $seconds; }, @@ -1002,16 +1203,16 @@ private static function serve(string $bytes): Closure } /** - * Returns a downloader that writes the bytes to the requested file once. + * Returns a downloader that writes the bytes to the requested file once, expecting the raw tarball URL. */ - private function tarballDownloader(string $bytes): HttpDownloader&MockObject + private function tarballDownloader(string $bytes, string $url = self::TARBALL_URL): HttpDownloader&MockObject { $downloader = $this->createMock(HttpDownloader::class); $downloader ->expects(self::once()) ->method('copy') - ->with(self::TARBALL_URL, "{$this->directory}/scope-pkg-1.0.0.tgz") + ->with($url, "{$this->directory}/scope-pkg-1.0.0.tgz") ->willReturnCallback( static function (string $url, string $to) use ($bytes): Response { file_put_contents($to, $bytes); @@ -1043,4 +1244,9 @@ private static function verboseIo(): BufferIO { return new BufferIO('', OutputInterface::VERBOSITY_VERBOSE); } + + private static function veryVerboseIo(): BufferIO + { + return new BufferIO('', OutputInterface::VERBOSITY_VERY_VERBOSE); + } } diff --git a/tests/Provider/InstalledStateProvider.php b/tests/Provider/InstalledStateProvider.php new file mode 100644 index 0000000..2a6064f --- /dev/null +++ b/tests/Provider/InstalledStateProvider.php @@ -0,0 +1,30 @@ + + */ + public static function malformedFiles(): iterable + { + yield 'entry holding a list' => ['{"packages": {"a": ["1.0.0", "./a"]}}']; + yield 'entry holding a number' => ['{"packages": {"a": {"version": 1, "file": "./a"}}}']; + yield 'entry holding a string' => ['{"packages": {"a": "1.0.0"}}']; + yield 'entry with an extra key' => ['{"packages": {"a": {"version": "1.0.0", "file": "./a", "x": "y"}}}']; + yield 'entry without integrity' => ['{"packages": {"a": {"version": "1.0.0", "resolved": "https://r/a.tgz"}}}']; + yield 'invalid JSON' => ['{']; + yield 'later entry malformed' => [ + '{"packages": {"a": {"version": "1.0.0", "file": "./a"}, "b": {"version": "1.0.0"}}}', + ]; + yield 'packages holding a string' => ['{"packages": "a"}']; + yield 'packages missing' => ['{"dependencies": {}}']; + yield 'root holding a string' => ['"packages"']; + } +} diff --git a/tests/Provider/NativeInstallerProvider.php b/tests/Provider/NativeInstallerProvider.php index f462d18..1352938 100644 --- a/tests/Provider/NativeInstallerProvider.php +++ b/tests/Provider/NativeInstallerProvider.php @@ -44,4 +44,19 @@ public static function mandatoryLocalFields(): iterable yield 'dependencies' => ['dependencies']; yield 'peer dependencies' => ['peerDependencies']; } + + /** + * @return iterable + */ + public static function staleEntries(): iterable + { + yield 'package in a scope' => [ + '@old/pkg/index.js', + ' - Removing @old/pkg', + ]; + yield 'top-level package' => [ + 'stale/index.js', + ' - Removing stale', + ]; + } } diff --git a/tests/Provider/NpmRegistryProvider.php b/tests/Provider/NpmRegistryProvider.php index 63806bb..4b2c3ad 100644 --- a/tests/Provider/NpmRegistryProvider.php +++ b/tests/Provider/NpmRegistryProvider.php @@ -29,6 +29,15 @@ public static function acceptedIntegrities(): iterable yield 'unknown token before a known one' => ['md5-AAAA ' . self::sri('sha384')]; } + /** + * @return iterable + */ + public static function credentialedRegistryUrls(): iterable + { + yield 'password' => ['https://user:secret@registry.test', 'https://user:***@registry.test']; + yield 'token as user name' => ['https://npm_0123456789abcdef@registry.test', 'https://npm***@registry.test']; + } + /** * @return iterable */