diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..19dffb7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: sodium + coverage: none + tools: composer:2.8.12 + + - name: Install + run: composer install --no-interaction --prefer-dist + + - name: Lint + run: composer run lint + + - name: Test + run: composer run test diff --git a/.github/workflows/module-release.yml b/.github/workflows/module-release.yml index b4ee8a9..9ead5d0 100644 --- a/.github/workflows/module-release.yml +++ b/.github/workflows/module-release.yml @@ -12,6 +12,11 @@ on: required: false default: module.zip type: string + host-version: + description: Bundled modules only. The InvoiceShelf release (a tag such as 3.0.0-alpha.12) whose composer.lock the bundle is built against; it must satisfy compatibility.invoiceshelf. + required: false + default: '' + type: string secrets: MODULE_SIGNING_SECRET_KEY_B64: required: true @@ -29,6 +34,10 @@ on: description: Name for the deterministic module ZIP required: true default: module.zip + host-version: + description: Bundled modules only. InvoiceShelf release tag to build the bundle against + required: false + default: '' permissions: contents: read @@ -42,6 +51,7 @@ jobs: MODULE_ARTIFACT_NAME: ${{ inputs['artifact-name'] || 'module.zip' }} MODULE_SIGNING_KEY_ID: ${{ vars.MODULE_SIGNING_KEY_ID }} MODULE_MARKETPLACE_INGEST_URL: ${{ vars.MODULE_MARKETPLACE_INGEST_URL }} + MODULE_HOST_VERSION: ${{ inputs['host-version'] || '' }} steps: - uses: actions/checkout@v4 with: @@ -63,6 +73,8 @@ jobs: php-version: '8.4' extensions: sodium coverage: none + # Pinned: the bundle a release ships is resolved by this Composer. + tools: composer:2.8.12 - uses: actions/setup-node@v4 with: @@ -78,6 +90,29 @@ jobs: pnpm install --frozen-lockfile pnpm run build fi + if [ "$(jq -r '.dependency_policy' module.json)" = bundled ]; then + if [ -z "$MODULE_HOST_VERSION" ]; then + echo 'Bundled modules need the host-version input: the InvoiceShelf release whose composer.lock the bundle is built against.' >&2 + exit 1 + fi + if [ ! -f bundle.lock ]; then + echo 'Bundled modules must commit bundle.lock; run vendor/bin/invoiceshelf-module bundle locally and commit it.' >&2 + exit 1 + fi + php -r ' + require "vendor/autoload.php"; + $constraint = json_decode(file_get_contents("module.json"), true)["compatibility"]["invoiceshelf"]; + if (! Composer\Semver\Semver::satisfies(getenv("MODULE_HOST_VERSION"), $constraint)) { + fwrite(STDERR, "host-version ".getenv("MODULE_HOST_VERSION")." does not satisfy compatibility.invoiceshelf {$constraint}.\n"); + exit(1); + } + ' + curl --fail --silent --show-error --location \ + "https://raw.githubusercontent.com/InvoiceShelf/InvoiceShelf/$MODULE_HOST_VERSION/composer.lock" \ + --output "$RUNNER_TEMP/host-composer.lock" + # Installs exactly bundle.lock; fails if it no longer fits this host or composer.json. + vendor/bin/invoiceshelf-module bundle . --host-lock="$RUNNER_TEMP/host-composer.lock" + fi vendor/bin/invoiceshelf-module validate-module module.json vendor/bin/invoiceshelf-module validate-package . jq -r '.assets[]' module.json | while IFS= read -r asset; do @@ -106,6 +141,11 @@ jobs: rm -rf "$package/dist" cp -a dist "$package/dist" fi + # bundle/ is generated and untracked, like dist/. + if [ -d bundle ]; then + rm -rf "$package/bundle" + cp -a bundle "$package/bundle" + fi find "$stage" -exec touch -h -d "@$source_date_epoch" {} + ( cd "$stage" diff --git a/README.md b/README.md index 009948b..6a96dec 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The MIT-licensed SDK for official [InvoiceShelf](https://invoiceshelf.com) v3 modules. It extends [`nwidart/laravel-modules`](https://github.com/nWidart/laravel-modules) with the contracts used by InvoiceShelf's module host and signed marketplace packages. -SDK **3.4** supports Module API **1.3**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT. +SDK **3.5** supports Module API **1.4**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT. ## What a module can add @@ -13,7 +13,7 @@ SDK **3.4** supports Module API **1.3**. First-party module packages live in the - Narrow host contracts under [`src/Contracts/Host`](src/Contracts/Host) for settings, authorization, and read-only company-data queries. `CompanyDataReader` covers the AI assistant's built-in queries plus `companyMembers()` and `existingInvoiceIds()` for modules that assign work to members or stamp their entries against invoices. Module code must not depend on InvoiceShelf Eloquent models. - AI drivers through [`src/Ai`](src/Ai): extend `AiDriver`, return `AiChatResponse`, and throw `AiException` for safe, localizable provider failures. -The host controls discovery, installation, activation, migrations, and provider registration. Official packages are not a general-purpose runtime Composer installer for arbitrary third-party code. +The host controls discovery, installation, activation, migrations, and provider registration. It never runs Composer: a module either uses only what the host already ships, or brings the third-party packages it needs pre-built in `bundle/` (see [Dependencies](#dependencies)). ## Compatibility @@ -21,8 +21,8 @@ Declare compatibility in `module.json`, then test it against the host versions y | Concern | Current contract | | --- | --- | -| SDK | `invoiceshelf/modules` `^3.4` | -| Module API | `^1.3.0` | +| SDK | `invoiceshelf/modules` `^3.5` | +| Module API | `^1.3.0`, or `^1.4.0` for a module with bundled dependencies | | Host application | Your explicit InvoiceShelf 3.x range | | PHP | The range your module actually supports | @@ -86,6 +86,42 @@ final class DataCleanup implements DataCleanupContract An intentionally empty method is valid for a module with nothing beyond reversible migrations. Throwing from `cleanup()` stops the uninstall so it can be retried safely. +## Dependencies + +`dependency_policy` says where a module's PHP code comes from. Nothing is resolved on the server: the host never runs Composer, and its own `vendor/` is read-only. + +| Policy | composer.json may require | Use it when | +| --- | --- | --- | +| `host-provided-only` | `php`, `ext-*`, `invoiceshelf/modules` | The module needs nothing beyond Laravel, the SDK and what the host already ships. | +| `bundled` | the above plus third-party packages | The module needs a library the host does not ship, such as a payment provider's SDK. Requires schema 2 and `module_api` `^1.4.0`. | + +### How a bundle is built + +A bundled module declares its packages in `composer.json` as usual and runs: + +```bash +vendor/bin/invoiceshelf-module bundle . --host-lock=../../composer.lock # the host's lock; --update to re-resolve +``` + +The command resolves the module's packages in a throwaway Composer project where every **production** package of that host is a root `replace` at its locked version. Composer therefore reuses what the host ships and installs only what it lacks; a package that needs a version the host does not ship is a resolver error, never a silent second copy. The command then writes: + +- `bundle///…`: the package files, with normalized permissions; +- `bundle/classmap.php`: every class, relative to `bundle/`, which the host reads as data and loads **after** its own autoloader, so a host copy always wins; +- `bundle/bundle.json`: each package's version, reference, license, PHP range and extensions, the host packages the module relies on (constraints kept per requirer), and any Composer `files` entries with the functions they declare; +- `bundle.lock` next to `composer.json`: the pinned resolution. + +Commit `bundle.lock` and review its diff like code: signing a release vouches for everything inside it. `bundle/` itself is generated: gitignore it, and keep your own tools out of it (for Pint, `"exclude": ["bundle"]` in `pint.json`). Without `--update` the command installs exactly `bundle.lock` and fails when it no longer fits the host or `composer.json`, or when your own `composer.lock` holds different versions of a bundled package (tests run on `vendor/`, the archive ships `bundle/`). The same tree is written byte for byte on every machine. + +The build refuses a bundle the host cannot load safely: a license an AGPL-3.0-only module cannot convey (Composer's license list counts as alternatives), a Composer plugin, a class or function under `App\`, `Composer\`, `Database\`, `Illuminate\`, `InvoiceShelf\`, `Laravel\`, `Modules\`, `Nwidart\` or `Tests\`, an extension that `compatibility.extensions` does not list (core extensions such as `ext-json` and `ext-pcre` excepted), a symbolic link, or more than 9,000 files. It warns when a bundled package is also a development dependency of the host, because a local host then loads its own copy. + +### What the host checks + +On marketplace install, local `install:module`, enabling a module, and every container start (`modules:reconcile`), the host refuses or disables a bundled module when an installed host package no longer satisfies a recorded constraint, a bundled package's PHP range excludes the running PHP, a bundled package or class is also provided by the host or another module, or a bundled function already exists. Composer `files` entries are loaded only for enabled modules. + +### Local development + +Build the bundle against the lock of the host you run (for a sibling checkout, `--host-lock=../InvoiceShelf/composer.lock`; for a module placed in the host's `Modules/`, `--host-lock=../../composer.lock`), then `php artisan install:module `. Rebuild when you change a package or update the host. A local host has its development packages installed, and those win over a bundled copy; the release workflow builds against the production package set. + ## Developing a module Install the SDK in the module project, then use Laravel Modules as usual: @@ -213,7 +249,7 @@ vendor/bin/invoiceshelf-module validate-module module.json vendor/bin/invoiceshelf-module validate-package . ``` -`validate-package` checks the manifest, providers, migrations, declared assets, and the closed host-provided dependency policy. The CLI also validates and canonicalizes generated `release-manifest.json` files. +`validate-package` checks the manifest, providers, migrations, declared assets, and the dependency policy: for a bundled module, also `bundle/` against `bundle.json` (reading `classmap.php` as data, never running it). The CLI also validates and canonicalizes generated `release-manifest.json` files. ## Releasing an official module diff --git a/RELEASING.md b/RELEASING.md index b2b7a45..ee410fb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ This document is for maintainers of official module repositories. It describes t ## Before tagging 1. Set `module.json`'s `version` to the intended exact SemVer version. -2. Build and commit every declared `dist/` asset. +2. Build and commit every declared `dist/` asset. A bundled module also commits an up-to-date `bundle.lock`, reviewed like code. 3. Run the module's lint, tests, frontend build, and package validation. 4. Merge the release change, then create an **unprefixed** tag that exactly matches `module.json`, for example `1.2.3` rather than `v1.2.3`. @@ -18,12 +18,22 @@ An official module normally calls the tagged SDK workflow from its own tag workf ```yaml jobs: release: - uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.4.0 + uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.5.0 with: channel: stable secrets: inherit ``` +A module with `dependency_policy: bundled` also passes `host-version`, the InvoiceShelf release tag whose `composer.lock` the bundle is built against. Use the oldest release the module supports: it must satisfy `compatibility.invoiceshelf`, and newer hosts are checked against the recorded host packages at install time. + +```yaml + with: + channel: stable + host-version: 3.0.0-alpha.12 +``` + +The workflow then downloads that lock, installs exactly the committed `bundle.lock` (it never re-resolves) and copies the generated `bundle/` into the archive next to `dist/`. Composer is pinned in the workflow so the same lock always yields the same bytes. + The workflow installs dependencies, runs Pint and PHPUnit, builds frontend assets when present, validates the package, creates a timestamp-normalized ZIP, generates and validates `release-manifest.json`, signs its canonical JSON, and sends it to marketplace ingest. ## Protected `module-release` environment diff --git a/bin/invoiceshelf-module b/bin/invoiceshelf-module index 9cddf14..9ba4d2d 100755 --- a/bin/invoiceshelf-module +++ b/bin/invoiceshelf-module @@ -3,6 +3,8 @@ declare(strict_types=1); +use InvoiceShelf\Modules\Bundle\BundleBuilder; +use InvoiceShelf\Modules\Bundle\ComposerProcess; use InvoiceShelf\Modules\Manifest\ManifestValidator; use InvoiceShelf\Modules\Manifest\SigningKeyPairGenerator; @@ -14,14 +16,48 @@ if (! is_string($autoloadPath) || ! is_file($autoloadPath)) { require $autoloadPath; -[$command, $target] = array_pad(array_slice($argv, 1), 2, null); +$positional = []; +$options = []; +foreach (array_slice($argv, 1) as $argument) { + if (preg_match('/^--([a-z-]+)(?:=(.*))?$/', $argument, $matches) === 1) { + $options[$matches[1]] = $matches[2] ?? true; + } else { + $positional[] = $argument; + } +} +[$command, $target] = array_pad($positional, 2, null); -if (! in_array($command, ['validate-module', 'validate-package', 'validate-release', 'canonicalize-release', 'generate-keypair'], true) || ! is_string($target)) { +if (! in_array($command, ['validate-module', 'validate-package', 'validate-release', 'canonicalize-release', 'generate-keypair', 'bundle'], true) || ! is_string($target) + || ($command === 'bundle' && ! is_string($options['host-lock'] ?? null))) { fwrite(STDERR, "Usage: invoiceshelf-module \n"); fwrite(STDERR, " invoiceshelf-module generate-keypair \n"); + fwrite(STDERR, " invoiceshelf-module bundle --host-lock= [--update] [--composer=]\n"); exit(64); } +if ($command === 'bundle') { + try { + $composer = is_string($options['composer'] ?? null) ? $options['composer'] : null; + $report = (new BundleBuilder(ComposerProcess::locate($composer)))->build($target, $options['host-lock'], ($options['update'] ?? false) === true); + } catch (Throwable $exception) { + fwrite(STDERR, "Bundle failed: {$exception->getMessage()}\n"); + exit(1); + } + + fwrite(STDOUT, ($report->lockWritten ? 'Resolved and wrote bundle.lock. ' : 'Installed from bundle.lock. ') + .($report->manifest->packages === [] ? "bundle/ ships nothing: the host provides every requirement.\n" : "bundle/ ships:\n")); + foreach ($report->manifest->packages as $name => $package) { + fwrite(STDOUT, " {$name} {$package['version']} (".implode(' or ', $package['license']).")\n"); + } + if ($report->manifest->hostPackages !== []) { + fwrite(STDOUT, 'Relies on the host for: '.implode(', ', array_keys($report->manifest->hostPackages))."\n"); + } + foreach ($report->warnings as $warning) { + fwrite(STDERR, "Warning: {$warning}\n"); + } + exit(0); +} + try { if ($command === 'generate-keypair') { $keypair = SigningKeyPairGenerator::generate($target); diff --git a/composer.json b/composer.json index ca4be47..3c26828 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "license": "MIT", "type": "library", "require": { + "composer/semver": "^3.4", "nikic/php-parser": "^5.0", "php": "^8.3", "nwidart/laravel-modules": "^13.0" diff --git a/src/Bundle/BundleBuilder.php b/src/Bundle/BundleBuilder.php new file mode 100644 index 0000000..b268810 --- /dev/null +++ b/src/Bundle/BundleBuilder.php @@ -0,0 +1,554 @@ +bundlesDependencies()) { + throw new InvalidArgumentException("module.json must declare dependency_policy 'bundled' before its dependencies can be bundled."); + } + + $composerJson = self::json($root.'/composer.json', 'composer.json'); + $requires = PackageValidator::thirdPartyRequires((array) ($composerJson['require'] ?? [])); + if ($requires === []) { + throw new InvalidArgumentException("composer.json requires no third-party packages; use dependency_policy 'host-provided-only' instead."); + } + + $host = HostLock::fromFile($hostLockPath); + $lockPath = $root.'/bundle.lock'; + $useLock = ! $update && is_file($lockPath); + $work = self::temporaryDirectory(); + + try { + self::writeJson($work.'/composer.json', self::project($root, $module, $composerJson, $requires, $host)); + + if ($useLock) { + $locked = self::json($lockPath, 'bundle.lock'); + self::assertLockFitsHost($locked, $host); + copy($lockPath, $work.'/composer.lock'); + $this->composer('install', $work, 'bundle.lock no longer fits this host or composer.json; run invoiceshelf-module bundle --update'); + self::assertLockIsNeeded($locked, $requires); + } else { + $this->composer('update', $work, 'Composer could not resolve the bundle against this host'); + copy($work.'/composer.lock', $lockPath); + } + + $installed = self::installedPackages($work.'/vendor'); + $warnings = self::checkPackages($installed, $module, $host); + $classmap = self::classmap($work.'/vendor', $installed); + $files = self::files($work.'/vendor', $installed); + $functions = []; + foreach ($files as $file) { + array_push($functions, ...BundleValidator::declaredFunctions($work.'/vendor/'.$file)); + } + sort($functions, SORT_STRING); + + $manifest = new BundleManifest( + $host->sha256, + self::packageEntries($installed), + self::hostPackages($composerJson['name'] ?? 'module', $requires, $installed, $host), + $files, + $functions, + ); + + self::writeBundle($root, $work.'/vendor', $installed, $classmap, $manifest); + } finally { + self::remove($work); + } + + BundleValidator::validate($root, $module, array_keys($requires)); + self::assertModuleLockMatches($root, $manifest); + + return new BundleReport($manifest, $warnings, ! $useLock); + } + + /** + * The throwaway Composer project the bundle is resolved in. + * + * @param array $composerJson + * @param array $requires + * @return array + */ + private static function project(string $root, ModuleManifest $module, array $composerJson, array $requires, HostLock $host): array + { + $project = [ + 'name' => 'invoiceshelf/bundle-'.$module->slug, + 'description' => 'Generated by invoiceshelf-module bundle; not a real package.', + 'type' => 'project', + 'require' => $requires, + 'config' => [ + 'vendor-dir' => 'vendor', + 'allow-plugins' => false, + 'classmap-authoritative' => true, + 'sort-packages' => true, + 'platform' => ['php' => self::phpFloor($module->compatibility->php)], + ], + 'minimum-stability' => $composerJson['minimum-stability'] ?? 'stable', + 'prefer-stable' => $composerJson['prefer-stable'] ?? true, + ]; + if ($host->replaces !== []) { + $project['replace'] = $host->replaces; + } + if ($host->provides !== []) { + $project['provide'] = $host->provides; + } + + $repositories = self::repositories($root, $composerJson['repositories'] ?? []); + if ($repositories !== []) { + $project['repositories'] = $repositories; + } + + return $project; + } + + /** + * The module's own repositories, minus the SDK's (the bundle never needs + * it), with path repositories made absolute and copied rather than linked. + * + * @return list + */ + private static function repositories(string $root, mixed $repositories): array + { + $result = []; + foreach (is_array($repositories) ? $repositories : [] as $key => $repository) { + if ($repository === false && is_string($key)) { + $result[] = [$key => false]; // e.g. "packagist.org": false + + continue; + } + if (! is_array($repository)) { + continue; + } + $url = $repository['url'] ?? null; + if (is_string($url) && preg_match('#github\.com[/:]InvoiceShelf/modules(?:\.git)?$#i', $url) === 1) { + continue; + } + if (($repository['type'] ?? null) === 'path' && is_string($url)) { + $repository['url'] = str_starts_with($url, '/') ? $url : $root.'/'.$url; + $repository['options'] = [...(array) ($repository['options'] ?? []), 'symlink' => false]; + } + $result[] = $repository; + } + + return $result; + } + + /** The lowest PHP the module supports, which the bundle must also run on. */ + private static function phpFloor(string $constraint): string + { + $lower = (new VersionParser)->parseConstraints($constraint)->getLowerBound()->getVersion(); + if (preg_match('/^(\d+)\.(\d+)\.(\d+)/', $lower, $matches) !== 1 || $matches[1] === '0') { + throw new InvalidArgumentException('compatibility.php in module.json needs a lower bound, such as ^8.4.0.'); + } + + return "{$matches[1]}.{$matches[2]}.{$matches[3]}"; + } + + private function composer(string $command, string $work, string $failure): void + { + [$exitCode, $output] = $this->composer->run([$command, ...self::COMPOSER_FLAGS], $work); + if ($exitCode !== 0) { + throw new RuntimeException("{$failure}.\n\n{$output}"); + } + } + + /** @param array $locked */ + private static function assertLockFitsHost(array $locked, HostLock $host): void + { + foreach ((array) ($locked['packages'] ?? []) as $package) { + $name = (string) ($package['name'] ?? ''); + if ($host->provides($name)) { + throw new RuntimeException("{$name} is in bundle.lock but this host already ships it; run invoiceshelf-module bundle --update."); + } + } + } + + /** + * Every locked package must still be needed by composer.json, directly or + * through another locked package; a stale entry would ship unused code. + * + * @param array $locked + * @param array $requires + */ + private static function assertLockIsNeeded(array $locked, array $requires): void + { + $graph = []; + foreach ((array) ($locked['packages'] ?? []) as $package) { + $graph[(string) ($package['name'] ?? '')] = array_keys((array) ($package['require'] ?? [])); + } + + $needed = []; + $queue = array_keys($requires); + while ($queue !== []) { + $name = array_shift($queue); + if (isset($needed[$name]) || ! isset($graph[$name])) { + continue; + } + $needed[$name] = true; + array_push($queue, ...$graph[$name]); + } + + $unused = array_diff(array_keys($graph), array_keys($needed)); + if ($unused !== []) { + throw new RuntimeException('bundle.lock lists packages composer.json no longer needs ('.implode(', ', $unused).'); run invoiceshelf-module bundle --update.'); + } + } + + /** @return array> */ + private static function installedPackages(string $vendor): array + { + $installed = self::json($vendor.'/composer/installed.json', 'the installed package list'); + $packages = []; + foreach ((array) ($installed['packages'] ?? $installed) as $package) { + if (is_array($package) && is_string($package['name'] ?? null)) { + $packages[$package['name']] = $package; + } + } + ksort($packages, SORT_STRING); + + return $packages; + } + + /** + * Refuse what the host cannot load safely; warn about what usually breaks. + * + * @param array> $installed + * @return list + */ + private static function checkPackages(array $installed, ModuleManifest $module, HostLock $host): array + { + $warnings = []; + foreach ($installed as $name => $package) { + if (($package['type'] ?? 'library') === 'composer-plugin') { + throw new RuntimeException("{$name} is a Composer plugin; plugins never run inside a module."); + } + $license = array_values(array_filter((array) ($package['license'] ?? []), 'is_string')); + if (! LicensePolicy::allows($license)) { + throw new RuntimeException("{$name} is licensed ".($license === [] ? 'without a declared license' : implode(' or ', $license)).', which an AGPL-3.0-only module cannot ship.'); + } + foreach (self::extensions($package) as $extension) { + if (! in_array($extension, $module->compatibility->extensions, true)) { + throw new RuntimeException("{$name} needs {$extension}; add it to compatibility.extensions in module.json."); + } + } + if ($host->hasDevPackage($name)) { + $warnings[] = "{$name} is a development dependency of the host, so a local host loads its own copy instead of the bundled one."; + } + if (isset($package['extra']['laravel']['providers'])) { + $warnings[] = "{$name} declares Laravel service providers; the host does not discover them, so register what you need from the module's provider."; + } + } + + return $warnings; + } + + /** + * @param array $package + * @return list + */ + private static function extensions(array $package): array + { + $extensions = array_values(array_filter( + array_keys((array) ($package['require'] ?? [])), + fn (string $name): bool => str_starts_with($name, 'ext-') && ! in_array($name, BundleManifest::CORE_EXTENSIONS, true), + )); + sort($extensions, SORT_STRING); + + return $extensions; + } + + /** + * Composer's authoritative classmap, rewritten relative to bundle/ and + * without Composer's own classes. + * + * @param array> $installed + * @return array + */ + private static function classmap(string $vendor, array $installed): array + { + $generated = require $vendor.'/composer/autoload_classmap.php'; + $prefix = realpath($vendor).'/'; + $classmap = []; + foreach ($generated as $class => $path) { + $path = str_replace('\\', '/', (string) $path); + if (str_starts_with($class, 'Composer\\') || ! str_starts_with($path, $prefix)) { + continue; + } + $relative = substr($path, strlen($prefix)); + $parts = explode('/', $relative); + $package = ($parts[0] ?? '').'/'.($parts[1] ?? ''); + if (! isset($installed[$package])) { + continue; + } + if (BundleValidator::isReservedClass($class)) { + throw new RuntimeException("{$package} defines {$class}, in a namespace reserved for InvoiceShelf, its modules, Composer or Laravel."); + } + $classmap[$class] = $relative; + } + ksort($classmap, SORT_STRING); + + return $classmap; + } + + /** + * Composer's `files` autoload entries in the order Composer would load + * them (dependencies first), relative to bundle/. The host loads them only + * for enabled modules. + * + * @param array> $installed + * @return list + */ + private static function files(string $vendor, array $installed): array + { + $generated = is_file($vendor.'/composer/autoload_files.php') ? require $vendor.'/composer/autoload_files.php' : []; + $prefix = realpath($vendor).'/'; + $files = []; + foreach ($generated as $path) { + $path = str_replace('\\', '/', (string) $path); + if (! str_starts_with($path, $prefix)) { + continue; + } + $relative = substr($path, strlen($prefix)); + $parts = explode('/', $relative); + if (isset($installed[($parts[0] ?? '').'/'.($parts[1] ?? '')])) { + $files[] = $relative; + } + } + + return $files; + } + + /** + * @param array> $installed + * @return array, php: string|null, extensions: list}> + */ + private static function packageEntries(array $installed): array + { + $entries = []; + foreach ($installed as $name => $package) { + $php = $package['require']['php'] ?? null; + $entries[$name] = [ + 'version' => (string) ($package['version'] ?? ''), + 'reference' => $package['dist']['reference'] ?? $package['source']['reference'] ?? null, + 'license' => array_values(array_filter((array) ($package['license'] ?? []), 'is_string')), + 'php' => is_string($php) ? $php : null, + 'extensions' => self::extensions($package), + ]; + } + + return $entries; + } + + /** + * Every host package the module or a bundled package requires, with each + * requirer's constraint kept separately. + * + * @param array $requires + * @param array> $installed + * @return array> + */ + private static function hostPackages(string $moduleName, array $requires, array $installed, HostLock $host): array + { + $requirers = [$moduleName => $requires]; + foreach ($installed as $name => $package) { + $requirers[$name] = (array) ($package['require'] ?? []); + } + + $hostPackages = []; + foreach ($requirers as $requirer => $requirements) { + foreach ($requirements as $name => $constraint) { + if (isset($installed[$name]) || ! PackageValidator::isThirdParty((string) $name) || ! $host->provides((string) $name)) { + continue; + } + $hostPackages[$name][$requirer] = (string) $constraint; + } + } + + return $hostPackages; + } + + /** + * Replace bundle/ with the package files, the classmap and bundle.json. + * + * @param array> $installed + * @param array $classmap + */ + private static function writeBundle(string $root, string $vendor, array $installed, array $classmap, BundleManifest $manifest): void + { + $bundle = $root.'/'.BundleManifest::DIRECTORY; + if (is_link($bundle)) { + throw new RuntimeException('bundle/ is a symbolic link; remove it and build again.'); + } + self::remove($bundle); + mkdir($bundle, 0755, true); + + foreach (array_keys($installed) as $package) { + self::copyTree($vendor.'/'.$package, $bundle.'/'.$package, $package); + } + + $lines = [" $path) { + $lines[] = ' '.var_export($class, true).' => '.var_export($path, true).",\n"; + } + $lines[] = "];\n"; + file_put_contents($bundle.'/'.BundleManifest::CLASSMAP, implode('', $lines)); + file_put_contents($bundle.'/'.BundleManifest::FILE, $manifest->toJson()); + self::normalizePermissions($bundle); + } + + private static function copyTree(string $source, string $target, string $package): void + { + if (! is_dir($source)) { + throw new RuntimeException("Composer did not install {$package}."); + } + mkdir($target, 0755, true); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + /** @var SplFileInfo $entry */ + foreach ($iterator as $entry) { + $relative = substr($entry->getPathname(), strlen($source) + 1); + if ($entry->isLink()) { + throw new RuntimeException("{$package} contains a symbolic link ({$relative}); bundles must contain plain files."); + } + $destination = $target.'/'.$relative; + if ($entry->isDir()) { + mkdir($destination, 0755, true); + + continue; + } + copy($entry->getPathname(), $destination); + } + } + + /** The same bytes and modes on every machine, whatever its umask. */ + private static function normalizePermissions(string $bundle): void + { + chmod($bundle, 0755); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($bundle, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($iterator as $entry) { + chmod($entry->getPathname(), $entry->isDir() ? 0755 : 0644); + } + } + + /** + * Tests run against vendor/ and the archive ships bundle/, so the two + * must hold the same versions. + */ + private static function assertModuleLockMatches(string $root, BundleManifest $manifest): void + { + if (! is_file($root.'/composer.lock')) { + return; + } + + $lock = self::json($root.'/composer.lock', 'composer.lock'); + foreach ([...(array) ($lock['packages'] ?? []), ...(array) ($lock['packages-dev'] ?? [])] as $package) { + $name = $package['name'] ?? null; + if (is_string($name) && isset($manifest->packages[$name]) && ($package['version'] ?? null) !== $manifest->packages[$name]['version']) { + throw new RuntimeException("composer.lock has {$name} {$package['version']} but bundle/ ships {$manifest->packages[$name]['version']}; align them (for example composer update {$name}) so tests run on what ships."); + } + } + } + + /** @return array */ + private static function json(string $path, string $name): array + { + $contents = is_file($path) ? file_get_contents($path) : false; + if ($contents === false) { + throw new InvalidArgumentException("Cannot read {$name} ({$path})."); + } + + try { + $value = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidArgumentException("{$name} is not valid JSON: {$exception->getMessage()}", previous: $exception); + } + + return is_array($value) ? $value : throw new InvalidArgumentException("{$name} must contain a JSON object."); + } + + /** @param array $value */ + private static function writeJson(string $path, array $value): void + { + file_put_contents($path, json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n"); + } + + private static function temporaryDirectory(): string + { + $directory = sys_get_temp_dir().'/invoiceshelf-bundle-'.bin2hex(random_bytes(8)); + mkdir($directory, 0700, true); + + return $directory; + } + + private static function remove(string $path): void + { + if (is_link($path) || is_file($path)) { + unlink($path); + + return; + } + if (! is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + $entry->isDir() && ! $entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($path); + } +} diff --git a/src/Bundle/BundleReport.php b/src/Bundle/BundleReport.php new file mode 100644 index 0000000..84ca00e --- /dev/null +++ b/src/Bundle/BundleReport.php @@ -0,0 +1,18 @@ + $warnings */ + public function __construct( + public BundleManifest $manifest, + public array $warnings, + public bool $lockWritten, + ) {} +} diff --git a/src/Bundle/ComposerProcess.php b/src/Bundle/ComposerProcess.php new file mode 100644 index 0000000..2319363 --- /dev/null +++ b/src/Bundle/ComposerProcess.php @@ -0,0 +1,64 @@ + $command */ + private function __construct(private array $command) {} + + /** + * The explicit --composer path wins, then COMPOSER_BINARY (set by Composer + * for scripts), then `composer` on the PATH. A .phar runs through this PHP. + */ + public static function locate(?string $binary = null): self + { + $binary ??= getenv('COMPOSER_BINARY') ?: 'composer'; + + return new self(str_ends_with($binary, '.phar') ? [PHP_BINARY, $binary] : [$binary]); + } + + /** + * @param list $arguments + * @return array{int, string} exit code and combined output + */ + public function run(array $arguments, string $directory): array + { + $environment = getenv(); + // A parent Composer run can point these at its own project. + unset($environment['COMPOSER'], $environment['COMPOSER_VENDOR_DIR'], $environment['COMPOSER_BIN_DIR']); + $environment['COMPOSER_NO_INTERACTION'] = '1'; + // The release workflow audits separately; a bundle build should not depend on the advisory API. + $environment['COMPOSER_NO_AUDIT'] = '1'; + + $pipes = []; + $process = proc_open( + [...$this->command, ...$arguments], + // One pipe for both streams, so a chatty stderr cannot fill its buffer and stall the run. + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['redirect', 1]], + $pipes, + $directory, + $environment, + ); + if (! is_resource($process)) { + throw new RuntimeException('Cannot start Composer ('.implode(' ', $this->command).').'); + } + + fclose($pipes[0]); + $output = (string) stream_get_contents($pipes[1]); + fclose($pipes[1]); + + return [proc_close($process), trim($output)]; + } +} diff --git a/src/Bundle/HostLock.php b/src/Bundle/HostLock.php new file mode 100644 index 0000000..b7db4ca --- /dev/null +++ b/src/Bundle/HostLock.php @@ -0,0 +1,96 @@ + $packages production package => locked version + * @param array $replaces package name => constraint, including what each package itself replaces + * @param array $provides virtual package name => constraint, from the packages' own `provide` + * @param list $devPackages + */ + public function __construct( + public string $sha256, + public array $packages, + public array $replaces, + public array $provides, + public array $devPackages, + ) {} + + public static function fromFile(string $path): self + { + $contents = is_file($path) ? file_get_contents($path) : false; + if ($contents === false) { + throw new InvalidArgumentException("Cannot read the host composer.lock at {$path}."); + } + + try { + $lock = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidArgumentException("The host composer.lock at {$path} is not valid JSON.", previous: $exception); + } + if (! is_array($lock) || ! is_array($lock['packages'] ?? null)) { + throw new InvalidArgumentException("{$path} is not a composer.lock: it has no packages list."); + } + + $packages = []; + $replaces = []; + $provides = []; + foreach ($lock['packages'] as $package) { + $name = $package['name'] ?? null; + $version = $package['version'] ?? null; + if (! is_string($name) || ! is_string($version)) { + continue; + } + $packages[$name] = $version; + $replaces[$name] = $version; + foreach ((array) ($package['replace'] ?? []) as $replaced => $constraint) { + $replaces[$replaced] ??= $constraint === 'self.version' ? $version : (string) $constraint; + } + foreach ((array) ($package['provide'] ?? []) as $provided => $constraint) { + $provides[$provided] ??= $constraint === 'self.version' ? $version : (string) $constraint; + } + } + + $devPackages = []; + foreach ((array) ($lock['packages-dev'] ?? []) as $package) { + if (is_string($package['name'] ?? null) && ! isset($packages[$package['name']])) { + $devPackages[] = $package['name']; + } + } + + ksort($packages, SORT_STRING); + ksort($replaces, SORT_STRING); + ksort($provides, SORT_STRING); + sort($devPackages, SORT_STRING); + + return new self(hash('sha256', $contents), $packages, $replaces, $provides, $devPackages); + } + + /** Whether a production install of this host already has the package (installed, replaced or provided). */ + public function provides(string $name): bool + { + return isset($this->replaces[$name]) || isset($this->provides[$name]); + } + + public function hasDevPackage(string $name): bool + { + return in_array($name, $this->devPackages, true); + } +} diff --git a/src/Manifest/BundleManifest.php b/src/Manifest/BundleManifest.php new file mode 100644 index 0000000..32a2f7a --- /dev/null +++ b/src/Manifest/BundleManifest.php @@ -0,0 +1,297 @@ +, php: string|null, extensions: list}> $packages + * @param array> $hostPackages + * @param list $files Composer `files` autoload entries, relative to bundle/, in load order + * @param list $functions functions those files declare unconditionally, fully qualified + */ + public function __construct( + public string $lockSha256, + public array $packages, + public array $hostPackages, + public array $files = [], + public array $functions = [], + ) {} + + /** @param array $value */ + public static function fromArray(array $value): self + { + self::rejectUnknownKeys($value, ['schema', 'built_against', 'packages', 'host_packages', 'files', 'functions'], 'bundle.json'); + + if (($value['schema'] ?? null) !== self::SCHEMA) { + throw new InvalidArgumentException('bundle.json must declare schema 1.'); + } + + $builtAgainst = $value['built_against'] ?? null; + if (! is_array($builtAgainst) || array_is_list($builtAgainst)) { + throw new InvalidArgumentException("bundle.json must declare a 'built_against' object."); + } + self::rejectUnknownKeys($builtAgainst, ['lock_sha256'], 'bundle.json built_against'); + $lockSha256 = $builtAgainst['lock_sha256'] ?? null; + if (! is_string($lockSha256) || preg_match('/^[a-f0-9]{64}$/', $lockSha256) !== 1) { + throw new InvalidArgumentException('bundle.json built_against.lock_sha256 must be a lowercase sha256.'); + } + + $packages = self::packages($value['packages'] ?? null); + $hostPackages = self::hostPackages($value['host_packages'] ?? null, $packages); + $files = self::files($value['files'] ?? [], $packages); + $functions = self::functions($value['functions'] ?? []); + + return new self($lockSha256, $packages, $hostPackages, $files, $functions); + } + + public static function fromFile(string $path): self + { + $contents = is_file($path) ? file_get_contents($path) : false; + if ($contents === false) { + throw new InvalidArgumentException('Bundled module must contain bundle/bundle.json; run invoiceshelf-module bundle.'); + } + + try { + $value = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidArgumentException('bundle/bundle.json is not valid JSON: '.$exception->getMessage(), previous: $exception); + } + if (! is_array($value) || array_is_list($value)) { + throw new InvalidArgumentException('bundle/bundle.json must contain a JSON object.'); + } + + return self::fromArray($value); + } + + public static function isPackageName(mixed $name): bool + { + return is_string($name) && preg_match(self::PACKAGE_NAME, $name) === 1; + } + + public static function isConstraint(mixed $constraint): bool + { + if (! is_string($constraint) || trim($constraint) === '') { + return false; + } + + try { + (new VersionParser)->parseConstraints($constraint); + } catch (UnexpectedValueException) { + return false; + } + + return true; + } + + /** @return list */ + public function extensions(): array + { + $extensions = []; + foreach ($this->packages as $package) { + array_push($extensions, ...$package['extensions']); + } + $extensions = array_values(array_unique($extensions)); + sort($extensions, SORT_STRING); + + return $extensions; + } + + /** @return array */ + public function toArray(): array + { + $packages = $this->packages; + ksort($packages, SORT_STRING); + $hostPackages = []; + foreach ($this->hostPackages as $name => $requirers) { + ksort($requirers, SORT_STRING); + $hostPackages[$name] = $requirers; + } + ksort($hostPackages, SORT_STRING); + + return [ + 'schema' => self::SCHEMA, + 'built_against' => ['lock_sha256' => $this->lockSha256], + 'packages' => $packages === [] ? new stdClass : $packages, + 'host_packages' => $hostPackages === [] ? new stdClass : $hostPackages, + 'files' => $this->files, + 'functions' => $this->functions, + ]; + } + + /** Stable, reviewable JSON: the same bundle always writes the same bytes. */ + public function toJson(): string + { + return json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)."\n"; + } + + /** @return array, php: string|null, extensions: list}> */ + private static function packages(mixed $value): array + { + // Empty is allowed: a module whose requirements the host already ships + // still records them in host_packages, and the host checks them. + if (! is_array($value) || (array_is_list($value) && $value !== [])) { + throw new InvalidArgumentException("bundle.json 'packages' must be an object."); + } + + $packages = []; + foreach ($value as $name => $package) { + if (! self::isPackageName($name)) { + throw new InvalidArgumentException("Bundled package name '{$name}' is not a valid Composer package name."); + } + if (str_starts_with($name, 'invoiceshelf/')) { + throw new InvalidArgumentException("Bundled package '{$name}' is reserved for InvoiceShelf; the host provides it."); + } + if (! is_array($package) || array_is_list($package)) { + throw new InvalidArgumentException("Bundled package '{$name}' must be an object."); + } + self::rejectUnknownKeys($package, ['version', 'reference', 'license', 'php', 'extensions'], "bundled package '{$name}'"); + + $version = $package['version'] ?? null; + if (! is_string($version) || $version === '') { + throw new InvalidArgumentException("Bundled package '{$name}' must declare its version."); + } + $reference = $package['reference'] ?? null; + if ($reference !== null && ! is_string($reference)) { + throw new InvalidArgumentException("Bundled package '{$name}' reference must be a string or null."); + } + $license = $package['license'] ?? null; + if (! is_array($license) || ! array_is_list($license) || $license === [] + || array_filter($license, fn (mixed $item): bool => ! is_string($item)) !== []) { + throw new InvalidArgumentException("Bundled package '{$name}' must declare its license."); + } + if (! LicensePolicy::allows($license)) { + throw new InvalidArgumentException("Bundled package '{$name}' is licensed ".implode(' or ', $license).', which an AGPL-3.0-only module cannot ship.'); + } + $php = $package['php'] ?? null; + if ($php !== null && ! self::isConstraint($php)) { + throw new InvalidArgumentException("Bundled package '{$name}' php requirement must be a Composer constraint or null."); + } + $extensions = $package['extensions'] ?? null; + if (! is_array($extensions) || ! array_is_list($extensions) + || array_filter($extensions, fn (mixed $item): bool => ! is_string($item) || preg_match('/^ext-[a-z0-9][a-z0-9_-]*$/', $item) !== 1) !== []) { + throw new InvalidArgumentException("Bundled package '{$name}' extensions must be a list of ext-name entries."); + } + + $packages[$name] = [ + 'version' => $version, + 'reference' => $reference, + 'license' => $license, + 'php' => $php, + 'extensions' => $extensions, + ]; + } + + return $packages; + } + + /** + * @param array $packages + * @return array> + */ + private static function hostPackages(mixed $value, array $packages): array + { + if (! is_array($value) || (array_is_list($value) && $value !== [])) { + throw new InvalidArgumentException("bundle.json 'host_packages' must be an object."); + } + + $hostPackages = []; + foreach ($value as $name => $requirers) { + if (! self::isPackageName($name)) { + throw new InvalidArgumentException("Host package name '{$name}' is not a valid Composer package name."); + } + if (isset($packages[$name])) { + throw new InvalidArgumentException("'{$name}' cannot be both bundled and provided by the host."); + } + if (! is_array($requirers) || array_is_list($requirers)) { + throw new InvalidArgumentException("Host package '{$name}' must map each requirer to its constraint."); + } + foreach ($requirers as $requirer => $constraint) { + if (! self::isPackageName($requirer) || ! self::isConstraint($constraint)) { + throw new InvalidArgumentException("Host package '{$name}' has an invalid requirement from '{$requirer}'."); + } + } + $hostPackages[$name] = $requirers; + } + + return $hostPackages; + } + + /** + * @param array $packages + * @return list + */ + private static function files(mixed $value, array $packages): array + { + if (! is_array($value) || ! array_is_list($value)) { + throw new InvalidArgumentException("bundle.json 'files' must be a list."); + } + foreach ($value as $file) { + $parts = is_string($file) ? explode('/', $file) : []; + if (count($parts) < 3 || in_array('..', $parts, true) || in_array('', $parts, true) || ! str_ends_with((string) $file, '.php') + || ! isset($packages[$parts[0].'/'.$parts[1]])) { + throw new InvalidArgumentException('bundle.json files must be PHP files inside bundled packages.'); + } + } + if (count(array_unique($value)) !== count($value)) { + throw new InvalidArgumentException('bundle.json files must not repeat.'); + } + + return $value; + } + + /** @return list */ + private static function functions(mixed $value): array + { + if (! is_array($value) || ! array_is_list($value) + || array_filter($value, fn (mixed $name): bool => ! is_string($name) || preg_match('/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(?:\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*$/', $name) !== 1) !== []) { + throw new InvalidArgumentException("bundle.json 'functions' must be a list of function names."); + } + + return $value; + } + + /** @param array $value @param list $allowed */ + private static function rejectUnknownKeys(array $value, array $allowed, string $context): void + { + $unknown = array_diff(array_keys($value), $allowed); + if ($unknown !== []) { + throw new InvalidArgumentException("{$context} contains unsupported field '".reset($unknown)."'."); + } + } +} diff --git a/src/Manifest/BundleValidator.php b/src/Manifest/BundleValidator.php new file mode 100644 index 0000000..0d6a3e1 --- /dev/null +++ b/src/Manifest/BundleValidator.php @@ -0,0 +1,246 @@ + $requires third-party packages the module's composer.json requires + */ + public static function validate(string $root, ModuleManifest $module, array $requires): BundleManifest + { + $bundle = $root.DIRECTORY_SEPARATOR.BundleManifest::DIRECTORY; + if (! is_dir($bundle) || is_link($bundle)) { + throw new InvalidArgumentException('Bundled module must contain a bundle/ directory; run invoiceshelf-module bundle.'); + } + + $manifest = BundleManifest::fromFile($bundle.DIRECTORY_SEPARATOR.BundleManifest::FILE); + + self::validateTree($bundle, $manifest); + self::validateClassmap($bundle, $manifest); + self::validateFiles($bundle, $manifest); + + foreach ($requires as $package) { + if (! isset($manifest->packages[$package]) && ! isset($manifest->hostPackages[$package])) { + throw new InvalidArgumentException("composer.json requires '{$package}', but bundle/ neither ships it nor records it as a host package; run invoiceshelf-module bundle."); + } + } + + $missing = array_diff($manifest->extensions(), $module->compatibility->extensions, BundleManifest::CORE_EXTENSIONS); + if ($missing !== []) { + throw new InvalidArgumentException('Bundled packages need '.implode(', ', $missing).'; add them to compatibility.extensions in module.json.'); + } + + return $manifest; + } + + /** + * Read bundle/classmap.php as data: a single `return [...]` of string pairs. + * + * @return array + */ + public static function classmap(string $bundle): array + { + $path = $bundle.DIRECTORY_SEPARATOR.BundleManifest::CLASSMAP; + $source = is_file($path) ? file_get_contents($path) : false; + if ($source === false) { + throw new InvalidArgumentException('Bundled module must contain bundle/classmap.php; run invoiceshelf-module bundle.'); + } + + try { + $statements = (new ParserFactory)->createForHostVersion()->parse($source) ?? []; + } catch (Error $exception) { + throw new InvalidArgumentException('bundle/classmap.php cannot be parsed: '.$exception->getMessage(), previous: $exception); + } + + if (count($statements) !== 1 || ! $statements[0] instanceof Return_ || ! $statements[0]->expr instanceof Array_) { + throw new InvalidArgumentException('bundle/classmap.php must contain only a returned array.'); + } + + $classmap = []; + foreach ($statements[0]->expr->items as $item) { + if (! $item instanceof ArrayItem || ! $item->key instanceof String_ || ! $item->value instanceof String_ || $item->byRef || $item->unpack) { + throw new InvalidArgumentException('bundle/classmap.php must map class names to relative paths as plain strings.'); + } + $classmap[$item->key->value] = $item->value->value; + } + + return $classmap; + } + + /** + * Functions a file declares unconditionally, fully qualified. + * + * Declarations inside an `if` (the usual `if (! function_exists(...))` + * guard) are left out: they cannot collide fatally. + * + * @return list + */ + public static function declaredFunctions(string $path): array + { + $source = is_file($path) ? file_get_contents($path) : false; + if ($source === false) { + throw new InvalidArgumentException("Cannot read bundled file {$path}."); + } + + try { + $statements = (new ParserFactory)->createForHostVersion()->parse($source) ?? []; + } catch (Error $exception) { + throw new InvalidArgumentException("Bundled file {$path} cannot be parsed: ".$exception->getMessage(), previous: $exception); + } + + $functions = []; + $collect = function (array $statements, string $namespace) use (&$functions): void { + foreach ($statements as $statement) { + if ($statement instanceof Function_) { + $functions[] = ltrim($namespace.'\\'.$statement->name->toString(), '\\'); + } + } + }; + foreach ($statements as $statement) { + if ($statement instanceof Namespace_) { + $collect($statement->stmts, $statement->name?->toString() ?? ''); + } + } + $collect(array_filter($statements, fn (Stmt $statement): bool => ! $statement instanceof Namespace_), ''); + + return $functions; + } + + public static function isReservedClass(string $class): bool + { + foreach (self::RESERVED_NAMESPACES as $prefix) { + if (stripos($class, $prefix) === 0) { + return true; + } + } + + return false; + } + + private static function validateTree(string $bundle, BundleManifest $manifest): void + { + $files = 0; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($bundle, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + /** @var SplFileInfo $entry */ + foreach ($iterator as $entry) { + $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($entry->getPathname(), strlen($bundle) + 1)); + if ($entry->isLink()) { + throw new InvalidArgumentException("bundle/{$relative} is a symbolic link; bundles must contain plain files."); + } + + $parts = explode('/', $relative); + if (count($parts) === 1) { + if (in_array($relative, [BundleManifest::FILE, BundleManifest::CLASSMAP], true)) { + $files++; + + continue; + } + if ($relative === 'autoload.php') { + throw new InvalidArgumentException("bundle/autoload.php is Composer's own autoloader, which the host never loads; rebuild with invoiceshelf-module bundle."); + } + if (! $entry->isDir()) { + throw new InvalidArgumentException("bundle/{$relative} is not part of a bundled package."); + } + + continue; + } + + $package = $parts[0].'/'.$parts[1]; + if (! isset($manifest->packages[$package])) { + throw new InvalidArgumentException("bundle/{$package} is not a package declared in bundle.json."); + } + if ($entry->isFile() && ++$files > self::MAX_FILES) { + throw new InvalidArgumentException('bundle/ holds more than '.self::MAX_FILES.' files; the marketplace accepts at most 10,000 entries per module.'); + } + } + + foreach (array_keys($manifest->packages) as $package) { + if (! is_dir($bundle.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $package))) { + throw new InvalidArgumentException("bundle.json declares '{$package}', but bundle/{$package} is missing."); + } + } + } + + private static function validateFiles(string $bundle, BundleManifest $manifest): void + { + $functions = []; + foreach ($manifest->files as $file) { + $path = $bundle.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $file); + if (! is_file($path)) { + throw new InvalidArgumentException("bundle.json lists bundle/{$file}, which does not exist."); + } + array_push($functions, ...self::declaredFunctions($path)); + } + foreach ($functions as $function) { + if (self::isReservedClass($function)) { + throw new InvalidArgumentException("Bundled function '{$function}' uses a namespace reserved for InvoiceShelf, its modules, Composer or Laravel."); + } + } + + $declared = $manifest->functions; + sort($functions, SORT_STRING); + sort($declared, SORT_STRING); + if ($functions !== $declared) { + throw new InvalidArgumentException('bundle.json functions do not match what the bundled files declare; rebuild with invoiceshelf-module bundle.'); + } + } + + private static function validateClassmap(string $bundle, BundleManifest $manifest): void + { + foreach (self::classmap($bundle) as $class => $path) { + if (preg_match('/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(?:\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*$/', $class) !== 1) { + throw new InvalidArgumentException("bundle/classmap.php lists an invalid class name '{$class}'."); + } + if (self::isReservedClass($class)) { + throw new InvalidArgumentException("Bundled class '{$class}' uses a namespace reserved for InvoiceShelf, its modules, Composer or Laravel."); + } + + $parts = explode('/', $path); + if (str_starts_with($path, '/') || str_contains($path, '\\') || in_array('..', $parts, true) || in_array('', $parts, true) + || ! str_ends_with($path, '.php') || count($parts) < 3 || ! isset($manifest->packages[$parts[0].'/'.$parts[1]])) { + throw new InvalidArgumentException("bundle/classmap.php maps '{$class}' to '{$path}', which is not a PHP file inside a bundled package."); + } + if (! is_file($bundle.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $path))) { + throw new InvalidArgumentException("bundle/classmap.php maps '{$class}' to bundle/{$path}, which does not exist."); + } + } + } +} diff --git a/src/Manifest/CleanupValidator.php b/src/Manifest/CleanupValidator.php index 7783768..ee2feec 100644 --- a/src/Manifest/CleanupValidator.php +++ b/src/Manifest/CleanupValidator.php @@ -111,6 +111,12 @@ private static function isExcluded(string $path, string $directory): bool { $relative = substr($path, strlen(rtrim($directory, DIRECTORY_SEPARATOR)) + 1); - return $relative !== false && preg_match('#(?:^|/)(?:vendor|node_modules|\.git)(?:/|$)#', str_replace(DIRECTORY_SEPARATOR, '/', $relative)) === 1; + if ($relative === false) { + return false; + } + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + + // bundle/ holds third-party code, and only at the module root. + return str_starts_with($relative, 'bundle/') || preg_match('#(?:^|/)(?:vendor|node_modules|\.git)(?:/|$)#', $relative) === 1; } } diff --git a/src/Manifest/LicensePolicy.php b/src/Manifest/LicensePolicy.php new file mode 100644 index 0000000..7f9f197 --- /dev/null +++ b/src/Manifest/LicensePolicy.php @@ -0,0 +1,60 @@ + $licenses */ + public static function allows(array $licenses): bool + { + foreach ($licenses as $license) { + if (self::expressionAllowed($license)) { + return true; + } + } + + return false; + } + + /** @return list */ + public static function allowed(): array + { + return self::ALLOWED; + } + + private static function expressionAllowed(string $expression): bool + { + $expression = trim(str_replace(['(', ')'], ' ', $expression)); + if ($expression === '') { + return false; + } + + foreach (preg_split('/\s+or\s+/i', $expression) ?: [] as $alternative) { + $parts = preg_split('/\s+and\s+/i', trim($alternative)) ?: []; + if ($parts !== [] && array_filter($parts, fn (string $part): bool => ! in_array(trim($part), self::ALLOWED, true)) === []) { + return true; + } + } + + return false; + } +} diff --git a/src/Manifest/ModuleManifest.php b/src/Manifest/ModuleManifest.php index 60437cc..1796aba 100644 --- a/src/Manifest/ModuleManifest.php +++ b/src/Manifest/ModuleManifest.php @@ -4,7 +4,10 @@ namespace InvoiceShelf\Modules\Manifest; +use Composer\Semver\Comparator; +use Composer\Semver\VersionParser; use InvalidArgumentException; +use UnexpectedValueException; /** * Versioned extension of nwidart's module.json loader descriptor. @@ -15,6 +18,15 @@ public const LEGACY_SCHEMA_VERSION = 1; + /** The module may require only PHP, extensions and the SDK; everything else must already be in the host. */ + public const POLICY_HOST_PROVIDED = 'host-provided-only'; + + /** The module ships the third-party packages the host lacks in bundle/, built at release time. */ + public const POLICY_BUNDLED = 'bundled'; + + /** The first host module API that loads bundle/ and checks it against the installed packages. */ + public const BUNDLED_MODULE_API = '1.4.0'; + /** * @param list $providers * @param array $moduleDependencies @@ -104,9 +116,7 @@ public static function fromArray(array $value): self throw new InvalidArgumentException("Module manifest schema_version=2 requires migration_policy 'reversible'."); } - if (($value['dependency_policy'] ?? null) !== 'host-provided-only') { - throw new InvalidArgumentException("Only dependency_policy 'host-provided-only' is supported; runtime Composer and Node dependencies are forbidden."); - } + $dependencyPolicy = self::dependencyPolicy($value['dependency_policy'] ?? null, $schemaVersion, $compatibility); return new self( $slug, @@ -124,12 +134,17 @@ public static function fromArray(array $value): self $compatibility, $dependencies, $migrationPolicy, - 'host-provided-only', + $dependencyPolicy, self::assets($value['assets'] ?? null), self::uninstall($value, $moduleName, $schemaVersion), ); } + public function bundlesDependencies(): bool + { + return $this->dependencyPolicy === self::POLICY_BUNDLED; + } + /** @return array */ public function toArray(): array { @@ -249,6 +264,46 @@ private static function dependencies(mixed $value, string $ownSlug): array return $value; } + /** + * A bundled module needs schema 2 and a host that knows how to load bundle/, + * so its module_api range must start at 1.4.0: an older host then refuses it + * at the compatibility check, before anything is downloaded. + */ + private static function dependencyPolicy(mixed $value, int $schemaVersion, Compatibility $compatibility): string + { + if ($value === self::POLICY_HOST_PROVIDED) { + return $value; + } + + if ($value !== self::POLICY_BUNDLED) { + throw new InvalidArgumentException("dependency_policy must be 'host-provided-only' or 'bundled'; runtime Composer and Node dependencies are forbidden."); + } + + if ($schemaVersion !== self::SCHEMA_VERSION) { + throw new InvalidArgumentException("dependency_policy 'bundled' requires schema_version=2."); + } + + if (! self::startsAtOrAbove($compatibility->moduleApi, self::BUNDLED_MODULE_API)) { + throw new InvalidArgumentException("dependency_policy 'bundled' requires compatibility.module_api to start at ".self::BUNDLED_MODULE_API.' or later.'); + } + + return $value; + } + + private static function startsAtOrAbove(string $constraint, string $floor): bool + { + $parser = new VersionParser; + + try { + $lower = $parser->parseConstraints($constraint)->getLowerBound(); + } catch (UnexpectedValueException) { + return false; + } + + // Composer gives an inclusive stable lower bound as "x.y.z.0-dev". + return Comparator::greaterThanOrEqualTo($lower->getVersion(), $parser->normalize($floor).'-dev'); + } + /** @return list */ private static function assets(mixed $value): array { diff --git a/src/Manifest/PackageValidator.php b/src/Manifest/PackageValidator.php index 754e56b..4c9d319 100644 --- a/src/Manifest/PackageValidator.php +++ b/src/Manifest/PackageValidator.php @@ -30,7 +30,13 @@ public static function validate(string $directory): ModuleManifest } } - self::validateComposer($root.'/composer.json', $module->slug); + $requires = self::validateComposer($root.'/composer.json', $module); + + if ($module->bundlesDependencies()) { + BundleValidator::validate($root, $module, $requires); + } elseif (file_exists($root.'/'.BundleManifest::DIRECTORY)) { + throw new InvalidArgumentException("Module packages with dependency_policy 'host-provided-only' must not contain bundle/."); + } if ($module->uninstall !== null) { CleanupValidator::validateDirectory($root, $module->uninstall->dataCleanup); @@ -60,8 +66,15 @@ private static function jsonFile(string $path, string $name): array return $decoded; } - private static function validateComposer(string $path, string $slug): void + /** + * Check composer.json and return the third-party packages it requires, + * which only a bundled module may have. + * + * @return list + */ + private static function validateComposer(string $path, ModuleManifest $module): array { + $slug = $module->slug; $composer = self::jsonFile($path, 'composer.json'); if (($composer['name'] ?? null) !== "invoiceshelf/module-{$slug}") { throw new InvalidArgumentException("composer.json name must be invoiceshelf/module-{$slug}."); @@ -73,7 +86,16 @@ private static function validateComposer(string $path, string $slug): void throw new InvalidArgumentException('composer.json must declare a require object.'); } + $requires = []; foreach ($composer['require'] as $package => $constraint) { + if ($module->bundlesDependencies() && is_string($package) && is_string($constraint) && self::isThirdParty($package)) { + if (! BundleManifest::isConstraint($constraint)) { + throw new InvalidArgumentException("composer.json dependency '{$package}' must use a valid Composer constraint."); + } + $requires[] = $package; + + continue; + } if (! is_string($package) || ! is_string($constraint) || (! in_array($package, ['php', 'invoiceshelf/modules'], true) && ! str_starts_with($package, 'ext-'))) { throw new InvalidArgumentException("composer.json dependency '{$package}' is not host-provided."); @@ -83,5 +105,35 @@ private static function validateComposer(string $path, string $slug): void throw new InvalidArgumentException("composer.json dependency '{$package}' must use a supported SemVer constraint."); } } + + return $requires; + } + + /** + * The third-party packages a composer.json `require` object names. + * + * @param array $require + * @return array + */ + public static function thirdPartyRequires(array $require): array + { + $packages = []; + foreach ($require as $package => $constraint) { + if (is_string($package) && is_string($constraint) && self::isThirdParty($package)) { + $packages[$package] = $constraint; + } + } + + return $packages; + } + + /** + * A package a bundled module may ship: a real Composer package that is not + * a platform requirement, the SDK, or another InvoiceShelf module (module + * dependencies go in module.json's module_dependencies). + */ + public static function isThirdParty(string $package): bool + { + return BundleManifest::isPackageName($package) && ! str_starts_with($package, 'invoiceshelf/'); } } diff --git a/tests/BinaryTest.php b/tests/BinaryTest.php index a3d1c29..ed20f5e 100644 --- a/tests/BinaryTest.php +++ b/tests/BinaryTest.php @@ -67,6 +67,35 @@ public function test_composer_proxy_binary_uses_the_consuming_projects_autoloade } } + public function test_bundle_requires_a_host_lock_and_reports_failures_on_stderr(): void + { + [$usageCode, , $usageError] = $this->runBinary(['bundle', '.']); + $this->assertSame(64, $usageCode); + $this->assertStringContainsString('--host-lock=', $usageError); + + [$failureCode, $output, $error] = $this->runBinary(['bundle', sys_get_temp_dir(), '--host-lock=/nonexistent/composer.lock']); + $this->assertSame(1, $failureCode); + $this->assertSame('', $output); + $this->assertStringStartsWith('Bundle failed: ', $error); + } + + /** + * @param list $arguments + * @return array{int, string, string} + */ + private function runBinary(array $arguments): array + { + $pipes = []; + $process = proc_open([PHP_BINARY, dirname(__DIR__).'/bin/invoiceshelf-module', ...$arguments], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $this->assertIsResource($process); + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $stdout, $stderr]; + } + /** @return array */ private function moduleManifest(): array { diff --git a/tests/BundleBuilderTest.php b/tests/BundleBuilderTest.php new file mode 100644 index 0000000..390392b --- /dev/null +++ b/tests/BundleBuilderTest.php @@ -0,0 +1,268 @@ +directory = sys_get_temp_dir().'/invoiceshelf-bundle-test-'.bin2hex(random_bytes(8)); + $this->module = $this->directory.'/Module'; + mkdir($this->module, 0700, true); + + [$exitCode] = ComposerProcess::locate()->run(['--version'], $this->directory); + if ($exitCode !== 0) { + $this->markTestSkipped('The composer binary is not available.'); + } + } + + protected function tearDown(): void + { + $this->remove($this->directory); + + parent::tearDown(); + } + + public function test_only_packages_the_host_lacks_are_bundled(): void + { + $this->module(['sample/greeter' => '^1.0']); + + $report = $this->build($this->hostLock(['sample/strings' => '1.0.0'])); + + $this->assertSame(['sample/greeter'], array_keys($report->manifest->packages)); + $this->assertSame(['sample/strings' => ['sample/greeter' => '^1.0']], $report->manifest->hostPackages); + $this->assertSame(['ext-mbstring'], $report->manifest->packages['sample/greeter']['extensions']); + $this->assertTrue($report->lockWritten); + $this->assertFileExists($this->module.'/bundle.lock'); + $this->assertDirectoryDoesNotExist($this->module.'/bundle/sample/strings'); + $this->assertDirectoryDoesNotExist($this->module.'/bundle/composer'); + $this->assertFileDoesNotExist($this->module.'/bundle/autoload.php'); + + $classmap = require $this->module.'/bundle/classmap.php'; + $this->assertSame(['Sample\\Greeter\\Greeter' => 'sample/greeter/src/Greeter.php'], $classmap); + } + + public function test_a_rebuild_from_bundle_lock_writes_the_same_bytes(): void + { + $this->module(['sample/greeter' => '^1.0']); + $host = $this->hostLock([]); + + $first = $this->build($host); + $tree = $this->tree($this->module.'/bundle'); + $second = $this->build($host); + + $this->assertTrue($first->lockWritten); + $this->assertFalse($second->lockWritten); + $this->assertSame(['sample/greeter', 'sample/strings'], array_keys($second->manifest->packages)); + $this->assertSame($tree, $this->tree($this->module.'/bundle')); + } + + public function test_a_host_that_ships_an_incompatible_version_is_a_resolver_error(): void + { + $this->module(['sample/greeter' => '^1.0']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('could not resolve the bundle against this host'); + + $this->build($this->hostLock(['sample/strings' => '2.0.0'])); + } + + public function test_bundle_lock_is_refused_once_the_host_ships_a_bundled_package(): void + { + $this->module(['sample/greeter' => '^1.0']); + $this->build($this->hostLock([])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('sample/strings is in bundle.lock but this host already ships it'); + + $this->build($this->hostLock(['sample/strings' => '1.0.0'])); + } + + public function test_bundle_lock_is_refused_when_composer_json_no_longer_needs_a_package(): void + { + $this->module(['sample/greeter' => '^1.0', 'sample/extra' => '^1.0']); + $host = $this->hostLock([]); + $this->build($host); + $this->module(['sample/greeter' => '^1.0']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('no longer needs (sample/extra)'); + + $this->build($host); + } + + public function test_files_autoload_is_recorded_with_the_functions_it_declares_unconditionally(): void + { + $this->module(['sample/helpers' => '^1.0']); + + $report = $this->build($this->hostLock([])); + + $this->assertSame(['sample/helpers/src/functions.php'], $report->manifest->files); + $this->assertSame(['Sample\\Helpers\\shout'], $report->manifest->functions); + $this->assertSame(['proprietary', 'MIT'], $report->manifest->packages['sample/helpers']['license']); + } + + /** @param array $requires */ + #[DataProvider('refusedBundles')] + public function test_packages_the_host_cannot_load_safely_are_refused(array $requires, array $extensions, string $message): void + { + $this->module($requires, $extensions); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($message); + + $this->build($this->hostLock([])); + } + + /** @return iterable, list, string}> */ + public static function refusedBundles(): iterable + { + yield 'incompatible license' => [['sample/proprietary' => '^1.0'], [], 'which an AGPL-3.0-only module cannot ship']; + yield 'reserved namespace' => [['sample/sneaky' => '^1.0'], [], 'in a namespace reserved for InvoiceShelf']; + yield 'composer plugin' => [['sample/plugin' => '^1.0'], [], 'is a Composer plugin']; + yield 'undeclared extension' => [['sample/greeter' => '^1.0'], [], 'needs ext-mbstring; add it to compatibility.extensions']; + } + + public function test_the_module_composer_lock_must_hold_the_versions_that_ship(): void + { + $this->module(['sample/greeter' => '^1.0']); + file_put_contents($this->module.'/composer.lock', json_encode([ + 'packages' => [['name' => 'sample/greeter', 'version' => '0.9.0']], + 'packages-dev' => [], + ], JSON_THROW_ON_ERROR)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('composer.lock has sample/greeter 0.9.0 but bundle/ ships 1.0.0'); + + $this->build($this->hostLock([])); + } + + public function test_only_a_bundled_module_with_third_party_packages_can_be_bundled(): void + { + $this->module(['sample/greeter' => '^1.0'], ['ext-mbstring'], 'host-provided-only'); + + try { + $this->build($this->hostLock([])); + $this->fail('A host-provided-only module should not be bundled.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString("must declare dependency_policy 'bundled'", $exception->getMessage()); + } + + $this->module([]); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires no third-party packages'); + + $this->build($this->hostLock([])); + } + + public function test_a_built_module_passes_package_validation(): void + { + $this->module(['sample/greeter' => '^1.0']); + $this->build($this->hostLock(['sample/strings' => '1.0.0'])); + mkdir($this->module.'/dist'); + file_put_contents($this->module.'/dist/init.js', 'built javascript'); + mkdir($this->module.'/app/Lifecycle', 0700, true); + file_put_contents($this->module.'/app/Lifecycle/DataCleanup.php', <<<'PHP' +assertTrue(PackageValidator::validate($this->module)->bundlesDependencies()); + } + + private function build(string $hostLock): BundleReport + { + return (new BundleBuilder(ComposerProcess::locate()))->build($this->module, $hostLock); + } + + /** + * @param array $requires + * @param list $extensions + */ + private function module(array $requires, array $extensions = ['ext-mbstring'], string $policy = 'bundled'): void + { + file_put_contents($this->module.'/module.json', json_encode([ + 'name' => 'Probe', 'alias' => 'probe', 'description' => '', 'keywords' => [], 'priority' => 0, + 'providers' => ['Modules\\Probe\\Providers\\ProbeServiceProvider'], 'aliases' => [], 'files' => [], 'requires' => [], + 'schema_version' => 2, 'slug' => 'probe', 'version' => '1.0.0', 'license' => 'AGPL-3.0-only', + 'compatibility' => ['invoiceshelf' => '^3.0.0', 'module_api' => '^1.4.0', 'php' => '^8.4.0', 'extensions' => $extensions], + 'module_dependencies' => [], 'migration_policy' => 'reversible', + 'uninstall' => ['data_cleanup' => 'Modules\\Probe\\Lifecycle\\DataCleanup'], + 'dependency_policy' => $policy, 'assets' => ['dist/init.js'], + ], JSON_THROW_ON_ERROR)); + + $packages = __DIR__.'/Fixtures/bundle/packages'; + $repositories = array_map(fn (string $path): array => ['type' => 'path', 'url' => $path], glob($packages.'/*', GLOB_ONLYDIR) ?: []); + file_put_contents($this->module.'/composer.json', json_encode([ + 'name' => 'invoiceshelf/module-probe', + 'license' => 'AGPL-3.0-only', + 'require' => ['php' => '^8.4', 'invoiceshelf/modules' => '^3.5', ...$requires], + 'repositories' => [...$repositories, ['packagist.org' => false]], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + /** @param array $packages */ + private function hostLock(array $packages): string + { + $path = $this->directory.'/host-'.md5(serialize($packages)).'.lock'; + file_put_contents($path, json_encode([ + 'packages' => array_map(fn (string $name, string $version): array => ['name' => $name, 'version' => $version], array_keys($packages), $packages), + 'packages-dev' => [], + ], JSON_THROW_ON_ERROR)); + + return $path; + } + + /** @return array relative path => sha256 and mode */ + private function tree(string $directory): array + { + $tree = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + $tree[substr($file->getPathname(), strlen($directory) + 1)] = hash_file('sha256', $file->getPathname()).' '.decoct(fileperms($file->getPathname()) & 0777); + } + ksort($tree); + + return $tree; + } + + private function remove(string $path): void + { + if (! is_dir($path)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $entry) { + $entry->isDir() && ! $entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($path); + } +} diff --git a/tests/BundleValidationTest.php b/tests/BundleValidationTest.php new file mode 100644 index 0000000..8f8c7cb --- /dev/null +++ b/tests/BundleValidationTest.php @@ -0,0 +1,308 @@ +directory = sys_get_temp_dir().'/invoiceshelf-bundle-validation-'.bin2hex(random_bytes(8)); + mkdir($this->directory, 0700, true); + } + + protected function tearDown(): void + { + $this->remove($this->directory); + + parent::tearDown(); + } + + public function test_tasks_and_projects_0_2_1_still_validates_unchanged(): void + { + $manifest = PackageValidator::validate(__DIR__.'/Fixtures/tasks-projects-0.2.1'); + + $this->assertSame('tasks-projects', $manifest->slug); + $this->assertSame('host-provided-only', $manifest->dependencyPolicy); + $this->assertFalse($manifest->bundlesDependencies()); + } + + public function test_a_host_provided_module_may_not_carry_a_bundle(): void + { + $module = $this->copyTasksProjects(); + mkdir($module.'/bundle'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("dependency_policy 'host-provided-only' must not contain bundle/"); + + PackageValidator::validate($module); + } + + public function test_a_host_provided_module_still_may_not_require_third_party_packages(): void + { + $module = $this->copyTasksProjects(); + $composer = json_decode((string) file_get_contents($module.'/composer.json'), true); + $composer['require']['stripe/stripe-php'] = '^17.0'; + file_put_contents($module.'/composer.json', json_encode($composer, JSON_THROW_ON_ERROR)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("composer.json dependency 'stripe/stripe-php' is not host-provided."); + + PackageValidator::validate($module); + } + + public function test_a_well_formed_bundle_validates(): void + { + $manifest = PackageValidator::validate($this->bundledModule()); + + $this->assertTrue($manifest->bundlesDependencies()); + } + + /** @param Closure(string): void $break */ + #[DataProvider('brokenBundles')] + public function test_a_broken_bundle_is_refused(Closure $break, string $message): void + { + $module = $this->bundledModule(); + $break($module); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + PackageValidator::validate($module); + } + + /** @return iterable */ + public static function brokenBundles(): iterable + { + yield 'missing bundle' => [fn (string $m) => self::removeTree($m.'/bundle'), 'must contain a bundle/ directory']; + yield 'classmap path escapes its package' => [ + fn (string $m) => self::writeClassmap($m, ['Sample\\Greeter\\Greeter' => 'sample/greeter/../../../outside.php']), + 'not a PHP file inside a bundled package', + ]; + yield 'classmap runs code' => [ + fn (string $m) => file_put_contents($m.'/bundle/classmap.php', " [ + fn (string $m) => self::writeClassmap($m, ['Illuminate\\Support\\Str' => 'sample/greeter/src/Greeter.php']), + 'reserved for InvoiceShelf', + ]; + yield 'composer autoloader shipped' => [ + fn (string $m) => file_put_contents($m.'/bundle/autoload.php', ' [ + fn (string $m) => mkdir($m.'/bundle/evil/package', 0700, true), + 'bundle/evil/package is not a package declared in bundle.json', + ]; + yield 'declared package missing' => [ + fn (string $m) => self::editManifest($m, fn (array $b) => [...$b, 'packages' => [...$b['packages'], 'sample/ghost' => $b['packages']['sample/greeter']]]), + "declares 'sample/ghost', but bundle/sample/ghost is missing", + ]; + yield 'require neither bundled nor host provided' => [ + fn (string $m) => self::editComposer($m, ['sample/other' => '^1.0']), + "composer.json requires 'sample/other', but bundle/ neither ships it", + ]; + yield 'extension not declared' => [ + fn (string $m) => self::editModule($m, ['extensions' => []]), + 'Bundled packages need ext-mbstring', + ]; + yield 'functions out of date' => [ + function (string $m): void { + mkdir($m.'/bundle/sample/greeter/functions', 0700, true); + file_put_contents($m.'/bundle/sample/greeter/functions/helpers.php', " [...$b, 'files' => ['sample/greeter/functions/helpers.php']]); + }, + 'functions do not match', + ]; + yield 'symbolic link' => [ + fn (string $m) => symlink('/etc/passwd', $m.'/bundle/sample/greeter/src/link.php'), + 'is a symbolic link', + ]; + yield 'incompatible license' => [ + fn (string $m) => self::editManifest($m, fn (array $b) => [...$b, 'packages' => ['sample/greeter' => [...$b['packages']['sample/greeter'], 'license' => ['proprietary']]]]), + 'cannot ship', + ]; + yield 'bundled and host provided at once' => [ + fn (string $m) => self::editManifest($m, fn (array $b) => [...$b, 'host_packages' => ['sample/greeter' => ['invoiceshelf/module-probe' => '^1.0']]]), + 'cannot be both bundled and provided by the host', + ]; + yield 'invalid host constraint' => [ + fn (string $m) => self::editManifest($m, fn (array $b) => [...$b, 'host_packages' => ['psr/log' => ['sample/greeter' => 'not a constraint']]]), + "invalid requirement from 'sample/greeter'", + ]; + } + + /** @param list $licenses */ + #[DataProvider('licenses')] + public function test_license_policy_reads_composer_licenses_as_alternatives(array $licenses, bool $allowed): void + { + $this->assertSame($allowed, LicensePolicy::allows($licenses)); + } + + /** @return iterable, bool}> */ + public static function licenses(): iterable + { + yield 'MIT' => [['MIT'], true]; + yield 'dual licensed' => [['MIT', 'Apache-2.0'], true]; + yield 'one allowed alternative' => [['proprietary', 'MIT'], true]; + yield 'LGPL short form' => [['LGPL-3.0'], true]; + yield 'SPDX or expression' => [['(MIT or GPL-3.0-or-later)'], true]; + yield 'SPDX and expression with a disallowed part' => [['MIT and proprietary'], false]; + yield 'proprietary' => [['proprietary'], false]; + yield 'OSL is not GPL compatible' => [['OSL-3.0'], false]; + yield 'none declared' => [[], false]; + } + + public function test_bundle_json_is_written_with_sorted_keys_and_round_trips(): void + { + $manifest = BundleManifest::fromArray([ + 'schema' => 1, + 'built_against' => ['lock_sha256' => str_repeat('a', 64)], + 'packages' => [ + 'zeta/z' => ['version' => '1.0.0', 'reference' => null, 'license' => ['MIT'], 'php' => '>=8.1', 'extensions' => []], + 'alpha/a' => ['version' => '2.0.0', 'reference' => 'abc', 'license' => ['MIT'], 'php' => null, 'extensions' => ['ext-gd']], + ], + 'host_packages' => ['psr/log' => ['zeta/z' => '^1.0 || ^2.0', 'alpha/a' => '^3.0']], + 'files' => [], + 'functions' => [], + ]); + + $json = $manifest->toJson(); + $this->assertLessThan(strpos($json, '"zeta/z"'), strpos($json, '"alpha/a"')); + $this->assertSame(['alpha/a' => '^3.0', 'zeta/z' => '^1.0 || ^2.0'], json_decode($json, true)['host_packages']['psr/log']); + $this->assertSame($json, BundleManifest::fromArray(json_decode($json, true))->toJson()); + $this->assertSame(['ext-gd'], $manifest->extensions()); + } + + private function copyTasksProjects(): string + { + $target = $this->directory.'/TasksProjects'; + self::copyTree(__DIR__.'/Fixtures/tasks-projects-0.2.1', $target); + + return $target; + } + + /** A complete bundled module whose bundle/ ships sample/greeter and relies on the host for sample/strings. */ + private function bundledModule(): string + { + $module = $this->directory.'/Probe'; + mkdir($module.'/dist', 0700, true); + mkdir($module.'/app/Lifecycle', 0700, true); + mkdir($module.'/bundle/sample/greeter/src', 0700, true); + + file_put_contents($module.'/module.json', json_encode([ + 'name' => 'Probe', 'alias' => 'probe', 'description' => '', 'keywords' => [], 'priority' => 0, + 'providers' => ['Modules\\Probe\\Providers\\ProbeServiceProvider'], 'aliases' => [], 'files' => [], 'requires' => [], + 'schema_version' => 2, 'slug' => 'probe', 'version' => '1.0.0', 'license' => 'AGPL-3.0-only', + 'compatibility' => ['invoiceshelf' => '^3.0.0', 'module_api' => '^1.4.0', 'php' => '^8.4.0', 'extensions' => ['ext-mbstring']], + 'module_dependencies' => [], 'migration_policy' => 'reversible', + 'uninstall' => ['data_cleanup' => 'Modules\\Probe\\Lifecycle\\DataCleanup'], + 'dependency_policy' => 'bundled', 'assets' => ['dist/init.js'], + ], JSON_THROW_ON_ERROR)); + file_put_contents($module.'/composer.json', json_encode([ + 'name' => 'invoiceshelf/module-probe', 'license' => 'AGPL-3.0-only', + 'require' => ['php' => '^8.4', 'invoiceshelf/modules' => '^3.5', 'sample/greeter' => '^1.0'], + ], JSON_THROW_ON_ERROR)); + file_put_contents($module.'/dist/init.js', 'built javascript'); + file_put_contents($module.'/app/Lifecycle/DataCleanup.php', <<<'PHP' + 'sample/greeter/src/Greeter.php']); + file_put_contents($module.'/bundle/bundle.json', (new BundleManifest( + str_repeat('b', 64), + ['sample/greeter' => ['version' => '1.0.0', 'reference' => 'abc', 'license' => ['MIT'], 'php' => '>=8.1', 'extensions' => ['ext-mbstring']]], + ['sample/strings' => ['sample/greeter' => '^1.0']], + ))->toJson()); + + return $module; + } + + /** @param array $classmap */ + private static function writeClassmap(string $module, array $classmap): void + { + $lines = [" $path) { + $lines[] = ' '.var_export($class, true).' => '.var_export($path, true).",\n"; + } + file_put_contents($module.'/bundle/classmap.php', implode('', [...$lines, "];\n"])); + } + + /** @param Closure(array): array $edit */ + private static function editManifest(string $module, Closure $edit): void + { + $bundle = json_decode((string) file_get_contents($module.'/bundle/bundle.json'), true); + file_put_contents($module.'/bundle/bundle.json', json_encode($edit($bundle), JSON_THROW_ON_ERROR)); + } + + /** @param array $requires */ + private static function editComposer(string $module, array $requires): void + { + $composer = json_decode((string) file_get_contents($module.'/composer.json'), true); + $composer['require'] = [...$composer['require'], ...$requires]; + file_put_contents($module.'/composer.json', json_encode($composer, JSON_THROW_ON_ERROR)); + } + + /** @param array $compatibility */ + private static function editModule(string $module, array $compatibility): void + { + $manifest = json_decode((string) file_get_contents($module.'/module.json'), true); + $manifest['compatibility'] = [...$manifest['compatibility'], ...$compatibility]; + file_put_contents($module.'/module.json', json_encode($manifest, JSON_THROW_ON_ERROR)); + } + + private static function copyTree(string $source, string $target): void + { + mkdir($target, 0700, true); + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST); + foreach ($iterator as $entry) { + $destination = $target.'/'.substr($entry->getPathname(), strlen($source) + 1); + $entry->isDir() ? mkdir($destination, 0700, true) : copy($entry->getPathname(), $destination); + } + } + + private static function removeTree(string $path): void + { + if (! is_dir($path) || is_link($path)) { + is_link($path) || is_file($path) ? unlink($path) : null; + + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $entry) { + $entry->isDir() && ! $entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($path); + } + + private function remove(string $path): void + { + self::removeTree($path); + } +} diff --git a/tests/Fixtures/bundle/packages/extra/composer.json b/tests/Fixtures/bundle/packages/extra/composer.json new file mode 100644 index 0000000..40891b1 --- /dev/null +++ b/tests/Fixtures/bundle/packages/extra/composer.json @@ -0,0 +1 @@ +{"name": "sample/extra", "version": "1.0.0", "license": "BSD-3-Clause", "autoload": {"psr-4": {"Sample\\Extra\\": "src/"}}} diff --git a/tests/Fixtures/bundle/packages/extra/src/Extra.php b/tests/Fixtures/bundle/packages/extra/src/Extra.php new file mode 100644 index 0000000..00f5a86 --- /dev/null +++ b/tests/Fixtures/bundle/packages/extra/src/Extra.php @@ -0,0 +1,5 @@ +=8.1", "ext-mbstring": "*", "sample/strings": "^1.0"}, "autoload": {"psr-4": {"Sample\\Greeter\\": "src/"}}} diff --git a/tests/Fixtures/bundle/packages/greeter/src/Greeter.php b/tests/Fixtures/bundle/packages/greeter/src/Greeter.php new file mode 100644 index 0000000..dd2953f --- /dev/null +++ b/tests/Fixtures/bundle/packages/greeter/src/Greeter.php @@ -0,0 +1,13 @@ +settings->deleteCompanyForAll(ModuleSettings::PREFIX.$key); + } + } + + /** + * Every key stored under `module.tasks-projects.` for a company. + * + * The switches are read from the same table the getters and the settings + * schema use, so a new toggle is added in one place and is cleaned up here + * without anyone remembering to come back. + * + * @return list + */ + public static function settingKeys(): array + { + return [...self::VALUE_KEYS, ...array_keys(ModuleSettings::FLAGS)]; + } +} diff --git a/tests/Fixtures/tasks-projects-0.2.1/composer.json b/tests/Fixtures/tasks-projects-0.2.1/composer.json new file mode 100644 index 0000000..6cb719c --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/composer.json @@ -0,0 +1,49 @@ +{ + "name": "invoiceshelf/module-tasks-projects", + "description": "Official Tasks and Projects module for InvoiceShelf", + "license": "AGPL-3.0-only", + "authors": [ + { + "name": "InvoiceShelf", + "email": "hello@invoiceshelf.com" + } + ], + "require": { + "php": "^8.4", + "ext-json": "*", + "invoiceshelf/modules": "^3.4" + }, + "require-dev": { + "laravel/pint": "^1.26", + "orchestra/testbench": "^11.0", + "phpunit/phpunit": "^12.0" + }, + "autoload": { + "psr-4": { + "Modules\\TasksProjects\\": "app/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\TasksProjects\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "lint": "vendor/bin/pint --test" + }, + "config": { + "allow-plugins": { + "wikimedia/composer-merge-plugin": true + }, + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true, + "repositories": { + "invoiceshelf-modules": { + "type": "vcs", + "url": "https://github.com/InvoiceShelf/modules.git" + } + } +} diff --git a/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000001_create_tp_projects_table.php b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000001_create_tp_projects_table.php new file mode 100644 index 0000000..20145d6 --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000001_create_tp_projects_table.php @@ -0,0 +1,47 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->string('name'); + $table->string('identifier', 32)->nullable(); + $table->text('description')->nullable(); + $table->string('colour', 16)->nullable(); + $table->string('status', 16)->default('ACTIVE'); + $table->unsignedInteger('currency_id')->nullable(); + $table->bigInteger('default_rate')->nullable(); + $table->unsignedInteger('budget_minutes')->nullable(); + $table->date('due_date')->nullable(); + $table->unsignedInteger('creator_id')->nullable(); + $table->timestamps(); + + $table->index(['company_id', 'status']); + $table->index(['company_id', 'customer_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_projects'); + } +}; diff --git a/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000002_create_tp_project_members_table.php b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000002_create_tp_project_members_table.php new file mode 100644 index 0000000..1a6469b --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000002_create_tp_project_members_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('project_id'); + $table->unsignedInteger('user_id'); + $table->bigInteger('rate')->nullable(); + $table->timestamps(); + + $table->unique(['project_id', 'user_id']); + $table->index('company_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_project_members'); + } +}; diff --git a/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php new file mode 100644 index 0000000..466c088 --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->string('name'); + $table->string('colour', 16)->nullable(); + $table->unsignedInteger('position'); + $table->boolean('is_default')->default(false); + $table->boolean('is_closed')->default(false); + $table->timestamps(); + + $table->index(['company_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_task_statuses'); + } +}; diff --git a/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000004_create_tp_tasks_table.php b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000004_create_tp_tasks_table.php new file mode 100644 index 0000000..69f7266 --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000004_create_tp_tasks_table.php @@ -0,0 +1,51 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('project_id')->nullable(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->unsignedBigInteger('task_status_id'); + $table->unsignedInteger('number'); + $table->string('name'); + $table->text('description')->nullable(); + $table->unsignedInteger('assignee_id')->nullable(); + $table->string('priority', 16)->nullable(); + $table->date('due_date')->nullable(); + $table->unsignedInteger('estimated_minutes')->nullable(); + $table->boolean('billable')->default(true); + $table->bigInteger('rate')->nullable(); + $table->decimal('board_position', 20, 10)->default(0); + $table->dateTime('closed_at')->nullable(); + $table->unsignedInteger('creator_id')->nullable(); + $table->timestamps(); + + $table->index(['company_id', 'task_status_id', 'board_position']); + $table->index(['company_id', 'project_id']); + $table->index(['company_id', 'assignee_id']); + $table->index(['company_id', 'customer_id']); + $table->unique(['company_id', 'number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_tasks'); + } +}; diff --git a/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php new file mode 100644 index 0000000..e05d11a --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php @@ -0,0 +1,54 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('task_id'); + $table->unsignedBigInteger('project_id')->nullable(); + $table->unsignedInteger('user_id'); + $table->dateTime('started_at')->nullable(); + $table->dateTime('ended_at')->nullable(); + $table->unsignedInteger('duration_minutes')->default(0); + $table->text('description')->nullable(); + $table->boolean('billable')->default(true); + $table->bigInteger('rate')->default(0); + $table->bigInteger('amount')->default(0); + $table->unsignedInteger('currency_id')->nullable(); + $table->unsignedInteger('running_user_id')->nullable(); + $table->unsignedInteger('invoice_id')->nullable(); + $table->unsignedInteger('invoice_item_id')->nullable(); + $table->dateTime('invoiced_at')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'running_user_id']); + $table->index(['company_id', 'user_id', 'started_at']); + $table->index(['company_id', 'billable', 'invoice_id']); + $table->index(['company_id', 'task_id']); + $table->index(['company_id', 'project_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_time_entries'); + } +}; diff --git a/tests/Fixtures/tasks-projects-0.2.1/dist/init.js b/tests/Fixtures/tasks-projects-0.2.1/dist/init.js new file mode 100644 index 0000000..7498da2 --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/dist/init.js @@ -0,0 +1 @@ +// Placeholder: the real 0.2.1 build output is not needed to validate the package contract. diff --git a/tests/Fixtures/tasks-projects-0.2.1/dist/style.css b/tests/Fixtures/tasks-projects-0.2.1/dist/style.css new file mode 100644 index 0000000..50f3f2d --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/dist/style.css @@ -0,0 +1 @@ +/* Placeholder for the 0.2.1 build output. */ diff --git a/tests/Fixtures/tasks-projects-0.2.1/module.json b/tests/Fixtures/tasks-projects-0.2.1/module.json new file mode 100644 index 0000000..2cd2469 --- /dev/null +++ b/tests/Fixtures/tasks-projects-0.2.1/module.json @@ -0,0 +1,41 @@ +{ + "name": "TasksProjects", + "alias": "tasksprojects", + "description": "Projects, tasks, Kanban board, time tracking and task-to-invoice billing.", + "keywords": [ + "projects", + "tasks", + "time-tracking", + "kanban", + "billing" + ], + "priority": 0, + "providers": [ + "Modules\\TasksProjects\\Providers\\TasksProjectsServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": {}, + "schema_version": 2, + "slug": "tasks-projects", + "version": "0.2.1", + "license": "AGPL-3.0-only", + "compatibility": { + "invoiceshelf": ">=3.0.0-alpha.2 <4.0.0", + "module_api": "^1.3.0", + "php": "^8.4.0", + "extensions": [ + "ext-json" + ] + }, + "module_dependencies": {}, + "migration_policy": "reversible", + "uninstall": { + "data_cleanup": "Modules\\TasksProjects\\Lifecycle\\DataCleanup" + }, + "dependency_policy": "host-provided-only", + "assets": [ + "dist/init.js", + "dist/style.css" + ] +} diff --git a/tests/ManifestTest.php b/tests/ManifestTest.php index 9d279e5..045b310 100644 --- a/tests/ManifestTest.php +++ b/tests/ManifestTest.php @@ -136,6 +136,7 @@ public static function invalidModuleManifests(): iterable yield 'bad dependency range' => ['supported SemVer constraint', ['module_dependencies' => ['other-module' => 'dev-main']]]; yield 'legacy rollback migration policy' => ['schema_version=1 requires migration_policy', ['migration_policy' => 'reversible']]; yield 'runtime dependency policy' => ['host-provided-only', ['dependency_policy' => 'composer-install']]; + yield 'bundled dependencies on schema 1' => ["dependency_policy 'bundled' requires schema_version=2", ['dependency_policy' => 'bundled']]; yield 'remote asset' => ['local dist', ['assets' => ['https://cdn.example.test/module.js']]]; yield 'source asset' => ['local dist', ['assets' => ['resources/module.ts']]]; yield 'unknown key' => ['unsupported field', ['composer_dependencies' => []]]; @@ -158,6 +159,21 @@ public static function invalidSchemaV2ModuleManifests(): iterable yield 'missing data cleanup class' => ['data_cleanup', ['uninstall' => ['data_cleanup' => '']]]; yield 'data cleanup outside module namespace' => ['Modules\\SalesTaxUs namespace', ['uninstall' => ['data_cleanup' => 'App\\Cleanup']]]; yield 'unknown uninstall key' => ['uninstall contract contains unsupported field', ['uninstall' => ['data_cleanup' => 'Modules\\SalesTaxUs\\Cleanup', 'command' => 'rm -rf']]]; + yield 'bundled dependencies on an older module api' => ['compatibility.module_api to start at 1.4.0', ['dependency_policy' => 'bundled', 'compatibility' => ['module_api' => '^1.3.0']]]; + yield 'bundled dependencies on an open module api range' => ['compatibility.module_api to start at 1.4.0', ['dependency_policy' => 'bundled', 'compatibility' => ['module_api' => '<2.0.0']]]; + } + + public function test_schema_v2_module_manifest_may_bundle_dependencies_from_module_api_1_4(): void + { + $value = array_replace_recursive($this->reversibleModuleManifest(), [ + 'dependency_policy' => 'bundled', + 'compatibility' => ['module_api' => '^1.4.0'], + ]); + + $manifest = ModuleManifest::fromArray($value); + + $this->assertTrue($manifest->bundlesDependencies()); + $this->assertSame('bundled', $manifest->toArray()['dependency_policy']); } public function test_canonical_json_sorts_objects_and_preserves_lists_exactly(): void